admin/tool/lp/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. * To respond to selection changed events - use tree.on("selectionchanged", handler).
  20. * The handler will receive an array of nodes, which are the list items that are currently
  21. * selected. (Or a single node if multiselect is disabled).
  22. *
  23. * @module tool_lp/tree
  24. * @copyright 2015 Damyon Wiese <damyon@moodle.com>
  25. * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  26. */
  27. define(['jquery', 'core/url', 'core/log'], function($, url, log) {
  28. // Private variables and functions.
  29. /** @var {String} expandedImage The html for an expanded tree node twistie. */
  30. var expandedImage = $('<img alt="" class="icon" src="' + url.imageUrl('t/expanded') + '"/>');
  31. /** @var {String} collapsedImage The html for a collapsed tree node twistie. */
  32. var collapsedImage = $('<img alt="" class="icon" src="' + url.imageUrl('t/collapsed') + '"/>');
  33. /**
  34. * Constructor
  35. *
  36. * @param {String} selector
  37. * @param {Boolean} multiSelect
  38. */
  39. var Tree = function(selector, multiSelect) {
  40. this.treeRoot = $(selector);
  41. this.multiSelect = (typeof multiSelect === 'undefined' || multiSelect === true);
  42. this.items = this.treeRoot.find('li');
  43. this.expandAll = this.items.length < 20;
  44. this.parents = this.treeRoot.find('li:has(ul)');
  45. if (multiSelect) {
  46. this.treeRoot.attr('aria-multiselectable', 'true');
  47. }
  48. this.items.attr('aria-selected', 'false');
  49. this.visibleItems = null;
  50. this.activeItem = null;
  51. this.lastActiveItem = null;
  52. this.keys = {
  53. tab: 9,
  54. enter: 13,
  55. space: 32,
  56. pageup: 33,
  57. pagedown: 34,
  58. end: 35,
  59. home: 36,
  60. left: 37,
  61. up: 38,
  62. right: 39,
  63. down: 40,
  64. eight: 56,
  65. asterisk: 106
  66. };
  67. this.init();
  68. this.bindEventHandlers();
  69. };
  70. // Public variables and functions.
  71. /**
  72. * Init this tree
  73. * @method init
  74. */
  75. Tree.prototype.init = function() {
  76. this.parents.attr('aria-expanded', 'true');
  77. this.parents.prepend(expandedImage.clone());
  78. this.items.attr('role', 'tree-item');
  79. this.items.attr('tabindex', '-1');
  80. this.parents.attr('role', 'group');
  81. this.treeRoot.attr('role', 'tree');
  82. this.visibleItems = this.treeRoot.find('li');
  83. var thisObj = this;
  84. if (!this.expandAll) {
  85. this.parents.each(function() {
  86. thisObj.collapseGroup($(this));
  87. });
  88. this.expandGroup(this.parents.first());
  89. }
  90. };
  91. /**
  92. * Expand a collapsed group.
  93. *
  94. * @method expandGroup
  95. * @param {Object} item is the jquery id of the parent item of the group
  96. */
  97. Tree.prototype.expandGroup = function(item) {
  98. // Find the first child ul node.
  99. var group = item.children('ul');
  100. // Expand the group.
  101. group.show().attr('aria-hidden', 'false');
  102. item.attr('aria-expanded', 'true');
  103. item.children('img').attr('src', expandedImage.attr('src'));
  104. // Update the list of visible items.
  105. this.visibleItems = this.treeRoot.find('li:visible');
  106. };
  107. /**
  108. * Collapse an expanded group.
  109. *
  110. * @method collapseGroup
  111. * @param {Object} item is the jquery id of the parent item of the group
  112. */
  113. Tree.prototype.collapseGroup = function(item) {
  114. var group = item.children('ul');
  115. // Collapse the group.
  116. group.hide().attr('aria-hidden', 'true');
  117. item.attr('aria-expanded', 'false');
  118. item.children('img').attr('src', collapsedImage.attr('src'));
  119. // Update the list of visible items.
  120. this.visibleItems = this.treeRoot.find('li:visible');
  121. };
  122. /**
  123. * Expand or collapse a group.
  124. *
  125. * @method toggleGroup
  126. * @param {Object} item is the jquery id of the parent item of the group
  127. */
  128. Tree.prototype.toggleGroup = function(item) {
  129. if (item.attr('aria-expanded') == 'true') {
  130. this.collapseGroup(item);
  131. } else {
  132. this.expandGroup(item);
  133. }
  134. };
  135. /**
  136. * Whenever the currently selected node has changed, trigger an event using this function.
  137. *
  138. * @method triggerChange
  139. */
  140. Tree.prototype.triggerChange = function() {
  141. var allSelected = this.items.filter('[aria-selected=true]');
  142. if (!this.multiSelect) {
  143. allSelected = allSelected.first();
  144. }
  145. this.treeRoot.trigger('selectionchanged', {selected: allSelected});
  146. };
  147. /**
  148. * Select all the items between the last focused item and this currently focused item.
  149. *
  150. * @method multiSelectItem
  151. * @param {Object} item is the jquery id of the newly selected item.
  152. */
  153. Tree.prototype.multiSelectItem = function(item) {
  154. if (!this.multiSelect) {
  155. this.items.attr('aria-selected', 'false');
  156. } else if (this.lastActiveItem !== null) {
  157. var lastIndex = this.visibleItems.index(this.lastActiveItem);
  158. var currentIndex = this.visibleItems.index(this.activeItem);
  159. var oneItem = null;
  160. while (lastIndex < currentIndex) {
  161. oneItem = $(this.visibleItems.get(lastIndex));
  162. oneItem.attr('aria-selected', 'true');
  163. lastIndex++;
  164. }
  165. while (lastIndex > currentIndex) {
  166. oneItem = $(this.visibleItems.get(lastIndex));
  167. oneItem.attr('aria-selected', 'true');
  168. lastIndex--;
  169. }
  170. }
  171. item.attr('aria-selected', 'true');
  172. this.triggerChange();
  173. };
  174. /**
  175. * Select a single item. Make sure all the parents are expanded. De-select all other items.
  176. *
  177. * @method selectItem
  178. * @param {Object} item is the jquery id of the newly selected item.
  179. */
  180. Tree.prototype.selectItem = function(item) {
  181. // Expand all nodes up the tree.
  182. var walk = item.parent();
  183. while (walk.attr('role') != 'tree') {
  184. walk = walk.parent();
  185. if (walk.attr('aria-expanded') == 'false') {
  186. this.expandGroup(walk);
  187. }
  188. walk = walk.parent();
  189. }
  190. this.items.attr('aria-selected', 'false');
  191. item.attr('aria-selected', 'true');
  192. this.triggerChange();
  193. };
  194. /**
  195. * Toggle the selected state for an item back and forth.
  196. *
  197. * @method toggleItem
  198. * @param {Object} item is the jquery id of the item to toggle.
  199. */
  200. Tree.prototype.toggleItem = function(item) {
  201. if (!this.multiSelect) {
  202. this.selectItem(item);
  203. return;
  204. }
  205. var current = item.attr('aria-selected');
  206. if (current === 'true') {
  207. current = 'false';
  208. } else {
  209. current = 'true';
  210. }
  211. item.attr('aria-selected', current);
  212. this.triggerChange();
  213. };
  214. /**
  215. * Set the focus to this item.
  216. *
  217. * @method updateFocus
  218. * @param {Object} item is the jquery id of the parent item of the group
  219. */
  220. Tree.prototype.updateFocus = function(item) {
  221. this.lastActiveItem = this.activeItem;
  222. this.activeItem = item;
  223. // Expand all nodes up the tree.
  224. var walk = item.parent();
  225. while (walk.attr('role') != 'tree') {
  226. walk = walk.parent();
  227. if (walk.attr('aria-expanded') == 'false') {
  228. this.expandGroup(walk);
  229. }
  230. walk = walk.parent();
  231. }
  232. this.items.attr('tabindex', '-1');
  233. item.attr('tabindex', 0);
  234. };
  235. /**
  236. * Handle a key down event - ie navigate the tree.
  237. *
  238. * @method handleKeyDown
  239. * @param {Object} item is the jquery id of the parent item of the group
  240. * @param {Event} e The event.
  241. * @return {Boolean}
  242. */
  243. // This function should be simplified. In the meantime..
  244. // eslint-disable-next-line complexity
  245. Tree.prototype.handleKeyDown = function(item, e) {
  246. var currentIndex = this.visibleItems.index(item);
  247. var newItem = null;
  248. var hasKeyModifier = e.shiftKey || e.ctrlKey || e.metaKey || e.altKey;
  249. var thisObj = this;
  250. switch (e.keyCode) {
  251. case this.keys.home: {
  252. // Jump to first item in tree.
  253. newItem = this.parents.first();
  254. newItem.focus();
  255. if (e.shiftKey) {
  256. this.multiSelectItem(newItem);
  257. } else if (!hasKeyModifier) {
  258. this.selectItem(newItem);
  259. }
  260. e.stopPropagation();
  261. return false;
  262. }
  263. case this.keys.end: {
  264. // Jump to last visible item.
  265. newItem = this.visibleItems.last();
  266. newItem.focus();
  267. if (e.shiftKey) {
  268. this.multiSelectItem(newItem);
  269. } else if (!hasKeyModifier) {
  270. this.selectItem(newItem);
  271. }
  272. e.stopPropagation();
  273. return false;
  274. }
  275. case this.keys.enter:
  276. case this.keys.space: {
  277. if (e.shiftKey) {
  278. this.multiSelectItem(item);
  279. } else if (e.metaKey || e.ctrlKey) {
  280. this.toggleItem(item);
  281. } else {
  282. this.selectItem(item);
  283. }
  284. e.stopPropagation();
  285. return false;
  286. }
  287. case this.keys.left: {
  288. if (item.has('ul') && item.attr('aria-expanded') == 'true') {
  289. this.collapseGroup(item);
  290. } else {
  291. // Move up to the parent.
  292. var itemUL = item.parent();
  293. var itemParent = itemUL.parent();
  294. if (itemParent.is('li')) {
  295. itemParent.focus();
  296. if (e.shiftKey) {
  297. this.multiSelectItem(itemParent);
  298. } else if (!hasKeyModifier) {
  299. this.selectItem(itemParent);
  300. }
  301. }
  302. }
  303. e.stopPropagation();
  304. return false;
  305. }
  306. case this.keys.right: {
  307. if (item.has('ul') && item.attr('aria-expanded') == 'false') {
  308. this.expandGroup(item);
  309. } else {
  310. // Move to the first item in the child group.
  311. newItem = item.children('ul').children('li').first();
  312. if (newItem.length > 0) {
  313. newItem.focus();
  314. if (e.shiftKey) {
  315. this.multiSelectItem(newItem);
  316. } else if (!hasKeyModifier) {
  317. this.selectItem(newItem);
  318. }
  319. }
  320. }
  321. e.stopPropagation();
  322. return false;
  323. }
  324. case this.keys.up: {
  325. if (currentIndex > 0) {
  326. var prev = this.visibleItems.eq(currentIndex - 1);
  327. prev.focus();
  328. if (e.shiftKey) {
  329. this.multiSelectItem(prev);
  330. } else if (!hasKeyModifier) {
  331. this.selectItem(prev);
  332. }
  333. }
  334. e.stopPropagation();
  335. return false;
  336. }
  337. case this.keys.down: {
  338. if (currentIndex < this.visibleItems.length - 1) {
  339. var next = this.visibleItems.eq(currentIndex + 1);
  340. next.focus();
  341. if (e.shiftKey) {
  342. this.multiSelectItem(next);
  343. } else if (!hasKeyModifier) {
  344. this.selectItem(next);
  345. }
  346. }
  347. e.stopPropagation();
  348. return false;
  349. }
  350. case this.keys.asterisk: {
  351. // Expand all groups.
  352. this.parents.each(function() {
  353. thisObj.expandGroup($(this));
  354. });
  355. e.stopPropagation();
  356. return false;
  357. }
  358. case this.keys.eight: {
  359. if (e.shiftKey) {
  360. // Expand all groups.
  361. this.parents.each(function() {
  362. thisObj.expandGroup($(this));
  363. });
  364. e.stopPropagation();
  365. }
  366. return false;
  367. }
  368. }
  369. return true;
  370. };
  371. /**
  372. * Handle a key press event - ie navigate the tree.
  373. *
  374. * @method handleKeyPress
  375. * @param {Object} item is the jquery id of the parent item of the group
  376. * @param {Event} e The event.
  377. * @return {Boolean}
  378. */
  379. Tree.prototype.handleKeyPress = function(item, e) {
  380. if (e.altKey || e.ctrlKey || e.shiftKey || e.metaKey) {
  381. // Do nothing.
  382. return true;
  383. }
  384. switch (e.keyCode) {
  385. case this.keys.tab: {
  386. return true;
  387. }
  388. case this.keys.enter:
  389. case this.keys.home:
  390. case this.keys.end:
  391. case this.keys.left:
  392. case this.keys.right:
  393. case this.keys.up:
  394. case this.keys.down: {
  395. e.stopPropagation();
  396. return false;
  397. }
  398. default : {
  399. var chr = String.fromCharCode(e.which);
  400. var match = false;
  401. var itemIndex = this.visibleItems.index(item);
  402. var itemCount = this.visibleItems.length;
  403. var currentIndex = itemIndex + 1;
  404. // Check if the active item was the last one on the list.
  405. if (currentIndex == itemCount) {
  406. currentIndex = 0;
  407. }
  408. // Iterate through the menu items (starting from the current item and wrapping) until a match is found
  409. // or the loop returns to the current menu item.
  410. while (currentIndex != itemIndex) {
  411. var currentItem = this.visibleItems.eq(currentIndex);
  412. var titleChr = currentItem.text().charAt(0);
  413. if (currentItem.has('ul')) {
  414. titleChr = currentItem.find('span').text().charAt(0);
  415. }
  416. if (titleChr.toLowerCase() == chr) {
  417. match = true;
  418. break;
  419. }
  420. currentIndex = currentIndex + 1;
  421. if (currentIndex == itemCount) {
  422. // Reached the end of the list, start again at the beginning.
  423. currentIndex = 0;
  424. }
  425. }
  426. if (match === true) {
  427. this.updateFocus(this.visibleItems.eq(currentIndex));
  428. }
  429. e.stopPropagation();
  430. return false;
  431. }
  432. }
  433. // eslint-disable-next-line no-unreachable
  434. return true;
  435. };
  436. /**
  437. * Attach an event listener to the tree.
  438. *
  439. * @method on
  440. * @param {String} eventname This is the name of the event to listen for. Only 'selectionchanged' is supported for now.
  441. * @param {Function} handler The function to call when the event is triggered.
  442. */
  443. Tree.prototype.on = function(eventname, handler) {
  444. if (eventname !== 'selectionchanged') {
  445. log.warning('Invalid custom event name for tree. Only "selectionchanged" is supported.');
  446. } else {
  447. this.treeRoot.on(eventname, handler);
  448. }
  449. };
  450. /**
  451. * Handle a double click (expand/collapse).
  452. *
  453. * @method handleDblClick
  454. * @param {Object} item is the jquery id of the parent item of the group
  455. * @param {Event} e The event.
  456. * @return {Boolean}
  457. */
  458. Tree.prototype.handleDblClick = function(item, e) {
  459. if (e.altKey || e.ctrlKey || e.shiftKey || e.metaKey) {
  460. // Do nothing.
  461. return true;
  462. }
  463. // Apply the focus markup.
  464. this.updateFocus(item);
  465. // Expand or collapse the group.
  466. this.toggleGroup(item);
  467. e.stopPropagation();
  468. return false;
  469. };
  470. /**
  471. * Handle a click (select).
  472. *
  473. * @method handleExpandCollapseClick
  474. * @param {Object} item is the jquery id of the parent item of the group
  475. * @param {Event} e The event.
  476. * @return {Boolean}
  477. */
  478. Tree.prototype.handleExpandCollapseClick = function(item, e) {
  479. // Do not shift the focus.
  480. this.toggleGroup(item);
  481. e.stopPropagation();
  482. return false;
  483. };
  484. /**
  485. * Handle a click (select).
  486. *
  487. * @method handleClick
  488. * @param {Object} item is the jquery id of the parent item of the group
  489. * @param {Event} e The event.
  490. * @return {Boolean}
  491. */
  492. Tree.prototype.handleClick = function(item, e) {
  493. if (e.shiftKey) {
  494. this.multiSelectItem(item);
  495. } else if (e.metaKey || e.ctrlKey) {
  496. this.toggleItem(item);
  497. } else {
  498. this.selectItem(item);
  499. }
  500. this.updateFocus(item);
  501. e.stopPropagation();
  502. return false;
  503. };
  504. /**
  505. * Handle a blur event
  506. *
  507. * @method handleBlur
  508. * @return {Boolean}
  509. */
  510. Tree.prototype.handleBlur = function() {
  511. return true;
  512. };
  513. /**
  514. * Handle a focus event
  515. *
  516. * @method handleFocus
  517. * @param {Object} item item is the jquery id of the parent item of the group
  518. * @return {Boolean}
  519. */
  520. Tree.prototype.handleFocus = function(item) {
  521. this.updateFocus(item);
  522. return true;
  523. };
  524. /**
  525. * Bind the event listeners we require.
  526. *
  527. * @method bindEventHandlers
  528. */
  529. Tree.prototype.bindEventHandlers = function() {
  530. var thisObj = this;
  531. // Bind a dblclick handler to the parent items.
  532. this.parents.dblclick(function(e) {
  533. return thisObj.handleDblClick($(this), e);
  534. });
  535. // Bind a click handler.
  536. this.items.click(function(e) {
  537. return thisObj.handleClick($(this), e);
  538. });
  539. // Bind a toggle handler to the expand/collapse icons.
  540. this.items.children('img').click(function(e) {
  541. return thisObj.handleExpandCollapseClick($(this).parent(), e);
  542. });
  543. // Bind a keydown handler.
  544. this.items.keydown(function(e) {
  545. return thisObj.handleKeyDown($(this), e);
  546. });
  547. // Bind a keypress handler.
  548. this.items.keypress(function(e) {
  549. return thisObj.handleKeyPress($(this), e);
  550. });
  551. // Bind a focus handler.
  552. this.items.focus(function(e) {
  553. return thisObj.handleFocus($(this), e);
  554. });
  555. // Bind a blur handler.
  556. this.items.blur(function(e) {
  557. return thisObj.handleBlur($(this), e);
  558. });
  559. };
  560. return /** @alias module:tool_lp/tree */ Tree;
  561. });