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