UNPKG

@hashicorp/design-system-components

Version:
302 lines (289 loc) 14.8 kB
import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { action } from '@ember/object'; import { assert } from '@ember/debug'; import { HdsPaginationDirectionValues } from '../types.js'; import { precompileTemplate } from '@ember/template-compilation'; import { g, i, n } from 'decorator-transforms/runtime'; import { setComponentTemplate } from '@ember/component'; var TEMPLATE = precompileTemplate("{{!\n Copyright (c) HashiCorp, Inc.\n SPDX-License-Identifier: MPL-2.0\n}}\n<div class=\"hds-pagination\" ...attributes>\n {{#if this.showInfo}}\n <Hds::Pagination::Info\n @itemsRangeStart={{this.itemsRangeStart}}\n @itemsRangeEnd={{this.itemsRangeEnd}}\n @totalItems={{@totalItems}}\n @showTotalItems={{@showTotalItems}}\n />\n {{/if}}\n\n <nav class=\"hds-pagination-nav\" aria-label={{this.ariaLabel}}>\n <Hds::Pagination::Nav::Arrow\n @direction=\"prev\"\n @showLabel={{this.showLabels}}\n @route={{this.routing.route}}\n @query={{this.routing.queryPrev}}\n @model={{this.routing.model}}\n @models={{this.routing.models}}\n @replace={{this.routing.replace}}\n @onClick={{this.onPageChange}}\n @disabled={{this.isDisabledPrev}}\n />\n {{#if this.showPageNumbers}}\n <ul class=\"hds-pagination-nav__page-list\">\n {{#each this.pages as |page|}}\n <li class=\"hds-pagination-nav__page-item\">\n {{#if (eq page \"…\")}}\n <Hds::Pagination::Nav::Ellipsis />\n {{else}}\n <Hds::Pagination::Nav::Number\n @page={{this.elliptizedPageArrayItemAsNumber page}}\n @route={{this.routing.route}}\n @query={{get this.routing.queryPages page}}\n @model={{this.routing.model}}\n @models={{this.routing.models}}\n @replace={{this.routing.replace}}\n @onClick={{this.onPageChange}}\n @isSelected={{if (eq page this.currentPage) true false}}\n />\n {{/if}}\n </li>\n {{/each}}\n </ul>\n {{/if}}\n <Hds::Pagination::Nav::Arrow\n @direction=\"next\"\n @showLabel={{this.showLabels}}\n @route={{this.routing.route}}\n @query={{this.routing.queryNext}}\n @model={{this.routing.model}}\n @models={{this.routing.models}}\n @replace={{this.routing.replace}}\n @onClick={{this.onPageChange}}\n @disabled={{this.isDisabledNext}}\n />\n </nav>\n\n {{#if this.showSizeSelector}}\n <Hds::Pagination::SizeSelector\n @pageSizes={{this.pageSizes}}\n @label={{@sizeSelectorLabel}}\n @selectedSize={{this.currentPageSize}}\n @onChange={{this.onPageSizeChange}}\n />\n {{/if}}\n</div>"); /** * Copyright (c) HashiCorp, Inc. * SPDX-License-Identifier: MPL-2.0 */ const ELLIPSIS = '…'; // for context about the decision to use these values, see: // https://hashicorp.slack.com/archives/C03A0N1QK8S/p1673546329082759 const DEFAULT_PAGE_SIZES = [10, 30, 50]; const elliptize = ({ pages, current, limit = 7 }) => { const length = pages.length; let result = []; let start; let end; if (length <= limit) { return pages; } if (current <= length / 2) { start = Math.ceil(limit / 2); end = limit - start; } else { end = Math.ceil(limit / 2); start = limit - end; } const sliceStart = pages.slice(0, start); const sliceEnd = pages.slice(-end); if (sliceStart.includes(current) && sliceStart.includes(current + 1)) { // "current" (and its next sibling) is contained within the "sliceStart" block sliceEnd.splice(0, 1, ELLIPSIS); result = [].concat(sliceStart, sliceEnd); } else if (sliceEnd.includes(current - 1) && sliceEnd.includes(current)) { // "current" (and its prev sibling) is contained within the "sliceEnd" block sliceStart.splice(-1, 1, ELLIPSIS); result = [].concat(sliceStart, sliceEnd); } else { // this is a bit more tricky :) // we need to calculate how many items there are before/after the current item // since both the initial and ending blocks are always 2 items long (number + ellipsis) // and there is always the "current" item, we can just subtract 5 from the limit const delta = (limit - 5) / 2; // this is why the limit needs to be an odd number // we slice the array starting at the "current" index, minus the delta, minus one because it's an array (zero-based) const sliceCurr = pages.slice(current - delta - 1, current + delta); result = [].concat(sliceStart.shift(), ELLIPSIS, sliceCurr, ELLIPSIS, sliceEnd.pop()); } return result; }; class HdsPaginationNumbered extends Component { static { g(this.prototype, "_currentPage", [tracked]); } #_currentPage = (i(this, "_currentPage"), void 0); // These two private variables are used to differentiate between // "uncontrolled" component (where the state is handled internally) and // "controlled" component (where the state is handled externally, by the consumer's code). // In the first case, these variables store the internal state of the component at any moment, // and their value is updated internally according to the user's interaction with the component. // In the second case, these variables store *only* the initial state of the component (coming from the arguments) // at rendering time, but from that moment on they're not updated anymore, no matter what interaction the user // has with the component (the state is controlled externally, eg. via query parameters) static { g(this.prototype, "_currentPageSize", [tracked]); } #_currentPageSize = (i(this, "_currentPageSize"), void 0); static { g(this.prototype, "_isControlled", [tracked]); } #_isControlled = (i(this, "_isControlled"), void 0); showInfo = this.args.showInfo ?? true; // if the "info" block is visible showLabels = this.args.showLabels ?? false; // if the labels for the "prev/next" controls are visible showSizeSelector = this.args.showSizeSelector ?? true; // if the "size selector" block is visible showPageNumbers = this.args.showPageNumbers ?? true; // if the "page numbers" block is visible isTruncated = this.args.isTruncated ?? true; // if the list of "page numbers" is truncated constructor(owner, args) { super(owner, args); const { queryFunction } = this.args; // This component works in two different ways, depending if we need to support // routing through links (`LinkTo`) for the "navigation controls", or not. // If there's no routing then the component behaves as "uncontrolled" // (the state updates - eg to the "currentPage" and "currentPageSize" // are handled by its internal logic). // If instead the component needs to update the routing (and we infer this via the "query" arguments) // then the component behaves as "controlled", where the state is // initialized and updated using the arguments passed to it. if (queryFunction === undefined) { this._isControlled = false; } else { assert('@model, @models, or @route for "Hds::Pagination::Numbered" must be provided when using the @queryFunction argument', this.args.model !== undefined || this.args.models !== undefined || this.args.route !== undefined); assert('@queryFunction for "Hds::Pagination::Numbered" must be a function', typeof queryFunction === 'function'); assert('@currentPage and @currentPageSize for "Hds::Pagination::Numbered" must be provided as numeric arguments when the pagination controls the routing', typeof this.args.currentPageSize === 'number' && typeof this.args.currentPage === 'number'); this._isControlled = true; } assert('@totalItems for "Hds::Pagination::Numbered" must be defined as an integer number', typeof this.args.totalItems === 'number'); this._currentPage = this.args.currentPage ?? 1; // we assert that `this.pageSizes` will always be an array with at least one item this._currentPageSize = this.args.currentPageSize ?? this.pageSizes[0]; } get ariaLabel() { return this.args.ariaLabel ?? 'Pagination'; } // This very specific `get/set` pattern is used to handle the two different use cases of the component // being "controlled" (when it has routing, meaning it needs to support pagination controls as links/`LinkTo`) // vs being "uncontrolled" (see comments above for details). // // If it has routing (and so it's "controlled"), than the value ("state") of the `currentPage/currentPageSize` variables // is *always* determined by the controller via arguments (most of the times, connected to query parameters in the URL). // For this reason the "get" method always returns the value from the `args`, // while the "set" method never updates the private internal state (_variable). // // If instead it doesn't have routing (and so it's "uncontrolled") than the value ("state") of the `currentPage/currentPageSize` variables // is *always* determined by the component's internal logic (and updated according to the user interaction with it). // For this reason the "get" and "set" methods always read from or write to the private internal state (_variable). get currentPage() { if (this._isControlled) { // if the component is controlled, `@currentPage` is asserted to be a number return this.args.currentPage; } else { return this._currentPage; } } set currentPage(value) { if (this._isControlled) ; else { // if `this._isControlled` is `false` this._currentPage = value; } } get currentPageSize() { if (this._isControlled) { // if the component is controlled, `@currentPageSize` is asserted to be a number return this.args.currentPageSize; } else { return this._currentPageSize; } } set currentPageSize(value) { if (this._isControlled) ; else { this._currentPageSize = value; } } get pageSizes() { const { pageSizes = DEFAULT_PAGE_SIZES } = this.args; assert( // TODO: Add test for this // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `pageSizes argument must be an array with at least one item. Received: ${pageSizes}`, Array.isArray(pageSizes) === true && pageSizes.length > 0); return pageSizes; } get itemsRangeStart() { // Calculate the starting range of items displayed on current page // if currentPage = 1st page and # of items per page is 10: // ( (1 - 1 = 0) * 10 = 0 ) + 1 = 1 // if current page = 2nd page: // ( (2 - 1 = 1) * 10 = 10 ) + 1 = 11 return (this.currentPage - 1) * this.currentPageSize + 1; } get itemsRangeEnd() { // Calculate ending range of items displayed on current page // 2 cases: 1) full page of items or 2) last page of items if (this.currentPage * this.currentPageSize < this.args.totalItems) { // 1) full page of items (pages 1 to page before last): return this.itemsRangeStart + this.currentPageSize - 1; } else { // 2) last page of items: return this.args.totalItems; } } get pages() { const pages = []; for (let i = 1; i <= this.totalPages; i++) { pages.push(i); } if (this.isTruncated) { return elliptize({ pages, current: this.currentPage }); } else { return pages; } } get totalPages() { return Math.max(Math.ceil(this.args.totalItems / this.currentPageSize), 1); } buildQueryParamsObject(page, pageSize) { // `page` may also be ellipsis if (this._isControlled && typeof page === 'number') { // if the component is controlled, `@queryFunction` is asserted to be a function return this.args.queryFunction(page, pageSize); } else { return {}; } } get routing() { const routing = { route: this.args.route ?? undefined, model: this.args.model ?? undefined, models: this.args.models ?? undefined, replace: this.args.replace ?? undefined }; // the "query" is dynamic and needs to be calculated if (this._isControlled) { routing.queryPrev = this.buildQueryParamsObject(this.currentPage - 1, this.currentPageSize); routing.queryNext = this.buildQueryParamsObject(this.currentPage + 1, this.currentPageSize); // IMPORTANT: here we need to use an object and not an array // otherwise the {{get object page}} will be shifted by one // (the pages are 1-based while the array would be zero-based) routing.queryPages = {}; this.pages.forEach(page => routing.queryPages[page] = this.buildQueryParamsObject(page, this.currentPageSize)); } else { routing.queryPrev = undefined; routing.queryNext = undefined; } return routing; } get isDisabledPrev() { return this.currentPage === 1; } get isDisabledNext() { return this.currentPage === this.totalPages; } onPageChange(page) { let gotoPageNumber; if (page === HdsPaginationDirectionValues.Prev && this.currentPage > 1) { gotoPageNumber = this.currentPage - 1; } else if (page === HdsPaginationDirectionValues.Next && this.currentPage < this.totalPages) { gotoPageNumber = this.currentPage + 1; } else { gotoPageNumber = page; } // we want to invoke the `onPageChange` callback only on actual page change if (gotoPageNumber !== this.currentPage) { // we have already determined that `gotoPageNumber` is not `prev` or `next` this.currentPage = gotoPageNumber; const { onPageChange } = this.args; if (typeof onPageChange === 'function') { onPageChange(this.currentPage, this.currentPageSize); } } } static { n(this.prototype, "onPageChange", [action]); } onPageSizeChange(newPageSize) { const { onPageSizeChange } = this.args; if (!this._isControlled) { // notice: we agreed to reset the pagination to the first element (any alternative would result in an unpredictable UX) this.currentPage = 1; this.currentPageSize = newPageSize; } // invoke the callback function if (typeof onPageSizeChange === 'function') { onPageSizeChange(newPageSize); } } static { n(this.prototype, "onPageSizeChange", [action]); } elliptizedPageArrayItemAsNumber = item => { if (typeof item === 'number') { return item; } else { throw new Error('Expected a number, but got an ellipsis'); } }; getPageNumberQuery(page) { return this.routing.queryPages[this.elliptizedPageArrayItemAsNumber(page)]; } } setComponentTemplate(TEMPLATE, HdsPaginationNumbered); export { DEFAULT_PAGE_SIZES, HdsPaginationNumbered as default, elliptize }; //# sourceMappingURL=index.js.map