MDL-50704 user: Do not validate timezones in user objects
[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             asterisk: 106
73         };
75         this.init();
77         this.bindEventHandlers();
78     };
79     // Public variables and functions.
81     /**
82      * Init this tree
83      * @method init
84      */
85     Tree.prototype.init = function() {
86         this.parents.attr('aria-expanded', 'true');
87         this.parents.prepend(expandedImage.clone());
89         this.items.attr('role', 'tree-item');
90         this.items.attr('tabindex', '-1');
91         this.parents.attr('role', 'group');
92         this.treeRoot.attr('role', 'tree');
94         this.visibleItems = this.treeRoot.find('li');
96         var thisObj = this;
97         if (!this.expandAll) {
98             this.parents.each(function() {
99                 thisObj.collapseGroup($(this));
100             });
101             this.expandGroup(this.parents.first());
102         }
103     };
105     /**
106      * Expand a collapsed group.
107      *
108      * @method expandGroup
109      * @param {Object} item is the jquery id of the parent item of the group
110      */
111     Tree.prototype.expandGroup = function(item) {
112         // Find the first child ul node.
113         var group = item.children('ul');
115         // Expand the group.
116         group.show().attr('aria-hidden', 'false');
118         item.attr('aria-expanded', 'true');
120         item.children('img').attr('src', expandedImage.attr('src'));
122         // Update the list of visible items.
123         this.visibleItems = this.treeRoot.find('li:visible');
124     };
126     /**
127      * Collapse an expanded group.
128      *
129      * @method collapseGroup
130      * @param {Object} item is the jquery id of the parent item of the group
131      */
132     Tree.prototype.collapseGroup = function(item) {
133         var group = item.children('ul');
135         // Collapse the group.
136         group.hide().attr('aria-hidden', 'true');
138         item.attr('aria-expanded', 'false');
140         item.children('img').attr('src', collapsedImage.attr('src'));
142         // Update the list of visible items.
143         this.visibleItems = this.treeRoot.find('li:visible');
144     };
146     /**
147      * Expand or collapse a group.
148      *
149      * @method toggleGroup
150      * @param {Object} item is the jquery id of the parent item of the group
151      */
152     Tree.prototype.toggleGroup = function(item) {
153         if (item.attr('aria-expanded') == 'true') {
154             this.collapseGroup(item);
155         } else {
156             this.expandGroup(item);
157         }
158     };
160     /**
161      * Whenever the currently selected node has changed, trigger an event using this function.
162      *
163      * @method triggerChange
164      */
165     Tree.prototype.triggerChange = function() {
166         var allSelected = this.items.filter('[aria-selected=true]');
167         if (!this.multiSelect) {
168             allSelected = allSelected.first();
169         }
170         this.treeRoot.trigger('selectionchanged', { selected: allSelected });
171     };
173     /**
174      * Select all the items between the last focused item and this currently focused item.
175      *
176      * @method multiSelectItem
177      * @param {Object} item is the jquery id of the newly selected item.
178      */
179     Tree.prototype.multiSelectItem = function(item) {
180         if (!this.multiSelect) {
181             this.items.attr('aria-selected', 'false');
182         } else if (this.lastActiveItem !== null) {
183             var lastIndex = this.visibleItems.index(this.lastActiveItem);
184             var currentIndex = this.visibleItems.index(this.activeItem);
185             var oneItem = null;
187             while (lastIndex < currentIndex) {
188                 oneItem  = $(this.visibleItems.get(lastIndex));
189                 oneItem.attr('aria-selected', 'true');
190                 lastIndex++;
191             }
192             while (lastIndex > currentIndex) {
193                 oneItem  = $(this.visibleItems.get(lastIndex));
194                 oneItem.attr('aria-selected', 'true');
195                 lastIndex--;
196             }
197         }
199         item.attr('aria-selected', 'true');
200         this.triggerChange();
201     };
203     /**
204      * Select a single item. Make sure all the parents are expanded. De-select all other items.
205      *
206      * @method selectItem
207      * @param {Object} item is the jquery id of the newly selected item.
208      */
209     Tree.prototype.selectItem = function(item) {
210         // Expand all nodes up the tree.
211         var walk = item.parent();
212         while (walk.attr('role') != 'tree') {
213             walk = walk.parent();
214             if (walk.attr('aria-expanded') == 'false') {
215                 this.expandGroup(walk);
216             }
217             walk = walk.parent();
218         }
219         this.items.attr('aria-selected', 'false');
220         item.attr('aria-selected', 'true');
221         this.triggerChange();
222     };
224     /**
225      * Toggle the selected state for an item back and forth.
226      *
227      * @method toggleItem
228      * @param {Object} item is the jquery id of the item to toggle.
229      */
230     Tree.prototype.toggleItem = function(item) {
231         if (!this.multiSelect) {
232             return this.selectItem(item);
233         }
235         var current = item.attr('aria-selected');
236         if (current === 'true') {
237             current = 'false';
238         } else {
239             current = 'true';
240         }
241         item.attr('aria-selected', current);
242         this.triggerChange();
243     };
245     /**
246      * Set the focus to this item.
247      *
248      * @method updateFocus
249      * @param {Object} item is the jquery id of the parent item of the group
250      */
251     Tree.prototype.updateFocus = function(item) {
252         this.lastActiveItem = this.activeItem;
253         this.activeItem = item;
254         // Expand all nodes up the tree.
255         var walk = item.parent();
256         while (walk.attr('role') != 'tree') {
257             walk = walk.parent();
258             if (walk.attr('aria-expanded') == 'false') {
259                 this.expandGroup(walk);
260             }
261             walk = walk.parent();
262         }
263         this.items.attr('tabindex', '-1');
264         item.attr('tabindex', 0);
265     };
267     /**
268      * Handle a key down event - ie navigate the tree.
269      *
270      * @method handleKeyDown
271      * @param {Object} item is the jquery id of the parent item of the group
272      * @param {Event} e The event.
273      */
274     Tree.prototype.handleKeyDown = function(item, e) {
275         var currentIndex = this.visibleItems.index(item);
276         var newItem = null;
278         switch (e.keyCode) {
279             case this.keys.home: {
280                  // Jump to first item in tree.
281                 newItem = this.parents.first();
282                 newItem.focus();
283                 if (e.shiftKey) {
284                     this.multiSelectItem(newItem);
285                 } else if (!e.ctrlKey) {
286                     this.selectItem(newItem);
287                 }
289                 e.stopPropagation();
290                 return false;
291             }
292             case this.keys.end: {
293                  // Jump to last visible item.
294                 newItem = this.visibleItems.last();
295                 newItem.focus();
296                 if (e.shiftKey) {
297                     this.multiSelectItem(newItem);
298                 } else if (!e.ctrlKey) {
299                     this.selectItem(newItem);
300                 }
302                 e.stopPropagation();
303                 return false;
304             }
305             case this.keys.enter:
306             case this.keys.space: {
308                 if (e.shiftKey) {
309                     this.multiSelectItem(item);
310                 } else if (e.ctrlKey) {
311                     this.toggleItem(item);
312                 } else {
313                     this.selectItem(item);
314                 }
316                 e.stopPropagation();
317                 return false;
318             }
319             case this.keys.left: {
320                 if (item.has('ul') && item.attr('aria-expanded') == 'true') {
321                     this.collapseGroup(item);
322                 } else {
323                     // Move up to the parent.
324                     var itemUL = item.parent();
325                     var itemParent = itemUL.parent();
326                     if (itemParent.is('li')) {
327                         itemParent.focus();
328                         if (e.shiftKey) {
329                             this.multiSelectItem(itemParent);
330                         } else if (!e.ctrlKey) {
331                             this.selectItem(itemParent);
332                         }
333                     }
334                 }
336                 e.stopPropagation();
337                 return false;
338             }
339             case this.keys.right: {
340                 if (item.has('ul') && item.attr('aria-expanded') == 'false') {
341                     this.expandGroup(item);
342                 } else {
343                     // Move to the first item in the child group.
344                     newItem = item.children('ul').children('li').first();
345                     if (newItem.length > 0) {
346                         newItem.focus();
347                         if (e.shiftKey) {
348                             this.multiSelectItem(newItem);
349                         } else if (!e.ctrlKey) {
350                             this.selectItem(newItem);
351                         }
352                     }
353                 }
355                 e.stopPropagation();
356                 return false;
357             }
358             case this.keys.up: {
360                 if (currentIndex > 0) {
361                     var prev = this.visibleItems.eq(currentIndex - 1);
362                     prev.focus();
363                     if (e.shiftKey) {
364                         this.multiSelectItem(prev);
365                     } else if (!e.ctrlKey) {
366                         this.selectItem(prev);
367                     }
368                 }
370                 e.stopPropagation();
371                 return false;
372             }
373             case this.keys.down: {
375                 if (currentIndex < this.visibleItems.length - 1) {
376                     var next = this.visibleItems.eq(currentIndex + 1);
377                     next.focus();
378                     if (e.shiftKey) {
379                         this.multiSelectItem(next);
380                     } else if (!e.ctrlKey) {
381                         this.selectItem(next);
382                     }
383                 }
384                 e.stopPropagation();
385                 return false;
386             }
387             case this.keys.asterisk: {
388                 // Expand all groups.
390                 var thisObj = this;
392                 this.parents.each(function() {
393                     thisObj.expandGroup($(this));
394                 });
396                 e.stopPropagation();
397                 return false;
398             }
399         }
401         return true;
402     };
404     /**
405      * Handle a key press event - ie navigate the tree.
406      *
407      * @method handleKeyPress
408      * @param {Object} item is the jquery id of the parent item of the group
409      * @param {Event} e The event.
410      */
411     Tree.prototype.handleKeyPress = function(item, e) {
412         if (e.altKey || e.ctrlKey || e.shiftKey) {
413             // Do nothing.
414             return true;
415         }
417         switch (e.keyCode) {
418             case this.keys.tab: {
419                 return true;
420             }
421             case this.keys.enter:
422             case this.keys.home:
423             case this.keys.end:
424             case this.keys.left:
425             case this.keys.right:
426             case this.keys.up:
427             case this.keys.down: {
428                 e.stopPropagation();
429                 return false;
430             }
431             default : {
432                 var chr = String.fromCharCode(e.which);
433                 var match = false;
434                 var itemIndex = this.visibleItems.index(item);
435                 var itemCount = this.visibleItems.length;
436                 var currentIndex = itemIndex + 1;
438                 // Check if the active item was the last one on the list.
439                 if (currentIndex == itemCount) {
440                     currentIndex = 0;
441                 }
443                 // Iterate through the menu items (starting from the current item and wrapping) until a match is found
444                 // or the loop returns to the current menu item.
445                 while (currentIndex != itemIndex)  {
447                     var currentItem = this.visibleItems.eq(currentIndex);
448                     var titleChr = currentItem.text().charAt(0);
450                     if (currentItem.has('ul')) {
451                         titleChr = currentItem.find('span').text().charAt(0);
452                     }
454                     if (titleChr.toLowerCase() == chr) {
455                         match = true;
456                         break;
457                     }
459                     currentIndex = currentIndex+1;
460                     if (currentIndex == itemCount) {
461                         // Reached the end of the list, start again at the beginning.
462                         currentIndex = 0;
463                     }
464                 }
466                 if (match === true) {
467                     this.updateFocus(this.visibleItems.eq(currentIndex));
468                 }
469                 e.stopPropagation();
470                 return false;
471             }
472         }
474         return true;
475     };
477     /**
478      * Attach an event listener to the tree.
479      *
480      * @method on
481      * @param {String} eventname This is the name of the event to listen for. Only 'selectionchanged' is supported for now.
482      * @param {Function} handler The function to call when the event is triggered.
483      */
484     Tree.prototype.on = function(eventname, handler) {
485         if (eventname !== 'selectionchanged') {
486             log.warning('Invalid custom event name for tree. Only "selectionchanged" is supported.');
487         } else {
488             this.treeRoot.on(eventname, handler);
489         }
490     };
492     /**
493      * Handle a double click (expand/collapse).
494      *
495      * @method handleDblClick
496      * @param {Object} item is the jquery id of the parent item of the group
497      * @param {Event} e The event.
498      */
499     Tree.prototype.handleDblClick = function(item, e) {
501         if (e.altKey || e.ctrlKey || e.shiftKey) {
502             // Do nothing.
503             return true;
504         }
506         // Apply the focus markup.
507         this.updateFocus(item);
509         // Expand or collapse the group.
510         this.toggleGroup(item);
512         e.stopPropagation();
513         return false;
514     };
516     /**
517      * Handle a click (select).
518      *
519      * @method handleExpandCollapseClick
520      * @param {Object} item is the jquery id of the parent item of the group
521      * @param {Event} e The event.
522      */
523     Tree.prototype.handleExpandCollapseClick = function(item, e) {
525         // Do not shift the focus.
526         this.toggleGroup(item);
527         e.stopPropagation();
528         return false;
529     };
532     /**
533      * Handle a click (select).
534      *
535      * @method handleClick
536      * @param {Object} item is the jquery id of the parent item of the group
537      * @param {Event} e The event.
538      */
539     Tree.prototype.handleClick = function(item, e) {
541         if (e.shiftKey) {
542             this.multiSelectItem(item);
543         } else if (e.ctrlKey) {
544             this.toggleItem(item);
545         } else {
546             this.selectItem(item);
547         }
548         this.updateFocus(item);
549         e.stopPropagation();
550         return false;
551     };
553     /**
554      * Handle a blur event
555      *
556      * @method handleBlur
557      * @param {Object} item item is the jquery id of the parent item of the group
558      * @param {Event} e The event.
559      */
560     Tree.prototype.handleBlur = function() {
561         return true;
562     };
564     /**
565      * Handle a focus event
566      *
567      * @method handleFocus
568      * @param {Object} item item is the jquery id of the parent item of the group
569      * @param {Event} e The event.
570      */
571     Tree.prototype.handleFocus = function(item) {
573         this.updateFocus(item);
575         return true;
576     };
578     /**
579      * Bind the event listeners we require.
580      *
581      * @method bindEventHandlers
582      */
583     Tree.prototype.bindEventHandlers = function() {
584         var thisObj = this;
586         // Bind a dblclick handler to the parent items.
587         this.parents.dblclick(function(e) {
588             return thisObj.handleDblClick($(this), e);
589         });
591         // Bind a click handler.
592         this.items.click(function(e) {
593             return thisObj.handleClick($(this), e);
594         });
596         // Bind a toggle handler to the expand/collapse icons.
597         this.items.children('img').click(function(e) {
598             return thisObj.handleExpandCollapseClick($(this).parent(), e);
599         });
601         // Bind a keydown handler.
602         this.items.keydown(function(e) {
603             return thisObj.handleKeyDown($(this), e);
604         });
606         // Bind a keypress handler.
607         this.items.keypress(function(e) {
608             return thisObj.handleKeyPress($(this), e);
609         });
611         // Bind a focus handler.
612         this.items.focus(function(e) {
613             return thisObj.handleFocus($(this), e);
614         });
616         // Bind a blur handler.
617         this.items.blur(function(e) {
618             return thisObj.handleBlur($(this), e);
619         });
621     };
623     return /** @alias module:tool_lp/tree */ Tree;
624 });