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