lib/amd/src/paged_content_paging_bar.js

  1. // This file is part of Moodle - http://moodle.org/
  2. //
  3. // Moodle is free software: you can redistribute it and/or modify
  4. // it under the terms of the GNU General Public License as published by
  5. // the Free Software Foundation, either version 3 of the License, or
  6. // (at your option) any later version.
  7. //
  8. // Moodle is distributed in the hope that it will be useful,
  9. // but WITHOUT ANY WARRANTY; without even the implied warranty of
  10. // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  11. // GNU General Public License for more details.
  12. //
  13. // You should have received a copy of the GNU General Public License
  14. // along with Moodle. If not, see <http://www.gnu.org/licenses/>.
  15. /**
  16. * Javascript to enhance the paged content paging bar.
  17. *
  18. * @module core/paging_bar
  19. * @copyright 2018 Ryan Wyllie <ryan@moodle.com>
  20. * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  21. */
  22. define([
  23. 'jquery',
  24. 'core/custom_interaction_events',
  25. 'core/paged_content_events',
  26. 'core/str',
  27. 'core/pubsub',
  28. 'core/pending',
  29. ],
  30. function(
  31. $,
  32. CustomEvents,
  33. PagedContentEvents,
  34. Str,
  35. PubSub,
  36. Pending
  37. ) {
  38. var SELECTORS = {
  39. ROOT: '[data-region="paging-bar"]',
  40. PAGE: '[data-page]',
  41. PAGE_ITEM: '[data-region="page-item"]',
  42. PAGE_LINK: '[data-region="page-link"]',
  43. FIRST_BUTTON: '[data-control="first"]',
  44. LAST_BUTTON: '[data-control="last"]',
  45. NEXT_BUTTON: '[data-control="next"]',
  46. PREVIOUS_BUTTON: '[data-control="previous"]',
  47. DOTS_BUTTONS: '[data-dots]',
  48. BEGINNING_DOTS_BUTTON: '[data-dots="beginning"]',
  49. ENDING_DOTS_BUTTON: '[data-dots="ending"]',
  50. };
  51. /**
  52. * Get the page element by number.
  53. *
  54. * @param {object} root The root element.
  55. * @param {Number} pageNumber The page number.
  56. * @return {jQuery}
  57. */
  58. var getPageByNumber = function(root, pageNumber) {
  59. return root.find(SELECTORS.PAGE_ITEM + '[data-page-number="' + pageNumber + '"]');
  60. };
  61. /**
  62. * Get the next button element.
  63. *
  64. * @param {object} root The root element.
  65. * @return {jQuery}
  66. */
  67. var getNextButton = function(root) {
  68. return root.find(SELECTORS.NEXT_BUTTON);
  69. };
  70. /**
  71. * Set the last page number after which no more pages
  72. * should be loaded.
  73. *
  74. * @param {object} root The root element.
  75. * @param {Number} number Page number.
  76. */
  77. var setLastPageNumber = function(root, number) {
  78. root.attr('data-last-page-number', number);
  79. };
  80. /**
  81. * Get the last page number.
  82. *
  83. * @param {object} root The root element.
  84. * @return {Number}
  85. */
  86. var getLastPageNumber = function(root) {
  87. return parseInt(root.attr('data-last-page-number'), 10);
  88. };
  89. /**
  90. * Get the active page number.
  91. *
  92. * @param {object} root The root element.
  93. * @returns {Number} The page number
  94. */
  95. var getActivePageNumber = function(root) {
  96. return parseInt(root.attr('data-active-page-number'), 10);
  97. };
  98. /**
  99. * Set the active page number.
  100. *
  101. * @param {object} root The root element.
  102. * @param {Number} number Page number.
  103. */
  104. var setActivePageNumber = function(root, number) {
  105. root.attr('data-active-page-number', number);
  106. };
  107. /**
  108. * Check if there is an active page number.
  109. *
  110. * @param {object} root The root element.
  111. * @returns {bool}
  112. */
  113. var hasActivePageNumber = function(root) {
  114. var number = getActivePageNumber(root);
  115. return !isNaN(number) && number != 0;
  116. };
  117. /**
  118. * Get the page number for a given page.
  119. *
  120. * @param {object} root The root element.
  121. * @param {object} page The page element.
  122. * @returns {Number} The page number
  123. */
  124. var getPageNumber = function(root, page) {
  125. if (page.attr('data-page') != undefined) {
  126. // If it's an actual page then we can just use the page number
  127. // attribute.
  128. return parseInt(page.attr('data-page-number'), 10);
  129. }
  130. var pageNumber = 1;
  131. var activePageNumber = null;
  132. switch (page.attr('data-control')) {
  133. case 'first':
  134. pageNumber = 1;
  135. break;
  136. case 'last':
  137. pageNumber = getLastPageNumber(root);
  138. break;
  139. case 'next':
  140. activePageNumber = getActivePageNumber(root);
  141. var lastPage = getLastPageNumber(root);
  142. if (!lastPage) {
  143. pageNumber = activePageNumber + 1;
  144. } else if (activePageNumber && activePageNumber < lastPage) {
  145. pageNumber = activePageNumber + 1;
  146. } else {
  147. pageNumber = lastPage;
  148. }
  149. break;
  150. case 'previous':
  151. activePageNumber = getActivePageNumber(root);
  152. if (activePageNumber && activePageNumber > 1) {
  153. pageNumber = activePageNumber - 1;
  154. } else {
  155. pageNumber = 1;
  156. }
  157. break;
  158. default:
  159. pageNumber = 1;
  160. break;
  161. }
  162. // Make sure we return an int not a string.
  163. return parseInt(pageNumber, 10);
  164. };
  165. /**
  166. * Get the limit of items for each page.
  167. *
  168. * @param {object} root The root element.
  169. * @returns {Number}
  170. */
  171. var getLimit = function(root) {
  172. return parseInt(root.attr('data-items-per-page'), 10);
  173. };
  174. /**
  175. * Set the limit of items for each page.
  176. *
  177. * @param {object} root The root element.
  178. * @param {Number} limit Items per page limit.
  179. */
  180. var setLimit = function(root, limit) {
  181. root.attr('data-items-per-page', limit);
  182. };
  183. /**
  184. * Show the paging bar.
  185. *
  186. * @param {object} root The root element.
  187. */
  188. var show = function(root) {
  189. root.removeClass('hidden');
  190. };
  191. /**
  192. * Hide the paging bar.
  193. *
  194. * @param {object} root The root element.
  195. */
  196. var hide = function(root) {
  197. root.addClass('hidden');
  198. };
  199. /**
  200. * Disable the next and last buttons in the paging bar.
  201. *
  202. * @param {object} root The root element.
  203. */
  204. var disableNextControlButtons = function(root) {
  205. var nextButton = root.find(SELECTORS.NEXT_BUTTON);
  206. var lastButton = root.find(SELECTORS.LAST_BUTTON);
  207. nextButton.addClass('disabled');
  208. nextButton.attr('aria-disabled', true);
  209. lastButton.addClass('disabled');
  210. lastButton.attr('aria-disabled', true);
  211. };
  212. /**
  213. * Enable the next and last buttons in the paging bar.
  214. *
  215. * @param {object} root The root element.
  216. */
  217. var enableNextControlButtons = function(root) {
  218. var nextButton = root.find(SELECTORS.NEXT_BUTTON);
  219. var lastButton = root.find(SELECTORS.LAST_BUTTON);
  220. nextButton.removeClass('disabled');
  221. nextButton.removeAttr('aria-disabled');
  222. lastButton.removeClass('disabled');
  223. lastButton.removeAttr('aria-disabled');
  224. };
  225. /**
  226. * Disable the previous and first buttons in the paging bar.
  227. *
  228. * @param {object} root The root element.
  229. */
  230. var disablePreviousControlButtons = function(root) {
  231. var previousButton = root.find(SELECTORS.PREVIOUS_BUTTON);
  232. var firstButton = root.find(SELECTORS.FIRST_BUTTON);
  233. previousButton.addClass('disabled');
  234. previousButton.attr('aria-disabled', true);
  235. firstButton.addClass('disabled');
  236. firstButton.attr('aria-disabled', true);
  237. };
  238. /**
  239. * Adjusts the size of the paging bar and hides unnecessary pages.
  240. *
  241. * @param {object} root The root element.
  242. */
  243. var adjustPagingBarSize = function(root) {
  244. var activePageNumber = getActivePageNumber(root);
  245. var lastPageNumber = getLastPageNumber(root);
  246. var dotsButtons = root.find(SELECTORS.DOTS_BUTTONS);
  247. var beginningDotsButton = root.find(SELECTORS.BEGINNING_DOTS_BUTTON);
  248. var endingDotsButton = root.find(SELECTORS.ENDING_DOTS_BUTTON);
  249. var pages = root.find(SELECTORS.PAGE);
  250. var barSize = parseInt(root.attr('data-bar-size'), 10);
  251. if (barSize && lastPageNumber > barSize) {
  252. var minpage = Math.max(activePageNumber - Math.round(barSize / 2), 1);
  253. var maxpage = minpage + barSize - 1;
  254. if (maxpage >= lastPageNumber) {
  255. maxpage = lastPageNumber;
  256. minpage = maxpage - barSize + 1;
  257. }
  258. if (minpage > 1) {
  259. show(beginningDotsButton);
  260. minpage++;
  261. } else {
  262. hide(beginningDotsButton);
  263. }
  264. if (maxpage < lastPageNumber) {
  265. show(endingDotsButton);
  266. maxpage--;
  267. } else {
  268. hide(endingDotsButton);
  269. }
  270. dotsButtons.addClass('disabled');
  271. dotsButtons.attr('aria-disabled', true);
  272. hide(pages);
  273. pages.each(function(index, page) {
  274. page = $(page);
  275. if ((index + 1) >= minpage && (index + 1) <= maxpage) {
  276. show(page);
  277. }
  278. });
  279. } else {
  280. hide(dotsButtons);
  281. }
  282. };
  283. /**
  284. * Enable the previous and first buttons in the paging bar.
  285. *
  286. * @param {object} root The root element.
  287. */
  288. var enablePreviousControlButtons = function(root) {
  289. var previousButton = root.find(SELECTORS.PREVIOUS_BUTTON);
  290. var firstButton = root.find(SELECTORS.FIRST_BUTTON);
  291. previousButton.removeClass('disabled');
  292. previousButton.removeAttr('aria-disabled');
  293. firstButton.removeClass('disabled');
  294. firstButton.removeAttr('aria-disabled');
  295. };
  296. /**
  297. * Get the components for a get_string request for the aria-label
  298. * on a page. The value is a comma separated string of key and
  299. * component.
  300. *
  301. * @param {object} root The root element.
  302. * @return {array} First element is the key, second is the component.
  303. */
  304. var getPageAriaLabelComponents = function(root) {
  305. var componentString = root.attr('data-aria-label-components-pagination-item');
  306. var components = componentString.split(',').map(function(component) {
  307. return component.trim();
  308. });
  309. return components;
  310. };
  311. /**
  312. * Get the components for a get_string request for the aria-label
  313. * on an active page. The value is a comma separated string of key and
  314. * component.
  315. *
  316. * @param {object} root The root element.
  317. * @return {array} First element is the key, second is the component.
  318. */
  319. var getActivePageAriaLabelComponents = function(root) {
  320. var componentString = root.attr('data-aria-label-components-pagination-active-item');
  321. var components = componentString.split(',').map(function(component) {
  322. return component.trim();
  323. });
  324. return components;
  325. };
  326. /**
  327. * Set page numbers on each of the given items. Page numbers are set
  328. * from 1..n (where n is the number of items).
  329. *
  330. * Sets the active page number to be the last page found with
  331. * an "active" class (if any).
  332. *
  333. * Sets the last page number.
  334. *
  335. * @param {object} root The root element.
  336. * @param {jQuery} items A jQuery list of items.
  337. */
  338. var generatePageNumbers = function(root, items) {
  339. var lastPageNumber = 0;
  340. setActivePageNumber(root, 0);
  341. items.each(function(index, item) {
  342. var pageNumber = index + 1;
  343. item = $(item);
  344. item.attr('data-page-number', pageNumber);
  345. lastPageNumber++;
  346. if (item.hasClass('active')) {
  347. setActivePageNumber(root, pageNumber);
  348. }
  349. });
  350. setLastPageNumber(root, lastPageNumber);
  351. };
  352. /**
  353. * Set the aria-labels on each of the page items in the paging bar.
  354. * This includes the next, previous, first, and last items.
  355. *
  356. * @param {object} root The root element.
  357. */
  358. var generateAriaLabels = function(root) {
  359. var pageAriaLabelComponents = getPageAriaLabelComponents(root);
  360. var activePageAriaLabelComponents = getActivePageAriaLabelComponents(root);
  361. var activePageNumber = getActivePageNumber(root);
  362. var pageItems = root.find(SELECTORS.PAGE_ITEM);
  363. // We want to request all of the strings at once rather than
  364. // one at a time.
  365. var stringRequests = pageItems.toArray().map(function(index, page) {
  366. page = $(page);
  367. var pageNumber = getPageNumber(root, page);
  368. if (pageNumber === activePageNumber) {
  369. return {
  370. key: activePageAriaLabelComponents[0],
  371. component: activePageAriaLabelComponents[1],
  372. param: pageNumber
  373. };
  374. } else {
  375. return {
  376. key: pageAriaLabelComponents[0],
  377. component: pageAriaLabelComponents[1],
  378. param: pageNumber
  379. };
  380. }
  381. });
  382. Str.get_strings(stringRequests).then(function(strings) {
  383. pageItems.each(function(index, page) {
  384. page = $(page);
  385. var string = strings[index];
  386. page.attr('aria-label', string);
  387. page.find(SELECTORS.PAGE_LINK).attr('aria-label', string);
  388. });
  389. return strings;
  390. })
  391. .catch(function() {
  392. // No need to interrupt the page if we can't load the aria lang strings.
  393. return;
  394. });
  395. };
  396. /**
  397. * Make the paging bar item for the given page number visible and fire
  398. * the SHOW_PAGES paged content event to tell any listening content to
  399. * update.
  400. *
  401. * @param {object} root The root element.
  402. * @param {Number} pageNumber The number for the page to show.
  403. * @param {string} id A uniqie id for this instance.
  404. */
  405. var showPage = function(root, pageNumber, id) {
  406. var pendingPromise = new Pending('core/paged_content_paging_bar:showPage');
  407. var lastPageNumber = getLastPageNumber(root);
  408. var isSamePage = pageNumber == getActivePageNumber(root);
  409. var limit = getLimit(root);
  410. var offset = (pageNumber - 1) * limit;
  411. if (!isSamePage) {
  412. // We only need to toggle the active class if the user didn't click
  413. // on the already active page.
  414. root.find(SELECTORS.PAGE_ITEM).removeClass('active').removeAttr('aria-current');
  415. var page = getPageByNumber(root, pageNumber);
  416. page.addClass('active');
  417. page.attr('aria-current', true);
  418. setActivePageNumber(root, pageNumber);
  419. adjustPagingBarSize(root);
  420. }
  421. // Make sure the control buttons are disabled as the user navigates
  422. // to either end of the limits.
  423. if (lastPageNumber && pageNumber >= lastPageNumber) {
  424. disableNextControlButtons(root);
  425. } else {
  426. enableNextControlButtons(root);
  427. }
  428. if (pageNumber > 1) {
  429. enablePreviousControlButtons(root);
  430. } else {
  431. disablePreviousControlButtons(root);
  432. }
  433. generateAriaLabels(root);
  434. // This event requires a payload that contains a list of all pages that
  435. // were activated. In the case of the paging bar we only show one page at
  436. // a time.
  437. PubSub.publish(id + PagedContentEvents.SHOW_PAGES, [{
  438. pageNumber: pageNumber,
  439. limit: limit,
  440. offset: offset
  441. }]);
  442. pendingPromise.resolve();
  443. };
  444. /**
  445. * Add event listeners for interactions with the paging bar as well as listening
  446. * for custom paged content events.
  447. *
  448. * Each event will trigger different logic to update parts of the paging bar's
  449. * display.
  450. *
  451. * @param {object} root The root element.
  452. * @param {string} id A uniqie id for this instance.
  453. */
  454. var registerEventListeners = function(root, id) {
  455. var ignoreControlWhileLoading = root.attr('data-ignore-control-while-loading');
  456. var loading = false;
  457. if (ignoreControlWhileLoading == "") {
  458. // Default to ignoring control while loading if not specified.
  459. ignoreControlWhileLoading = true;
  460. }
  461. CustomEvents.define(root, [
  462. CustomEvents.events.activate
  463. ]);
  464. root.on(CustomEvents.events.activate, SELECTORS.PAGE_ITEM, function(e, data) {
  465. data.originalEvent.preventDefault();
  466. data.originalEvent.stopPropagation();
  467. if (ignoreControlWhileLoading && loading) {
  468. // Do nothing if configured to ignore control while loading.
  469. return;
  470. }
  471. var page = $(e.target).closest(SELECTORS.PAGE_ITEM);
  472. if (!page.hasClass('disabled')) {
  473. var pageNumber = getPageNumber(root, page);
  474. showPage(root, pageNumber, id);
  475. loading = true;
  476. }
  477. });
  478. // This event is fired when all of the items have been loaded. Typically used
  479. // in an "infinite" pages context when we don't know the exact number of pages
  480. // ahead of time.
  481. PubSub.subscribe(id + PagedContentEvents.ALL_ITEMS_LOADED, function(pageNumber) {
  482. loading = false;
  483. var currentLastPage = getLastPageNumber(root);
  484. if (!currentLastPage || pageNumber < currentLastPage) {
  485. // Somehow the value we've got saved is higher than the new
  486. // value we just received. Perhaps events came out of order.
  487. // In any case, save the lowest value.
  488. setLastPageNumber(root, pageNumber);
  489. }
  490. if (pageNumber === 1 && root.attr('data-hide-control-on-single-page')) {
  491. // If all items were loaded on the first page then we can hide
  492. // the paging bar because there are no other pages to load.
  493. hide(root);
  494. disableNextControlButtons(root);
  495. disablePreviousControlButtons(root);
  496. } else {
  497. show(root);
  498. disableNextControlButtons(root);
  499. }
  500. });
  501. // This event is fired after all of the requested pages have been rendered.
  502. PubSub.subscribe(id + PagedContentEvents.PAGES_SHOWN, function() {
  503. // All pages have been shown so turn off the loading flag.
  504. loading = false;
  505. });
  506. // This is triggered when the paging limit is modified.
  507. PubSub.subscribe(id + PagedContentEvents.SET_ITEMS_PER_PAGE_LIMIT, function(limit) {
  508. // Update the limit.
  509. setLimit(root, limit);
  510. setLastPageNumber(root, 0);
  511. setActivePageNumber(root, 0);
  512. show(root);
  513. // Reload the data from page 1 again.
  514. showPage(root, 1, id);
  515. });
  516. };
  517. /**
  518. * Initialise the paging bar.
  519. * @param {object} root The root element.
  520. * @param {string} id A uniqie id for this instance.
  521. */
  522. var init = function(root, id) {
  523. root = $(root);
  524. var pages = root.find(SELECTORS.PAGE);
  525. generatePageNumbers(root, pages);
  526. registerEventListeners(root, id);
  527. if (hasActivePageNumber(root)) {
  528. var activePageNumber = getActivePageNumber(root);
  529. // If the the paging bar was rendered with an active page selected
  530. // then make sure we fired off the event to tell the content page to
  531. // show.
  532. getPageByNumber(root, activePageNumber).click();
  533. if (activePageNumber == 1) {
  534. // If the first page is active then disable the previous buttons.
  535. disablePreviousControlButtons(root);
  536. }
  537. } else {
  538. // There was no active page number so load the first page using
  539. // the next button. This allows the infinite pagination to work.
  540. getNextButton(root).click();
  541. }
  542. adjustPagingBarSize(root);
  543. };
  544. return {
  545. init: init,
  546. disableNextControlButtons: disableNextControlButtons,
  547. enableNextControlButtons: enableNextControlButtons,
  548. disablePreviousControlButtons: disablePreviousControlButtons,
  549. enablePreviousControlButtons: enablePreviousControlButtons,
  550. showPage: showPage,
  551. rootSelector: SELECTORS.ROOT,
  552. };
  553. });