lib/amd/src/tree.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. * Implement an accessible aria tree widget, from a nested unordered list.
  17. * Based on http://oaa-accessibility.org/example/41/.
  18. *
  19. * @module core/tree
  20. * @copyright 2015 Damyon Wiese <damyon@moodle.com>
  21. * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  22. */
  23. define(['jquery'], function($) {
  24. // Private variables and functions.
  25. var SELECTORS = {
  26. ITEM: '[role=treeitem]',
  27. GROUP: '[role=treeitem]:has([role=group]), [role=treeitem][aria-owns], [role=treeitem][data-requires-ajax=true]',
  28. CLOSED_GROUP: '[role=treeitem]:has([role=group])[aria-expanded=false], [role=treeitem][aria-owns][aria-expanded=false], ' +
  29. '[role=treeitem][data-requires-ajax=true][aria-expanded=false]',
  30. FIRST_ITEM: '[role=treeitem]:first',
  31. VISIBLE_ITEM: '[role=treeitem]:visible',
  32. UNLOADED_AJAX_ITEM: '[role=treeitem][data-requires-ajax=true][data-loaded=false][aria-expanded=true]'
  33. };
  34. /**
  35. * Constructor.
  36. *
  37. * @param {String} selector
  38. * @param {function} selectCallback Called when the active node is changed.
  39. */
  40. var Tree = function(selector, selectCallback) {
  41. this.treeRoot = $(selector);
  42. this.treeRoot.data('activeItem', null);
  43. this.selectCallback = selectCallback;
  44. this.keys = {
  45. tab: 9,
  46. enter: 13,
  47. space: 32,
  48. pageup: 33,
  49. pagedown: 34,
  50. end: 35,
  51. home: 36,
  52. left: 37,
  53. up: 38,
  54. right: 39,
  55. down: 40,
  56. asterisk: 106
  57. };
  58. // Apply the standard default initialisation for all nodes, starting with the tree root.
  59. this.initialiseNodes(this.treeRoot);
  60. // Make the first item the active item for the tree so that it is added to the tab order.
  61. this.setActiveItem(this.treeRoot.find(SELECTORS.FIRST_ITEM));
  62. // Create the cache of the visible items.
  63. this.refreshVisibleItemsCache();
  64. // Create the event handlers for the tree.
  65. this.bindEventHandlers();
  66. };
  67. Tree.prototype.registerEnterCallback = function(callback) {
  68. this.enterCallback = callback;
  69. };
  70. /**
  71. * Find all visible tree items and save a cache of them on the tree object.
  72. *
  73. * @method refreshVisibleItemsCache
  74. */
  75. Tree.prototype.refreshVisibleItemsCache = function() {
  76. this.treeRoot.data('visibleItems', this.treeRoot.find(SELECTORS.VISIBLE_ITEM));
  77. };
  78. /**
  79. * Get all visible tree items.
  80. *
  81. * @method getVisibleItems
  82. * @return {Object} visible items
  83. */
  84. Tree.prototype.getVisibleItems = function() {
  85. return this.treeRoot.data('visibleItems');
  86. };
  87. /**
  88. * Mark the given item as active within the tree and fire the callback for when the active item is set.
  89. *
  90. * @method setActiveItem
  91. * @param {object} item jquery object representing an item on the tree.
  92. */
  93. Tree.prototype.setActiveItem = function(item) {
  94. var currentActive = this.treeRoot.data('activeItem');
  95. if (item === currentActive) {
  96. return;
  97. }
  98. // Remove previous active from tab order.
  99. if (currentActive) {
  100. currentActive.attr('tabindex', '-1');
  101. currentActive.attr('aria-selected', 'false');
  102. }
  103. item.attr('tabindex', '0');
  104. item.attr('aria-selected', 'true');
  105. // Set the new active item.
  106. this.treeRoot.data('activeItem', item);
  107. if (typeof this.selectCallback === 'function') {
  108. this.selectCallback(item);
  109. }
  110. };
  111. /**
  112. * Determines if the given item is a group item (contains child tree items) in the tree.
  113. *
  114. * @method isGroupItem
  115. * @param {object} item jquery object representing an item on the tree.
  116. * @returns {bool}
  117. */
  118. Tree.prototype.isGroupItem = function(item) {
  119. return item.is(SELECTORS.GROUP);
  120. };
  121. /**
  122. * Determines if the given item is a group item (contains child tree items) in the tree.
  123. *
  124. * @method isGroupItem
  125. * @param {object} item jquery object representing an item on the tree.
  126. * @returns {bool}
  127. */
  128. Tree.prototype.getGroupFromItem = function(item) {
  129. var ariaowns = this.treeRoot.find('#' + item.attr('aria-owns'));
  130. var plain = item.children('[role=group]');
  131. if (ariaowns.length > plain.length) {
  132. return ariaowns;
  133. } else {
  134. return plain;
  135. }
  136. };
  137. /**
  138. * Determines if the given group item (contains child tree items) is collapsed.
  139. *
  140. * @method isGroupCollapsed
  141. * @param {object} item jquery object representing a group item on the tree.
  142. * @returns {bool}
  143. */
  144. Tree.prototype.isGroupCollapsed = function(item) {
  145. return item.attr('aria-expanded') === 'false';
  146. };
  147. /**
  148. * Determines if the given group item (contains child tree items) can be collapsed.
  149. *
  150. * @method isGroupCollapsible
  151. * @param {object} item jquery object representing a group item on the tree.
  152. * @returns {bool}
  153. */
  154. Tree.prototype.isGroupCollapsible = function(item) {
  155. return item.attr('data-collapsible') !== 'false';
  156. };
  157. /**
  158. * Performs the tree initialisation for all child items from the given node,
  159. * such as removing everything from the tab order and setting aria selected
  160. * on items.
  161. *
  162. * @method initialiseNodes
  163. * @param {object} node jquery object representing a node.
  164. */
  165. Tree.prototype.initialiseNodes = function(node) {
  166. this.removeAllFromTabOrder(node);
  167. this.setAriaSelectedFalseOnItems(node);
  168. // Get all ajax nodes that have been rendered as expanded but haven't loaded the child items yet.
  169. var thisTree = this;
  170. node.find(SELECTORS.UNLOADED_AJAX_ITEM).each(function() {
  171. var unloadedNode = $(this);
  172. // Collapse and then expand to trigger the ajax loading.
  173. thisTree.collapseGroup(unloadedNode);
  174. thisTree.expandGroup(unloadedNode);
  175. });
  176. };
  177. /**
  178. * Removes all child DOM elements of the given node from the tab order.
  179. *
  180. * @method removeAllFromTabOrder
  181. * @param {object} node jquery object representing a node.
  182. */
  183. Tree.prototype.removeAllFromTabOrder = function(node) {
  184. node.find('*').attr('tabindex', '-1');
  185. this.getGroupFromItem($(node)).find('*').attr('tabindex', '-1');
  186. };
  187. /**
  188. * Find all child tree items from the given node and set the aria selected attribute to false.
  189. *
  190. * @method setAriaSelectedFalseOnItems
  191. * @param {object} node jquery object representing a node.
  192. */
  193. Tree.prototype.setAriaSelectedFalseOnItems = function(node) {
  194. node.find(SELECTORS.ITEM).attr('aria-selected', 'false');
  195. };
  196. /**
  197. * Expand all group nodes within the tree.
  198. *
  199. * @method expandAllGroups
  200. */
  201. Tree.prototype.expandAllGroups = function() {
  202. var thisTree = this;
  203. this.treeRoot.find(SELECTORS.CLOSED_GROUP).each(function() {
  204. var groupNode = $(this);
  205. thisTree.expandGroup($(this)).done(function() {
  206. thisTree.expandAllChildGroups(groupNode);
  207. });
  208. });
  209. };
  210. /**
  211. * Find all child group nodes from the given node and expand them.
  212. *
  213. * @method expandAllChildGroups
  214. * @param {Object} item is the jquery id of the group.
  215. */
  216. Tree.prototype.expandAllChildGroups = function(item) {
  217. var thisTree = this;
  218. this.getGroupFromItem(item).find(SELECTORS.CLOSED_GROUP).each(function() {
  219. var groupNode = $(this);
  220. thisTree.expandGroup($(this)).done(function() {
  221. thisTree.expandAllChildGroups(groupNode);
  222. });
  223. });
  224. };
  225. /**
  226. * Expand a collapsed group.
  227. *
  228. * Handles expanding nodes that are ajax loaded (marked with a data-requires-ajax attribute).
  229. *
  230. * @method expandGroup
  231. * @param {Object} item is the jquery id of the parent item of the group.
  232. * @return {Object} a promise that is resolved when the group has been expanded.
  233. */
  234. Tree.prototype.expandGroup = function(item) {
  235. var promise = $.Deferred();
  236. // Ignore nodes that are explicitly maked as not expandable or are already expanded.
  237. if (item.attr('data-expandable') !== 'false' && this.isGroupCollapsed(item)) {
  238. // If this node requires ajax load and we haven't already loaded it.
  239. if (item.attr('data-requires-ajax') === 'true' && item.attr('data-loaded') !== 'true') {
  240. item.attr('data-loaded', false);
  241. // Get the closes ajax loading module specificed in the tree.
  242. var moduleName = item.closest('[data-ajax-loader]').attr('data-ajax-loader');
  243. var thisTree = this;
  244. // Flag this node as loading.
  245. const p = item.find('p');
  246. p.addClass('loading');
  247. // Require the ajax module (must be AMD) and try to load the items.
  248. require([moduleName], function(loader) {
  249. // All ajax module must implement a "load" method.
  250. loader.load(item).done(function() {
  251. item.attr('data-loaded', true);
  252. // Set defaults on the newly constructed part of the tree.
  253. thisTree.initialiseNodes(item);
  254. thisTree.finishExpandingGroup(item);
  255. // Make sure no child elements of the item we just loaded are tabbable.
  256. p.removeClass('loading');
  257. promise.resolve();
  258. });
  259. });
  260. } else {
  261. this.finishExpandingGroup(item);
  262. promise.resolve();
  263. }
  264. } else {
  265. promise.resolve();
  266. }
  267. return promise;
  268. };
  269. /**
  270. * Perform the necessary DOM changes to display a group item.
  271. *
  272. * @method finishExpandingGroup
  273. * @param {Object} item is the jquery id of the parent item of the group.
  274. */
  275. Tree.prototype.finishExpandingGroup = function(item) {
  276. // Expand the group.
  277. var group = this.getGroupFromItem(item);
  278. group.removeAttr('aria-hidden');
  279. item.attr('aria-expanded', 'true');
  280. // Update the list of visible items.
  281. this.refreshVisibleItemsCache();
  282. };
  283. /**
  284. * Collapse an expanded group.
  285. *
  286. * @method collapseGroup
  287. * @param {Object} item is the jquery id of the parent item of the group.
  288. */
  289. Tree.prototype.collapseGroup = function(item) {
  290. // If the item is not collapsible or already collapsed then do nothing.
  291. if (!this.isGroupCollapsible(item) || this.isGroupCollapsed(item)) {
  292. return;
  293. }
  294. // Collapse the group.
  295. var group = this.getGroupFromItem(item);
  296. group.attr('aria-hidden', 'true');
  297. item.attr('aria-expanded', 'false');
  298. // Update the list of visible items.
  299. this.refreshVisibleItemsCache();
  300. };
  301. /**
  302. * Expand or collapse a group.
  303. *
  304. * @method toggleGroup
  305. * @param {Object} item is the jquery id of the parent item of the group.
  306. */
  307. Tree.prototype.toggleGroup = function(item) {
  308. if (item.attr('aria-expanded') === 'true') {
  309. this.collapseGroup(item);
  310. } else {
  311. this.expandGroup(item);
  312. }
  313. };
  314. /**
  315. * Handle a key down event - ie navigate the tree.
  316. *
  317. * @method handleKeyDown
  318. * @param {Event} e The event.
  319. */
  320. // This function should be simplified. In the meantime..
  321. // eslint-disable-next-line complexity
  322. Tree.prototype.handleKeyDown = function(e) {
  323. var item = $(e.target);
  324. var currentIndex = this.getVisibleItems()?.index(item);
  325. if ((e.altKey || e.ctrlKey || e.metaKey) || (e.shiftKey && e.keyCode != this.keys.tab)) {
  326. // Do nothing.
  327. return;
  328. }
  329. switch (e.keyCode) {
  330. case this.keys.home: {
  331. // Jump to first item in tree.
  332. this.getVisibleItems().first().focus();
  333. e.preventDefault();
  334. return;
  335. }
  336. case this.keys.end: {
  337. // Jump to last visible item.
  338. this.getVisibleItems().last().focus();
  339. e.preventDefault();
  340. return;
  341. }
  342. case this.keys.enter: {
  343. var links = item.children('a').length ? item.children('a') : item.children().not(SELECTORS.GROUP).find('a');
  344. if (links.length) {
  345. if (links.first().data('overrides-tree-activation-key-handler')) {
  346. // If the link overrides handling of activation keys, let it do so.
  347. links.first().triggerHandler(e);
  348. } else if (typeof this.enterCallback === 'function') {
  349. // Use callback if there is one.
  350. this.enterCallback(item);
  351. } else {
  352. window.location.href = links.first().attr('href');
  353. }
  354. } else if (this.isGroupItem(item)) {
  355. this.toggleGroup(item, true);
  356. }
  357. e.preventDefault();
  358. return;
  359. }
  360. case this.keys.space: {
  361. if (this.isGroupItem(item)) {
  362. this.toggleGroup(item, true);
  363. } else if (item.children('a').length) {
  364. var firstLink = item.children('a').first();
  365. if (firstLink.data('overrides-tree-activation-key-handler')) {
  366. firstLink.triggerHandler(e);
  367. }
  368. }
  369. e.preventDefault();
  370. return;
  371. }
  372. case this.keys.left: {
  373. var focusParent = function(tree) {
  374. // Get the immediate visible parent group item that contains this element.
  375. tree.getVisibleItems().filter(function() {
  376. return tree.getGroupFromItem($(this)).has(item).length;
  377. }).focus();
  378. };
  379. // If this is a group item then collapse it and focus the parent group
  380. // in accordance with the aria spec.
  381. if (this.isGroupItem(item)) {
  382. if (this.isGroupCollapsed(item)) {
  383. focusParent(this);
  384. } else {
  385. this.collapseGroup(item);
  386. }
  387. } else {
  388. focusParent(this);
  389. }
  390. e.preventDefault();
  391. return;
  392. }
  393. case this.keys.right: {
  394. // If this is a group item then expand it and focus the first child item
  395. // in accordance with the aria spec.
  396. if (this.isGroupItem(item)) {
  397. if (this.isGroupCollapsed(item)) {
  398. this.expandGroup(item);
  399. } else {
  400. // Move to the first item in the child group.
  401. this.getGroupFromItem(item).find(SELECTORS.ITEM).first().focus();
  402. }
  403. }
  404. e.preventDefault();
  405. return;
  406. }
  407. case this.keys.up: {
  408. if (currentIndex > 0) {
  409. var prev = this.getVisibleItems().eq(currentIndex - 1);
  410. prev.focus();
  411. }
  412. e.preventDefault();
  413. return;
  414. }
  415. case this.keys.down: {
  416. if (currentIndex < this.getVisibleItems().length - 1) {
  417. var next = this.getVisibleItems().eq(currentIndex + 1);
  418. next.focus();
  419. }
  420. e.preventDefault();
  421. return;
  422. }
  423. case this.keys.asterisk: {
  424. // Expand all groups.
  425. this.expandAllGroups();
  426. e.preventDefault();
  427. return;
  428. }
  429. }
  430. };
  431. /**
  432. * Handle an item click.
  433. *
  434. * @param {Event} event the click event
  435. * @param {jQuery} item the item clicked
  436. */
  437. Tree.prototype.handleItemClick = function(event, item) {
  438. // Update the active item.
  439. item.focus();
  440. // If the item is a group node.
  441. if (this.isGroupItem(item)) {
  442. this.toggleGroup(item);
  443. }
  444. };
  445. /**
  446. * Handle a click (select).
  447. *
  448. * @method handleClick
  449. * @param {Event} event The event.
  450. */
  451. Tree.prototype.handleClick = function(event) {
  452. if (event.altKey || event.ctrlKey || event.shiftKey || event.metaKey) {
  453. // Do nothing.
  454. return;
  455. }
  456. // Get the closest tree item from the event target.
  457. var item = $(event.target).closest('[role="treeitem"]');
  458. if (!item.is(event.currentTarget)) {
  459. return;
  460. }
  461. this.handleItemClick(event, item);
  462. };
  463. /**
  464. * Handle a focus event.
  465. *
  466. * @method handleFocus
  467. * @param {Event} e The event.
  468. */
  469. Tree.prototype.handleFocus = function(e) {
  470. this.setActiveItem($(e.target));
  471. };
  472. /**
  473. * Bind the event listeners we require.
  474. *
  475. * @method bindEventHandlers
  476. */
  477. Tree.prototype.bindEventHandlers = function() {
  478. // Bind event handlers to the tree items. Use event delegates to allow
  479. // for dynamically loaded parts of the tree.
  480. this.treeRoot.on({
  481. click: this.handleClick.bind(this),
  482. keydown: this.handleKeyDown.bind(this),
  483. focus: this.handleFocus.bind(this),
  484. }, SELECTORS.ITEM);
  485. };
  486. return /** @alias module:core/tree */ Tree;
  487. });