[ Index ]

PHP Cross Reference of Unnamed Project

title

Body

[close]

/admin/tool/lp/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   * 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') + '"/>');
  35  
  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);
  45  
  46          this.items = this.treeRoot.find('li');
  47          this.expandAll = this.items.length < 20;
  48          this.parents = this.treeRoot.find('li:has(ul)');
  49  
  50          if (multiSelect) {
  51              this.treeRoot.attr('aria-multiselectable', 'true');
  52          }
  53  
  54          this.items.attr('aria-selected', 'false');
  55  
  56          this.visibleItems = null;
  57          this.activeItem = null;
  58          this.lastActiveItem = null;
  59  
  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          };
  75  
  76          this.init();
  77  
  78          this.bindEventHandlers();
  79      };
  80      // Public variables and functions.
  81  
  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());
  89  
  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');
  94  
  95          this.visibleItems = this.treeRoot.find('li');
  96  
  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      };
 105  
 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');
 115  
 116          // Expand the group.
 117          group.show().attr('aria-hidden', 'false');
 118  
 119          item.attr('aria-expanded', 'true');
 120  
 121          item.children('img').attr('src', expandedImage.attr('src'));
 122  
 123          // Update the list of visible items.
 124          this.visibleItems = this.treeRoot.find('li:visible');
 125      };
 126  
 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');
 135  
 136          // Collapse the group.
 137          group.hide().attr('aria-hidden', 'true');
 138  
 139          item.attr('aria-expanded', 'false');
 140  
 141          item.children('img').attr('src', collapsedImage.attr('src'));
 142  
 143          // Update the list of visible items.
 144          this.visibleItems = this.treeRoot.find('li:visible');
 145      };
 146  
 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      };
 160  
 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      };
 173  
 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;
 187  
 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          }
 199  
 200          item.attr('aria-selected', 'true');
 201          this.triggerChange();
 202      };
 203  
 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      };
 224  
 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              this.selectItem(item);
 234              return;
 235          }
 236  
 237          var current = item.attr('aria-selected');
 238          if (current === 'true') {
 239              current = 'false';
 240          } else {
 241              current = 'true';
 242          }
 243          item.attr('aria-selected', current);
 244          this.triggerChange();
 245      };
 246  
 247      /**
 248       * Set the focus to this item.
 249       *
 250       * @method updateFocus
 251       * @param {Object} item is the jquery id of the parent item of the group
 252       */
 253      Tree.prototype.updateFocus = function(item) {
 254          this.lastActiveItem = this.activeItem;
 255          this.activeItem = item;
 256          // Expand all nodes up the tree.
 257          var walk = item.parent();
 258          while (walk.attr('role') != 'tree') {
 259              walk = walk.parent();
 260              if (walk.attr('aria-expanded') == 'false') {
 261                  this.expandGroup(walk);
 262              }
 263              walk = walk.parent();
 264          }
 265          this.items.attr('tabindex', '-1');
 266          item.attr('tabindex', 0);
 267      };
 268  
 269      /**
 270       * Handle a key down event - ie navigate the tree.
 271       *
 272       * @method handleKeyDown
 273       * @param {Object} item is the jquery id of the parent item of the group
 274       * @param {Event} e The event.
 275       * @return {Boolean}
 276       */
 277       // This function should be simplified. In the meantime..
 278      // eslint-disable-next-line complexity
 279      Tree.prototype.handleKeyDown = function(item, e) {
 280          var currentIndex = this.visibleItems.index(item);
 281          var newItem = null;
 282          var hasKeyModifier = e.shiftKey || e.ctrlKey || e.metaKey || e.altKey;
 283          var thisObj = this;
 284  
 285          switch (e.keyCode) {
 286              case this.keys.home: {
 287                   // Jump to first item in tree.
 288                  newItem = this.parents.first();
 289                  newItem.focus();
 290                  if (e.shiftKey) {
 291                      this.multiSelectItem(newItem);
 292                  } else if (!hasKeyModifier) {
 293                      this.selectItem(newItem);
 294                  }
 295  
 296                  e.stopPropagation();
 297                  return false;
 298              }
 299              case this.keys.end: {
 300                   // Jump to last visible item.
 301                  newItem = this.visibleItems.last();
 302                  newItem.focus();
 303                  if (e.shiftKey) {
 304                      this.multiSelectItem(newItem);
 305                  } else if (!hasKeyModifier) {
 306                      this.selectItem(newItem);
 307                  }
 308  
 309                  e.stopPropagation();
 310                  return false;
 311              }
 312              case this.keys.enter:
 313              case this.keys.space: {
 314  
 315                  if (e.shiftKey) {
 316                      this.multiSelectItem(item);
 317                  } else if (e.metaKey || e.ctrlKey) {
 318                      this.toggleItem(item);
 319                  } else {
 320                      this.selectItem(item);
 321                  }
 322  
 323                  e.stopPropagation();
 324                  return false;
 325              }
 326              case this.keys.left: {
 327                  if (item.has('ul') && item.attr('aria-expanded') == 'true') {
 328                      this.collapseGroup(item);
 329                  } else {
 330                      // Move up to the parent.
 331                      var itemUL = item.parent();
 332                      var itemParent = itemUL.parent();
 333                      if (itemParent.is('li')) {
 334                          itemParent.focus();
 335                          if (e.shiftKey) {
 336                              this.multiSelectItem(itemParent);
 337                          } else if (!hasKeyModifier) {
 338                              this.selectItem(itemParent);
 339                          }
 340                      }
 341                  }
 342  
 343                  e.stopPropagation();
 344                  return false;
 345              }
 346              case this.keys.right: {
 347                  if (item.has('ul') && item.attr('aria-expanded') == 'false') {
 348                      this.expandGroup(item);
 349                  } else {
 350                      // Move to the first item in the child group.
 351                      newItem = item.children('ul').children('li').first();
 352                      if (newItem.length > 0) {
 353                          newItem.focus();
 354                          if (e.shiftKey) {
 355                              this.multiSelectItem(newItem);
 356                          } else if (!hasKeyModifier) {
 357                              this.selectItem(newItem);
 358                          }
 359                      }
 360                  }
 361  
 362                  e.stopPropagation();
 363                  return false;
 364              }
 365              case this.keys.up: {
 366  
 367                  if (currentIndex > 0) {
 368                      var prev = this.visibleItems.eq(currentIndex - 1);
 369                      prev.focus();
 370                      if (e.shiftKey) {
 371                          this.multiSelectItem(prev);
 372                      } else if (!hasKeyModifier) {
 373                          this.selectItem(prev);
 374                      }
 375                  }
 376  
 377                  e.stopPropagation();
 378                  return false;
 379              }
 380              case this.keys.down: {
 381  
 382                  if (currentIndex < this.visibleItems.length - 1) {
 383                      var next = this.visibleItems.eq(currentIndex + 1);
 384                      next.focus();
 385                      if (e.shiftKey) {
 386                          this.multiSelectItem(next);
 387                      } else if (!hasKeyModifier) {
 388                          this.selectItem(next);
 389                      }
 390                  }
 391                  e.stopPropagation();
 392                  return false;
 393              }
 394              case this.keys.asterisk: {
 395                  // Expand all groups.
 396                  this.parents.each(function() {
 397                      thisObj.expandGroup($(this));
 398                  });
 399  
 400                  e.stopPropagation();
 401                  return false;
 402              }
 403              case this.keys.eight: {
 404                  if (e.shiftKey) {
 405                      // Expand all groups.
 406                      this.parents.each(function() {
 407                          thisObj.expandGroup($(this));
 408                      });
 409  
 410                      e.stopPropagation();
 411                  }
 412  
 413                  return false;
 414              }
 415          }
 416  
 417          return true;
 418      };
 419  
 420      /**
 421       * Handle a key press event - ie navigate the tree.
 422       *
 423       * @method handleKeyPress
 424       * @param {Object} item is the jquery id of the parent item of the group
 425       * @param {Event} e The event.
 426       * @return {Boolean}
 427       */
 428      Tree.prototype.handleKeyPress = function(item, e) {
 429          if (e.altKey || e.ctrlKey || e.shiftKey || e.metaKey) {
 430              // Do nothing.
 431              return true;
 432          }
 433  
 434          switch (e.keyCode) {
 435              case this.keys.tab: {
 436                  return true;
 437              }
 438              case this.keys.enter:
 439              case this.keys.home:
 440              case this.keys.end:
 441              case this.keys.left:
 442              case this.keys.right:
 443              case this.keys.up:
 444              case this.keys.down: {
 445                  e.stopPropagation();
 446                  return false;
 447              }
 448              default : {
 449                  var chr = String.fromCharCode(e.which);
 450                  var match = false;
 451                  var itemIndex = this.visibleItems.index(item);
 452                  var itemCount = this.visibleItems.length;
 453                  var currentIndex = itemIndex + 1;
 454  
 455                  // Check if the active item was the last one on the list.
 456                  if (currentIndex == itemCount) {
 457                      currentIndex = 0;
 458                  }
 459  
 460                  // Iterate through the menu items (starting from the current item and wrapping) until a match is found
 461                  // or the loop returns to the current menu item.
 462                  while (currentIndex != itemIndex) {
 463  
 464                      var currentItem = this.visibleItems.eq(currentIndex);
 465                      var titleChr = currentItem.text().charAt(0);
 466  
 467                      if (currentItem.has('ul')) {
 468                          titleChr = currentItem.find('span').text().charAt(0);
 469                      }
 470  
 471                      if (titleChr.toLowerCase() == chr) {
 472                          match = true;
 473                          break;
 474                      }
 475  
 476                      currentIndex = currentIndex + 1;
 477                      if (currentIndex == itemCount) {
 478                          // Reached the end of the list, start again at the beginning.
 479                          currentIndex = 0;
 480                      }
 481                  }
 482  
 483                  if (match === true) {
 484                      this.updateFocus(this.visibleItems.eq(currentIndex));
 485                  }
 486                  e.stopPropagation();
 487                  return false;
 488              }
 489          }
 490  
 491          // eslint-disable-next-line no-unreachable
 492          return true;
 493      };
 494  
 495      /**
 496       * Attach an event listener to the tree.
 497       *
 498       * @method on
 499       * @param {String} eventname This is the name of the event to listen for. Only 'selectionchanged' is supported for now.
 500       * @param {Function} handler The function to call when the event is triggered.
 501       */
 502      Tree.prototype.on = function(eventname, handler) {
 503          if (eventname !== 'selectionchanged') {
 504              log.warning('Invalid custom event name for tree. Only "selectionchanged" is supported.');
 505          } else {
 506              this.treeRoot.on(eventname, handler);
 507          }
 508      };
 509  
 510      /**
 511       * Handle a double click (expand/collapse).
 512       *
 513       * @method handleDblClick
 514       * @param {Object} item is the jquery id of the parent item of the group
 515       * @param {Event} e The event.
 516       * @return {Boolean}
 517       */
 518      Tree.prototype.handleDblClick = function(item, e) {
 519  
 520          if (e.altKey || e.ctrlKey || e.shiftKey || e.metaKey) {
 521              // Do nothing.
 522              return true;
 523          }
 524  
 525          // Apply the focus markup.
 526          this.updateFocus(item);
 527  
 528          // Expand or collapse the group.
 529          this.toggleGroup(item);
 530  
 531          e.stopPropagation();
 532          return false;
 533      };
 534  
 535      /**
 536       * Handle a click (select).
 537       *
 538       * @method handleExpandCollapseClick
 539       * @param {Object} item is the jquery id of the parent item of the group
 540       * @param {Event} e The event.
 541       * @return {Boolean}
 542       */
 543      Tree.prototype.handleExpandCollapseClick = function(item, e) {
 544  
 545          // Do not shift the focus.
 546          this.toggleGroup(item);
 547          e.stopPropagation();
 548          return false;
 549      };
 550  
 551  
 552      /**
 553       * Handle a click (select).
 554       *
 555       * @method handleClick
 556       * @param {Object} item is the jquery id of the parent item of the group
 557       * @param {Event} e The event.
 558       * @return {Boolean}
 559       */
 560      Tree.prototype.handleClick = function(item, e) {
 561  
 562          if (e.shiftKey) {
 563              this.multiSelectItem(item);
 564          } else if (e.metaKey || e.ctrlKey) {
 565              this.toggleItem(item);
 566          } else {
 567              this.selectItem(item);
 568          }
 569          this.updateFocus(item);
 570          e.stopPropagation();
 571          return false;
 572      };
 573  
 574      /**
 575       * Handle a blur event
 576       *
 577       * @method handleBlur
 578       * @param {Object} item item is the jquery id of the parent item of the group
 579       * @param {Event} e The event.
 580       * @return {Boolean}
 581       */
 582      Tree.prototype.handleBlur = function() {
 583          return true;
 584      };
 585  
 586      /**
 587       * Handle a focus event
 588       *
 589       * @method handleFocus
 590       * @param {Object} item item is the jquery id of the parent item of the group
 591       * @param {Event} e The event.
 592       * @return {Boolean}
 593       */
 594      Tree.prototype.handleFocus = function(item) {
 595  
 596          this.updateFocus(item);
 597  
 598          return true;
 599      };
 600  
 601      /**
 602       * Bind the event listeners we require.
 603       *
 604       * @method bindEventHandlers
 605       */
 606      Tree.prototype.bindEventHandlers = function() {
 607          var thisObj = this;
 608  
 609          // Bind a dblclick handler to the parent items.
 610          this.parents.dblclick(function(e) {
 611              return thisObj.handleDblClick($(this), e);
 612          });
 613  
 614          // Bind a click handler.
 615          this.items.click(function(e) {
 616              return thisObj.handleClick($(this), e);
 617          });
 618  
 619          // Bind a toggle handler to the expand/collapse icons.
 620          this.items.children('img').click(function(e) {
 621              return thisObj.handleExpandCollapseClick($(this).parent(), e);
 622          });
 623  
 624          // Bind a keydown handler.
 625          this.items.keydown(function(e) {
 626              return thisObj.handleKeyDown($(this), e);
 627          });
 628  
 629          // Bind a keypress handler.
 630          this.items.keypress(function(e) {
 631              return thisObj.handleKeyPress($(this), e);
 632          });
 633  
 634          // Bind a focus handler.
 635          this.items.focus(function(e) {
 636              return thisObj.handleFocus($(this), e);
 637          });
 638  
 639          // Bind a blur handler.
 640          this.items.blur(function(e) {
 641              return thisObj.handleBlur($(this), e);
 642          });
 643  
 644      };
 645  
 646      return /** @alias module:tool_lp/tree */ Tree;
 647  });


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