1/*! 2 * jquery.fancytree.ariagrid.js 3 * 4 * Support ARIA compliant markup and keyboard navigation for tree grids with 5 * embedded input controls. 6 * (Extension module for jquery.fancytree.js: https://github.com/mar10/fancytree/) 7 * 8 * @requires ext-table 9 * 10 * Copyright (c) 2008-2023, Martin Wendt (https://wwWendt.de) 11 * 12 * Released under the MIT license 13 * https://github.com/mar10/fancytree/wiki/LicenseInfo 14 * 15 * @version 2.38.3 16 * @date 2023-02-01T20:52:50Z 17 */ 18 19(function (factory) { 20 if (typeof define === "function" && define.amd) { 21 // AMD. Register as an anonymous module. 22 define([ 23 "jquery", 24 "./jquery.fancytree", 25 "./jquery.fancytree.table", 26 ], factory); 27 } else if (typeof module === "object" && module.exports) { 28 // Node/CommonJS 29 require("./jquery.fancytree.table"); // core + table 30 module.exports = factory(require("jquery")); 31 } else { 32 // Browser globals 33 factory(jQuery); 34 } 35})(function ($) { 36 "use strict"; 37 38 /******************************************************************************* 39 * Private functions and variables 40 */ 41 42 // Allow these navigation keys even when input controls are focused 43 44 var FT = $.ui.fancytree, 45 clsFancytreeActiveCell = "fancytree-active-cell", 46 clsFancytreeCellMode = "fancytree-cell-mode", 47 clsFancytreeCellNavMode = "fancytree-cell-nav-mode", 48 VALID_MODES = ["allow", "force", "start", "off"], 49 // Define which keys are handled by embedded <input> control, and should 50 // *not* be passed to tree navigation handler in cell-edit mode: 51 INPUT_KEYS = { 52 text: ["left", "right", "home", "end", "backspace"], 53 number: ["up", "down", "left", "right", "home", "end", "backspace"], 54 checkbox: [], 55 link: [], 56 radiobutton: ["up", "down"], 57 "select-one": ["up", "down"], 58 "select-multiple": ["up", "down"], 59 }, 60 NAV_KEYS = ["up", "down", "left", "right", "home", "end"]; 61 62 /* Set aria-activedescendant on container to active cell's ID (generate one if required).*/ 63 function setActiveDescendant(tree, $target) { 64 var id = $target ? $target.uniqueId().attr("id") : ""; 65 66 tree.$container.attr("aria-activedescendant", id); 67 } 68 69 /* Calculate TD column index (considering colspans).*/ 70 function getColIdx($tr, $td) { 71 var colspan, 72 td = $td.get(0), 73 idx = 0; 74 75 $tr.children().each(function () { 76 if (this === td) { 77 return false; 78 } 79 colspan = $(this).prop("colspan"); 80 idx += colspan ? colspan : 1; 81 }); 82 return idx; 83 } 84 85 /* Find TD at given column index (considering colspans).*/ 86 function findTdAtColIdx($tr, colIdx) { 87 var colspan, 88 res = null, 89 idx = 0; 90 91 $tr.children().each(function () { 92 if (idx >= colIdx) { 93 res = $(this); 94 return false; 95 } 96 colspan = $(this).prop("colspan"); 97 idx += colspan ? colspan : 1; 98 }); 99 return res; 100 } 101 102 /* Find adjacent cell for a given direction. Skip empty cells and consider merged cells */ 103 function findNeighbourTd(tree, $target, keyCode) { 104 var nextNode, 105 node, 106 navMap = { "ctrl+home": "first", "ctrl+end": "last" }, 107 $td = $target.closest("td"), 108 $tr = $td.parent(), 109 treeOpts = tree.options, 110 colIdx = getColIdx($tr, $td), 111 $tdNext = null; 112 113 keyCode = navMap[keyCode] || keyCode; 114 115 switch (keyCode) { 116 case "left": 117 $tdNext = treeOpts.rtl ? $td.next() : $td.prev(); 118 break; 119 case "right": 120 $tdNext = treeOpts.rtl ? $td.prev() : $td.next(); 121 break; 122 case "up": 123 case "down": 124 case "ctrl+home": 125 case "ctrl+end": 126 node = $tr[0].ftnode; 127 nextNode = tree.findRelatedNode(node, keyCode); 128 if (nextNode) { 129 nextNode.makeVisible(); 130 nextNode.setActive(); 131 $tdNext = findTdAtColIdx($(nextNode.tr), colIdx); 132 } 133 break; 134 case "home": 135 $tdNext = treeOpts.rtl 136 ? $tr.children("td").last() 137 : $tr.children("td").first(); 138 break; 139 case "end": 140 $tdNext = treeOpts.rtl 141 ? $tr.children("td").first() 142 : $tr.children("td").last(); 143 break; 144 } 145 return $tdNext && $tdNext.length ? $tdNext : null; 146 } 147 148 /* Return a descriptive string of the current mode. */ 149 function getGridNavMode(tree) { 150 if (tree.$activeTd) { 151 return tree.forceNavMode ? "cell-nav" : "cell-edit"; 152 } 153 return "row"; 154 } 155 156 /* .*/ 157 function activateEmbeddedLink($td) { 158 // $td.find( "a" )[ 0 ].trigger("click"); // does not work (always)? 159 // $td.find( "a" ).trigger("click"); 160 var event = document.createEvent("MouseEvent"), 161 a = $td.find("a")[0]; // document.getElementById('nameOfID'); 162 163 event = new CustomEvent("click"); 164 a.dispatchEvent(event); 165 } 166 167 /** 168 * [ext-ariagrid] Set active cell and activate cell-nav or cell-edit mode if needed. 169 * Pass $td=null to enter row-mode. 170 * 171 * See also FancytreeNode#setActive(flag, {cell: idx}) 172 * 173 * @param {jQuery | Element | integer} [$td] 174 * @param {Event|null} [orgEvent=null] 175 * @alias Fancytree#activateCell 176 * @requires jquery.fancytree.ariagrid.js 177 * @since 2.23 178 */ 179 $.ui.fancytree._FancytreeClass.prototype.activateCell = function ( 180 $td, 181 orgEvent 182 ) { 183 var colIdx, 184 $input, 185 $tr, 186 res, 187 tree = this, 188 $prevTd = this.$activeTd || null, 189 newNode = $td ? FT.getNode($td) : null, 190 prevNode = $prevTd ? FT.getNode($prevTd) : null, 191 anyNode = newNode || prevNode, 192 $prevTr = $prevTd ? $prevTd.closest("tr") : null; 193 194 anyNode.debug( 195 "activateCell(" + 196 ($prevTd ? $prevTd.text() : "null") + 197 ") -> " + 198 ($td ? $td.text() : "OFF") 199 ); 200 201 // Make available as event 202 203 if ($td) { 204 FT.assert($td.length, "Invalid active cell"); 205 colIdx = getColIdx($(newNode.tr), $td); 206 res = this._triggerNodeEvent("activateCell", newNode, orgEvent, { 207 activeTd: tree.$activeTd, 208 colIdx: colIdx, 209 mode: null, // editMode ? "cell-edit" : "cell-nav" 210 }); 211 if (res === false) { 212 return false; 213 } 214 this.$container.addClass(clsFancytreeCellMode); 215 this.$container.toggleClass( 216 clsFancytreeCellNavMode, 217 !!this.forceNavMode 218 ); 219 $tr = $td.closest("tr"); 220 if ($prevTd) { 221 // cell-mode => cell-mode 222 if ($prevTd.is($td)) { 223 return; 224 } 225 $prevTd 226 .removeAttr("tabindex") 227 .removeClass(clsFancytreeActiveCell); 228 229 if (!$prevTr.is($tr)) { 230 // We are moving to a different row: only the inputs in the 231 // active row should be tabbable 232 $prevTr.find(">td :input,a").attr("tabindex", "-1"); 233 } 234 } 235 $tr.find(">td :input:enabled,a").attr("tabindex", "0"); 236 newNode.setActive(); 237 $td.addClass(clsFancytreeActiveCell); 238 this.$activeTd = $td; 239 240 $input = $td.find(":input:enabled,a"); 241 this.debug("Focus input", $input); 242 if ($input.length) { 243 $input.focus(); 244 setActiveDescendant(this, $input); 245 } else { 246 $td.attr("tabindex", "-1").focus(); 247 setActiveDescendant(this, $td); 248 } 249 } else { 250 res = this._triggerNodeEvent("activateCell", prevNode, orgEvent, { 251 activeTd: null, 252 colIdx: null, 253 mode: "row", 254 }); 255 if (res === false) { 256 return false; 257 } 258 // $td == null: switch back to row-mode 259 this.$container.removeClass( 260 clsFancytreeCellMode + " " + clsFancytreeCellNavMode 261 ); 262 // console.log("activateCell: set row-mode for " + this.activeNode, $prevTd); 263 if ($prevTd) { 264 // cell-mode => row-mode 265 $prevTd 266 .removeAttr("tabindex") 267 .removeClass(clsFancytreeActiveCell); 268 // In row-mode, only embedded inputs of the active row are tabbable 269 $prevTr 270 .find("td") 271 .blur() // we need to blur first, because otherwise the focus frame is not reliably removed(?) 272 .removeAttr("tabindex"); 273 $prevTr.find(">td :input,a").attr("tabindex", "-1"); 274 this.$activeTd = null; 275 // The cell lost focus, but the tree still needs to capture keys: 276 this.activeNode.setFocus(); 277 setActiveDescendant(this, $tr); 278 } else { 279 // row-mode => row-mode (nothing to do) 280 } 281 } 282 }; 283 284 /******************************************************************************* 285 * Extension code 286 */ 287 $.ui.fancytree.registerExtension({ 288 name: "ariagrid", 289 version: "2.38.3", 290 // Default options for this extension. 291 options: { 292 // Internal behavior flags 293 activateCellOnDoubelclick: true, 294 cellFocus: "allow", 295 // TODO: use a global tree option `name` or `title` instead?: 296 label: "Tree Grid", // Added as `aria-label` attribute 297 }, 298 299 treeInit: function (ctx) { 300 var tree = ctx.tree, 301 treeOpts = ctx.options, 302 opts = treeOpts.ariagrid; 303 304 // ariagrid requires the table extension to be loaded before itself 305 if (tree.ext.grid) { 306 this._requireExtension("grid", true, true); 307 } else { 308 this._requireExtension("table", true, true); 309 } 310 if (!treeOpts.aria) { 311 $.error("ext-ariagrid requires `aria: true`"); 312 } 313 if ($.inArray(opts.cellFocus, VALID_MODES) < 0) { 314 $.error("Invalid `cellFocus` option"); 315 } 316 this._superApply(arguments); 317 318 // The combination of $activeTd and forceNavMode determines the current 319 // navigation mode: 320 this.$activeTd = null; // active cell (null in row-mode) 321 this.forceNavMode = true; 322 323 this.$container 324 .addClass("fancytree-ext-ariagrid") 325 .toggleClass(clsFancytreeCellNavMode, !!this.forceNavMode) 326 .attr("aria-label", "" + opts.label); 327 this.$container 328 .find("thead > tr > th") 329 .attr("role", "columnheader"); 330 331 // Store table options for easier evaluation of default actions 332 // depending of active cell column 333 this.nodeColumnIdx = treeOpts.table.nodeColumnIdx; 334 this.checkboxColumnIdx = treeOpts.table.checkboxColumnIdx; 335 if (this.checkboxColumnIdx == null) { 336 this.checkboxColumnIdx = this.nodeColumnIdx; 337 } 338 339 this.$container 340 .on("focusin", function (event) { 341 // Activate node if embedded input gets focus (due to a click) 342 var node = FT.getNode(event.target), 343 $td = $(event.target).closest("td"); 344 345 // tree.debug( "focusin: " + ( node ? node.title : "null" ) + 346 // ", target: " + ( $td ? $td.text() : null ) + 347 // ", node was active: " + ( node && node.isActive() ) + 348 // ", last cell: " + ( tree.$activeTd ? tree.$activeTd.text() : null ) ); 349 // tree.debug( "focusin: target", event.target ); 350 351 // TODO: add ":input" as delegate filter instead of testing here 352 if ( 353 node && 354 !$td.is(tree.$activeTd) && 355 $(event.target).is(":input") 356 ) { 357 node.debug("Activate cell on INPUT focus event"); 358 tree.activateCell($td); 359 } 360 }) 361 .on("fancytreeinit", function (event, data) { 362 if ( 363 opts.cellFocus === "start" || 364 opts.cellFocus === "force" 365 ) { 366 tree.debug("Enforce cell-mode on init"); 367 tree.debug( 368 "init", 369 tree.getActiveNode() || tree.getFirstChild() 370 ); 371 ( 372 tree.getActiveNode() || tree.getFirstChild() 373 ).setActive(true, { cell: tree.nodeColumnIdx }); 374 tree.debug( 375 "init2", 376 tree.getActiveNode() || tree.getFirstChild() 377 ); 378 } 379 }) 380 .on("fancytreefocustree", function (event, data) { 381 // Enforce cell-mode when container gets focus 382 if (opts.cellFocus === "force" && !tree.$activeTd) { 383 var node = tree.getActiveNode() || tree.getFirstChild(); 384 tree.debug("Enforce cell-mode on focusTree event"); 385 node.setActive(true, { cell: 0 }); 386 } 387 }) 388 // .on("fancytreeupdateviewport", function(event, data) { 389 // tree.debug(event.type, data); 390 // }) 391 .on("fancytreebeforeupdateviewport", function (event, data) { 392 // When scrolling, the TR may be re-used by another node, so the 393 // active cell marker an 394 // tree.debug(event.type, data); 395 if (tree.viewport && tree.$activeTd) { 396 tree.info("Cancel cell-mode due to scroll event."); 397 tree.activateCell(null); 398 } 399 }); 400 }, 401 nodeClick: function (ctx) { 402 var targetType = ctx.targetType, 403 tree = ctx.tree, 404 node = ctx.node, 405 event = ctx.originalEvent, 406 $target = $(event.target), 407 $td = $target.closest("td"); 408 409 tree.debug( 410 "nodeClick: node: " + 411 (node ? node.title : "null") + 412 ", targetType: " + 413 targetType + 414 ", target: " + 415 ($td.length ? $td.text() : null) + 416 ", node was active: " + 417 (node && node.isActive()) + 418 ", last cell: " + 419 (tree.$activeTd ? tree.$activeTd.text() : null) 420 ); 421 422 if (tree.$activeTd) { 423 // If already in cell-mode, activate new cell 424 tree.activateCell($td); 425 if ($target.is(":input")) { 426 return; 427 } else if ( 428 $target.is(".fancytree-checkbox") || 429 $target.is(".fancytree-expander") 430 ) { 431 return this._superApply(arguments); 432 } 433 return false; 434 } 435 return this._superApply(arguments); 436 }, 437 nodeDblclick: function (ctx) { 438 var tree = ctx.tree, 439 treeOpts = ctx.options, 440 opts = treeOpts.ariagrid, 441 event = ctx.originalEvent, 442 $td = $(event.target).closest("td"); 443 444 // console.log("nodeDblclick", tree.$activeTd, ctx.options.ariagrid.cellFocus) 445 if ( 446 opts.activateCellOnDoubelclick && 447 !tree.$activeTd && 448 opts.cellFocus === "allow" 449 ) { 450 // If in row-mode, activate new cell 451 tree.activateCell($td); 452 return false; 453 } 454 return this._superApply(arguments); 455 }, 456 nodeRenderStatus: function (ctx) { 457 // Set classes for current status 458 var res, 459 node = ctx.node, 460 $tr = $(node.tr); 461 462 res = this._super(ctx); 463 464 if (node.parent) { 465 $tr.attr("aria-level", node.getLevel()) 466 .attr("aria-setsize", node.parent.children.length) 467 .attr("aria-posinset", node.getIndex() + 1); 468 469 // 2018-06-24: not required according to 470 // https://github.com/w3c/aria-practices/issues/132#issuecomment-397698250 471 // if ( $tr.is( ":hidden" ) ) { 472 // $tr.attr( "aria-hidden", true ); 473 // } else { 474 // $tr.removeAttr( "aria-hidden" ); 475 // } 476 477 // this.debug("nodeRenderStatus: " + this.$activeTd + ", " + $tr.attr("aria-expanded")); 478 // In cell-mode, move aria-expanded attribute from TR to first child TD 479 if (this.$activeTd && $tr.attr("aria-expanded") != null) { 480 $tr.remove("aria-expanded"); 481 $tr.find("td") 482 .eq(this.nodeColumnIdx) 483 .attr("aria-expanded", node.isExpanded()); 484 } else { 485 $tr.find("td") 486 .eq(this.nodeColumnIdx) 487 .removeAttr("aria-expanded"); 488 } 489 } 490 return res; 491 }, 492 nodeSetActive: function (ctx, flag, callOpts) { 493 var $td, 494 node = ctx.node, 495 tree = ctx.tree, 496 $tr = $(node.tr); 497 498 flag = flag !== false; 499 node.debug("nodeSetActive(" + flag + ")", callOpts); 500 // Support custom `cell` option 501 if (flag && callOpts && callOpts.cell != null) { 502 // `cell` may be a col-index, <td>, or `$(td)` 503 if (typeof callOpts.cell === "number") { 504 $td = findTdAtColIdx($tr, callOpts.cell); 505 } else { 506 $td = $(callOpts.cell); 507 } 508 tree.activateCell($td); 509 return; 510 } 511 // tree.debug( "nodeSetActive: activeNode " + this.activeNode ); 512 return this._superApply(arguments); 513 }, 514 nodeKeydown: function (ctx) { 515 var handleKeys, 516 inputType, 517 res, 518 $td, 519 $embeddedCheckbox = null, 520 tree = ctx.tree, 521 node = ctx.node, 522 treeOpts = ctx.options, 523 opts = treeOpts.ariagrid, 524 event = ctx.originalEvent, 525 eventString = FT.eventToString(event), 526 $target = $(event.target), 527 $activeTd = this.$activeTd, 528 $activeTr = $activeTd ? $activeTd.closest("tr") : null, 529 colIdx = $activeTd ? getColIdx($activeTr, $activeTd) : -1, 530 forceNav = 531 $activeTd && 532 tree.forceNavMode && 533 $.inArray(eventString, NAV_KEYS) >= 0; 534 535 if (opts.cellFocus === "off") { 536 return this._superApply(arguments); 537 } 538 539 if ($target.is(":input:enabled")) { 540 inputType = $target.prop("type"); 541 } else if ($target.is("a")) { 542 inputType = "link"; 543 } 544 if ($activeTd && $activeTd.find(":checkbox:enabled").length === 1) { 545 $embeddedCheckbox = $activeTd.find(":checkbox:enabled"); 546 inputType = "checkbox"; 547 } 548 tree.debug( 549 "nodeKeydown(" + 550 eventString + 551 "), activeTd: '" + 552 ($activeTd && $activeTd.text()) + 553 "', inputType: " + 554 inputType 555 ); 556 557 if (inputType && eventString !== "esc" && !forceNav) { 558 handleKeys = INPUT_KEYS[inputType]; 559 if (handleKeys && $.inArray(eventString, handleKeys) >= 0) { 560 return; // Let input control handle the key 561 } 562 } 563 564 switch (eventString) { 565 case "right": 566 if ($activeTd) { 567 // Cell mode: move to neighbour (stop on right border) 568 $td = findNeighbourTd(tree, $activeTd, eventString); 569 if ($td) { 570 tree.activateCell($td); 571 } 572 } else if ( 573 node && 574 !node.isExpanded() && 575 node.hasChildren() !== false 576 ) { 577 // Row mode and current node can be expanded: 578 // default handling will expand. 579 break; 580 } else { 581 // Row mode: switch to cell-mode 582 $td = $(node.tr).find(">td").first(); 583 tree.activateCell($td); 584 } 585 return false; // no default handling 586 587 case "left": 588 case "home": 589 case "end": 590 case "ctrl+home": 591 case "ctrl+end": 592 case "up": 593 case "down": 594 if ($activeTd) { 595 // Cell mode: move to neighbour 596 $td = findNeighbourTd(tree, $activeTd, eventString); 597 // Note: $td may be null if we move outside bounds. In this case 598 // we switch back to row-mode (i.e. call activateCell(null) ). 599 if (!$td && "left right".indexOf(eventString) < 0) { 600 // Only switch to row-mode if left/right hits the bounds 601 return false; 602 } 603 if ($td || opts.cellFocus !== "force") { 604 tree.activateCell($td); 605 } 606 return false; 607 } 608 break; 609 610 case "esc": 611 if ($activeTd && !tree.forceNavMode) { 612 // Switch from cell-edit-mode to cell-nav-mode 613 // $target.closest( "td" ).focus(); 614 tree.forceNavMode = true; 615 tree.debug("Enter cell-nav-mode"); 616 tree.$container.toggleClass( 617 clsFancytreeCellNavMode, 618 !!tree.forceNavMode 619 ); 620 return false; 621 } else if ($activeTd && opts.cellFocus !== "force") { 622 // Switch back from cell-mode to row-mode 623 tree.activateCell(null); 624 return false; 625 } 626 // tree.$container.toggleClass( clsFancytreeCellNavMode, !!tree.forceNavMode ); 627 break; 628 629 case "return": 630 // Let user override the default action. 631 // This event is triggered in row-mode and cell-mode 632 res = tree._triggerNodeEvent( 633 "defaultGridAction", 634 node, 635 event, 636 { 637 activeTd: tree.$activeTd ? tree.$activeTd[0] : null, 638 colIdx: colIdx, 639 mode: getGridNavMode(tree), 640 } 641 ); 642 if (res === false) { 643 return false; 644 } 645 // Implement default actions (for cell-mode only). 646 if ($activeTd) { 647 // Apply 'default action' for embedded cell control 648 if (colIdx === this.nodeColumnIdx) { 649 node.toggleExpanded(); 650 } else if (colIdx === this.checkboxColumnIdx) { 651 // TODO: only in checkbox mode! 652 node.toggleSelected(); 653 } else if ($embeddedCheckbox) { 654 // Embedded checkboxes are always toggled (ignoring `autoFocusInput`) 655 $embeddedCheckbox.prop( 656 "checked", 657 !$embeddedCheckbox.prop("checked") 658 ); 659 } else if (tree.forceNavMode && $target.is(":input")) { 660 tree.forceNavMode = false; 661 tree.$container.removeClass( 662 clsFancytreeCellNavMode 663 ); 664 tree.debug("enable cell-edit-mode"); 665 } else if ($activeTd.find("a").length === 1) { 666 activateEmbeddedLink($activeTd); 667 } 668 } else { 669 // ENTER in row-mode: Switch from row-mode to cell-mode 670 // TODO: it was also suggested to expand/collapse instead 671 // https://github.com/w3c/aria-practices/issues/132#issuecomment-407634891 672 $td = $(node.tr).find(">td").nth(this.nodeColumnIdx); 673 tree.activateCell($td); 674 } 675 return false; // no default handling 676 677 case "space": 678 if ($activeTd) { 679 if (colIdx === this.checkboxColumnIdx) { 680 node.toggleSelected(); 681 } else if ($embeddedCheckbox) { 682 $embeddedCheckbox.prop( 683 "checked", 684 !$embeddedCheckbox.prop("checked") 685 ); 686 } 687 return false; // no default handling 688 } 689 break; 690 691 default: 692 // Allow to focus input by typing alphanum keys 693 } 694 return this._superApply(arguments); 695 }, 696 treeSetOption: function (ctx, key, value) { 697 var tree = ctx.tree, 698 opts = tree.options.ariagrid; 699 700 if (key === "ariagrid") { 701 // User called `$().fancytree("option", "ariagrid.SUBKEY", VALUE)` 702 if (value.cellFocus !== opts.cellFocus) { 703 if ($.inArray(value.cellFocus, VALID_MODES) < 0) { 704 $.error("Invalid `cellFocus` option"); 705 } 706 // TODO: fix current focus and mode 707 } 708 } 709 return this._superApply(arguments); 710 }, 711 }); 712 // Value returned by `require('jquery.fancytree..')` 713 return $.ui.fancytree; 714}); // End of closure 715