[ Index ]

PHP Cross Reference of Unnamed Project

title

Body

[close]

/lib/amd/src/ -> tree.js (source)

   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  });


Generated: Thu Aug 11 10:00:09 2016 Cross-referenced by PHPXref 0.7.1