Proyectos de Subversion Moodle

Rev

| Ultima modificación | Ver Log |

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