define([ '../_StoreMixin', 'dojo/_base/declare', 'dojo/_base/array', 'dojo/_base/lang', 'dojo/dom-construct', 'dojo/dom-class', 'dojo/on', 'dojo/query', 'dojo/string', 'dojo/has', 'dojo/keys', 'dojo/when', '../util/misc', 'dojo/i18n!./nls/pagination', 'dojo/_base/sniff' ], function (_StoreMixin, declare, arrayUtil, lang, domConstruct, domClass, on, query, string, has, keys, when, miscUtil, i18n) { function cleanupContent(grid) { // Remove any currently-rendered rows, or noDataMessage if (!grid._removeNoDataNode()) { grid.cleanup(); } grid.contentNode.innerHTML = ''; } function cleanupLoading(grid) { if (grid.loadingNode) { grid._loadingCount--; if (!grid._loadingCount) { domConstruct.destroy(grid.loadingNode); delete grid.loadingNode; } } else if (grid._oldPageNodes) { // If cleaning up after a load w/ showLoadingMessage: false, // be careful to only clean up rows from the old page, not the new one for (var id in grid._oldPageNodes) { grid.removeRow(grid._oldPageNodes[id]); } delete grid._oldPageNodes; } delete grid._isLoading; } return declare(_StoreMixin, { // summary: // An extension for adding discrete pagination to a List or Grid. // rowsPerPage: Number // Number of rows (items) to show on a given page. rowsPerPage: 10, // pagingTextBox: Boolean // Indicates whether or not to show a textbox for paging. pagingTextBox: false, // previousNextArrows: Boolean // Indicates whether or not to show the previous and next arrow links. previousNextArrows: true, // firstLastArrows: Boolean // Indicates whether or not to show the first and last arrow links. firstLastArrows: false, // pagingLinks: Number // The number of page links to show on each side of the current page // Set to 0 (or false) to disable page links. pagingLinks: 2, // pageSizeOptions: Array[Number] // This provides options for different page sizes in a drop-down. // If it is empty (default), no page size drop-down will be displayed. pageSizeOptions: null, // showLoadingMessage: Boolean // If true, clears previous data and displays loading node when requesting // another page; if false, leaves previous data in place until new data // arrives, then replaces it immediately. showLoadingMessage: true, // i18nPagination: Object // This object contains all of the internationalized strings as // key/value pairs. i18nPagination: i18n, showFooter: true, _currentPage: 1, _loadingCount: 0, buildRendering: function () { this.inherited(arguments); // add pagination to footer var grid = this, paginationNode = this.paginationNode = domConstruct.create('div', { className: 'dgrid-pagination' }, this.footerNode), statusNode = this.paginationStatusNode = domConstruct.create('div', { className: 'dgrid-status' }, paginationNode), i18n = this.i18nPagination, navigationNode, node; if (typeof this._processScroll === 'function') { // Warn the user of an invalid class + mixin combination when they mix OnDemandList and this extension. this.bodyNode.innerHTML = i18n.notCompatibleWithOnDemand; console.warn(i18n.notCompatibleWithOnDemand); } statusNode.tabIndex = 0; // Initialize UI based on pageSizeOptions and rowsPerPage this._updatePaginationSizeSelect(); this._updateRowsPerPageOption(); // initialize some content into paginationStatusNode, to ensure // accurate results on initial resize call this._updatePaginationStatus(this._total); navigationNode = this.paginationNavigationNode = domConstruct.create('div', { className: 'dgrid-navigation' }, paginationNode); if (this.firstLastArrows) { // create a first-page link node = this.paginationFirstNode = domConstruct.create('span', { 'aria-label': i18n.gotoFirst, className: 'dgrid-first dgrid-page-link', innerHTML: '«', tabIndex: 0 }, navigationNode); } if (this.previousNextArrows) { // create a previous link node = this.paginationPreviousNode = domConstruct.create('span', { 'aria-label': i18n.gotoPrev, className: 'dgrid-previous dgrid-page-link', innerHTML: '‹', tabIndex: 0 }, navigationNode); } this.paginationLinksNode = domConstruct.create('span', { className: 'dgrid-pagination-links' }, navigationNode); if (this.previousNextArrows) { // create a next link node = this.paginationNextNode = domConstruct.create('span', { 'aria-label': i18n.gotoNext, className: 'dgrid-next dgrid-page-link', innerHTML: '›', tabIndex: 0 }, navigationNode); } if (this.firstLastArrows) { // create a last-page link node = this.paginationLastNode = domConstruct.create('span', { 'aria-label': i18n.gotoLast, className: 'dgrid-last dgrid-page-link', innerHTML: '»', tabIndex: 0 }, navigationNode); } /* jshint maxlen: 121 */ this._listeners.push(on(navigationNode, '.dgrid-page-link:click,.dgrid-page-link:keydown', function (event) { // For keyboard events, only respond to enter if (event.type === 'keydown' && event.keyCode !== 13) { return; } var cls = this.className, curr, max; if (grid._isLoading || cls.indexOf('dgrid-page-disabled') > -1) { return; } curr = grid._currentPage; max = Math.ceil(grid._total / grid.rowsPerPage); // determine navigation target based on clicked link's class if (this === grid.paginationPreviousNode) { grid.gotoPage(curr - 1); } else if (this === grid.paginationNextNode) { grid.gotoPage(curr + 1); } else if (this === grid.paginationFirstNode) { grid.gotoPage(1); } else if (this === grid.paginationLastNode) { grid.gotoPage(max); } else if (cls === 'dgrid-page-link') { grid.gotoPage(+this.innerHTML); // the innerHTML has the page number } })); }, destroy: function () { this.inherited(arguments); if (this._pagingTextBoxChangeHandle) { this._pagingTextBoxChangeHandle.remove(); } if (this._pagingTextBoxKeyPressHandle) { this._pagingTextBoxKeyPressHandle.remove(); } }, _updatePaginationSizeSelect: function () { // summary: // Creates or repopulates the pagination size selector based on // the values in pageSizeOptions. Called from buildRendering // and _setPageSizeOptions. var pageSizeOptions = this.pageSizeOptions, paginationSizeSelect = this.paginationSizeSelect, handle; if (pageSizeOptions && pageSizeOptions.length) { if (!paginationSizeSelect) { // First time setting page options; create the select paginationSizeSelect = this.paginationSizeSelect = domConstruct.create('select', { 'aria-label': this.i18nPagination.rowsPerPage, className: 'dgrid-page-size' }, this.paginationNode); handle = this._paginationSizeChangeHandle = on(paginationSizeSelect, 'change', lang.hitch(this, function () { this.set('rowsPerPage', +this.paginationSizeSelect.value); })); this._listeners.push(handle); } // Repopulate options paginationSizeSelect.options.length = 0; for (var i = 0; i < pageSizeOptions.length; i++) { domConstruct.create('option', { innerHTML: pageSizeOptions[i], selected: this.rowsPerPage === pageSizeOptions[i], value: pageSizeOptions[i] }, paginationSizeSelect); } // Ensure current rowsPerPage value is in options this._updateRowsPerPageOption(); } else if (!(pageSizeOptions && pageSizeOptions.length) && paginationSizeSelect) { // pageSizeOptions was removed; remove/unhook the drop-down domConstruct.destroy(paginationSizeSelect); this.paginationSizeSelect = null; this._paginationSizeChangeHandle.remove(); } }, _setPageSizeOptions: function (pageSizeOptions) { this.pageSizeOptions = pageSizeOptions && pageSizeOptions.sort(function (a, b) { return a - b; }); this._updatePaginationSizeSelect(); }, _updateRowsPerPageOption: function () { // summary: // Ensures that an option for rowsPerPage's value exists in the // paginationSizeSelect drop-down (if one is rendered). // Called from buildRendering and _setRowsPerPage. var rowsPerPage = this.rowsPerPage, pageSizeOptions = this.pageSizeOptions, paginationSizeSelect = this.paginationSizeSelect; if (paginationSizeSelect) { if (arrayUtil.indexOf(pageSizeOptions, rowsPerPage) < 0) { this._setPageSizeOptions(pageSizeOptions.concat([rowsPerPage])); } else { paginationSizeSelect.value = '' + rowsPerPage; } } }, _setRowsPerPage: function (rowsPerPage) { this.rowsPerPage = rowsPerPage; this._updateRowsPerPageOption(); this.gotoPage(1); }, _updateNavigation: function (total) { // summary: // Update status and navigation controls based on total count from query var grid = this, i18n = this.i18nPagination, linksNode = this.paginationLinksNode, currentPage = this._currentPage, pagingLinks = this.pagingLinks, paginationNavigationNode = this.paginationNavigationNode, end = Math.ceil(total / this.rowsPerPage), pagingTextBoxKeyPressHandle = this._pagingTextBoxKeyPressHandle, pagingTextBoxChangeHandle = this._pagingTextBoxChangeHandle, focused = document.activeElement, focusedPage, lastFocusablePageLink, focusableNodes; function _gotoPage(page) { page = +page; if (!isNaN(page) && page > 0 && page <= end) { grid.gotoPage(page); } } function pageLink(page, addSpace) { var link; var disabled; var requirePageChange = true; if (grid.pagingTextBox && page === currentPage && end > 1) { // use a paging text box if enabled instead of just a number link = domConstruct.create('input', { 'aria-label': i18n.jumpPage, className: 'dgrid-page-input', type: 'text', value: currentPage }, linksNode); grid._pagingTextBoxChangeHandle = on(link, 'change', function () { if (requirePageChange) { _gotoPage(+this.value); } requirePageChange = true; }); grid._pagingTextBoxKeyPressHandle = on(link, 'keypress', function (evt) { if (evt.keyCode === keys.ENTER) { requirePageChange = false; _gotoPage(+this.value); } }); if (focused && focused.tagName === 'INPUT') { link.focus(); } } else { // normal link disabled = page === currentPage; link = domConstruct.create('span', { 'aria-label': i18n.gotoPage, className: 'dgrid-page-link' + (disabled ? ' dgrid-page-disabled' : ''), innerHTML: page + (addSpace ? ' ' : ''), tabIndex: disabled ? -1 : 0 }, linksNode); // Try to restore focus if applicable; // if we need to but can't, try on the previous or next page, // depending on whether we're at the end if (focusedPage === page) { if (!disabled) { link.focus(); } else if (page < end) { focusedPage++; } else { lastFocusablePageLink.focus(); } } if (!disabled) { lastFocusablePageLink = link; } } } function setDisabled(link, disabled) { domClass.toggle(link, 'dgrid-page-disabled', disabled); link.tabIndex = disabled ? -1 : 0; } function addSkipNode() { // Adds visual indication of skipped page numbers in navigation area domConstruct.create('span', { className: 'dgrid-page-skip', innerHTML: '...' }, linksNode); } if (!focused || !this.paginationNavigationNode.contains(focused)) { focused = null; } else if (focused.className === 'dgrid-page-link') { focusedPage = +focused.innerHTML; } if (pagingTextBoxKeyPressHandle) { pagingTextBoxKeyPressHandle.remove(); } if (pagingTextBoxChangeHandle) { pagingTextBoxChangeHandle.remove(); } linksNode.innerHTML = ''; query('.dgrid-first, .dgrid-previous', paginationNavigationNode).forEach(function (link) { setDisabled(link, currentPage === 1); }); query('.dgrid-last, .dgrid-next', paginationNavigationNode).forEach(function (link) { setDisabled(link, currentPage >= end); }); if (pagingLinks && end > 0) { // always include the first page (back to the beginning) pageLink(1, true); var start = currentPage - pagingLinks; if (start > 2) { addSkipNode(); } else { start = 2; } // now iterate through all the page links we should show for (var i = start; i < Math.min(currentPage + pagingLinks + 1, end); i++) { pageLink(i, true); } if (currentPage + pagingLinks + 1 < end) { addSkipNode(); } // last link if (end > 1) { pageLink(end); } } else if (grid.pagingTextBox) { // The pageLink function is also used to create the paging textbox. pageLink(currentPage); } if (focused && focused.tabIndex === -1) { // One of the first/last or prev/next links was focused but // is now disabled, so find something focusable focusableNodes = query('[tabindex="0"]', this.paginationNavigationNode); if (focused === this.paginationPreviousNode || focused === this.paginationFirstNode) { focused = focusableNodes[0]; } else if (focusableNodes.length) { focused = focusableNodes[focusableNodes.length - 1]; } if (focused) { focused.focus(); } } }, _updatePaginationStatus: function (total) { var count = this.rowsPerPage; var start = Math.min(total, (this._currentPage - 1) * count + 1); this.paginationStatusNode.innerHTML = string.substitute(this.i18nPagination.status, { start: start, end: Math.min(total, start + count - 1), total: total }); }, refresh: function (options) { // summary: // Re-renders the first page of data, or the current page if // options.keepCurrentPage is true. var self = this; var page = options && options.keepCurrentPage ? Math.min(this._currentPage, Math.ceil(this._total / this.rowsPerPage)) : 1; this.inherited(arguments); // Reset to first page and return promise from gotoPage return this.gotoPage(page).then(function (results) { self._emitRefreshComplete(); return results; }); }, _onNotification: function (rows, event, collection) { var rowsPerPage = this.rowsPerPage; var pageEnd = this._currentPage * rowsPerPage; var needsRefresh = (event.type === 'add' && event.index < pageEnd) || (event.type === 'delete' && event.previousIndex < pageEnd) || (event.type === 'update' && Math.floor(event.index / rowsPerPage) !== Math.floor(event.previousIndex / rowsPerPage)); if (needsRefresh) { // Refresh the current page to maintain correct number of rows on page this.gotoPage(Math.min(this._currentPage, Math.ceil(event.totalLength / this.rowsPerPage)) || 1); } // If we're not updating the whole page, check if we at least need to update status/navigation else if (collection === this._renderedCollection && event.totalLength !== this._total) { this._updatePaginationStatus(event.totalLength); this._updateNavigation(event.totalLength); } }, renderQueryResults: function (results, beforeNode) { var grid = this, rows = this.inherited(arguments); if (!beforeNode) { if (this._topLevelRequest) { // Cancel previous async request that didn't finish this._topLevelRequest.cancel(); delete this._topLevelRequest; } if (typeof rows.cancel === 'function') { // Store reference to new async request in progress this._topLevelRequest = rows; } rows.then(function () { if (grid._topLevelRequest) { // Remove reference to request now that it's finished delete grid._topLevelRequest; } }); } return rows; }, insertRow: function () { var oldNodes = this._oldPageNodes, row = this.inherited(arguments); if (oldNodes && row === oldNodes[row.id]) { // If the previous row was reused, avoid removing it in cleanup delete oldNodes[row.id]; } return row; }, gotoPage: function (page) { // summary: // Loads the given page. Note that page numbers start at 1. var grid = this, start = (this._currentPage - 1) * this.rowsPerPage; if (!this._renderedCollection) { console.warn('Pagination requires a collection to operate.'); return when([]); } if (this._renderedCollection.releaseRange) { this._renderedCollection.releaseRange(start, start + this.rowsPerPage); } return this._trackError(function () { var count = grid.rowsPerPage, start = (page - 1) * count, options = { start: start, count: count }, results, contentNode = grid.contentNode, loadingNode, oldNodes, children, i, len; if (grid.showLoadingMessage) { grid._loadingCount++; cleanupContent(grid); loadingNode = grid.loadingNode = domConstruct.create('div', { className: 'dgrid-loading', innerHTML: grid.loadingMessage }, contentNode); } else { // Reference nodes to be cleared later, rather than now; // iterate manually since IE < 9 doesn't like slicing HTMLCollections grid._oldPageNodes = oldNodes = {}; children = contentNode.children; for (i = 0, len = children.length; i < len; i++) { oldNodes[children[i].id] = children[i]; } } // set flag to deactivate pagination event handlers until loaded grid._isLoading = true; results = grid._renderedCollection.fetchRange({ start: start, end: start + count }); return grid.renderQueryResults(results, null, options).then(function (rows) { cleanupLoading(grid); // Reset scroll Y-position now that new page is loaded. grid.scrollTo({ y: 0 }); if (grid._rows) { grid._rows.min = start; grid._rows.max = start + count - 1; } results.totalLength.then(function (total) { if (!total) { grid._insertNoDataNode(); } // Update status text based on now-current page and total. grid._total = total; grid._currentPage = page; grid._rowsOnPage = rows.length; grid._updatePaginationStatus(total); // It's especially important that _updateNavigation is called only // after renderQueryResults is resolved as well (to prevent jumping). grid._updateNavigation(total); }); return results; }, function (error) { cleanupLoading(grid); throw error; }); }); } }); });