UNPKG

jquery.fancytree

Version:

JavaScript tree view / tree grid plugin with support for keyboard, inline editing, filtering, checkboxes, drag'n'drop, and lazy loading

636 lines (572 loc) 19.7 kB
/*! * jquery.fancytree.ariagrid.js * * Support ARIA compliant markup and keyboard navigation for tree grids with * embedded input controls. * (Extension module for jquery.fancytree.js: https://github.com/mar10/fancytree/) * * @requires ext-table * * Copyright (c) 2008-2018, Martin Wendt (http://wwWendt.de) * * Released under the MIT license * https://github.com/mar10/fancytree/wiki/LicenseInfo * * @version 2.30.0 * @date 2018-09-02T15:42:49Z */ ( function( factory ) { if ( typeof define === "function" && define.amd ) { // AMD. Register as an anonymous module. define([ "jquery", "./jquery.fancytree", "./jquery.fancytree.table" ], factory ); } else if ( typeof module === "object" && module.exports ) { // Node/CommonJS require( "./jquery.fancytree.table" ); // core + table module.exports = factory( require( "jquery" ) ); } else { // Browser globals factory( jQuery ); } }( function( $ ) { "use strict"; /******************************************************************************* * Private functions and variables */ // Allow these navigation keys even when input controls are focused var FT = $.ui.fancytree, clsFancytreeActiveCell = "fancytree-active-cell", clsFancytreeCellMode = "fancytree-cell-mode", clsFancytreeCellNavMode = "fancytree-cell-nav-mode", VALID_MODES = [ "allow", "force", "start", "off" ], // Define which keys are handled by embedded <input> control, and should // *not* be passed to tree navigation handler in cell-edit mode: INPUT_KEYS = { "text": [ "left", "right", "home", "end", "backspace" ], "number": [ "up", "down", "left", "right", "home", "end", "backspace" ], "checkbox": [], "link": [], "radiobutton": [ "up", "down" ], "select-one": [ "up", "down" ], "select-multiple": [ "up", "down" ] }, NAV_KEYS = [ "up", "down", "left", "right", "home", "end" ]; /* Set aria-activedescendant on container to active cell's ID (generate one if required).*/ function setActiveDescendant( tree, $target ) { var id = $target ? $target.uniqueId().attr( "id" ) : ""; tree.$container.attr( "aria-activedescendant", id ); } /* Calculate TD column index (considering colspans).*/ function getColIdx( $tr, $td ) { var colspan, td = $td.get( 0 ), idx = 0; $tr.children().each( function() { if ( this === td ) { return false; } colspan = $( this ).prop( "colspan" ); idx += colspan ? colspan : 1; }); return idx; } /* Find TD at given column index (considering colspans).*/ function findTdAtColIdx( $tr, colIdx ) { var colspan, res = null, idx = 0; $tr.children().each( function() { if ( idx >= colIdx ) { res = $( this ); return false; } colspan = $( this ).prop( "colspan" ); idx += colspan ? colspan : 1; }); return res; } /* Find adjacent cell for a given direction. Skip empty cells and consider merged cells */ function findNeighbourTd( tree, $target, keyCode ) { var $td = $target.closest( "td" ), $tr = $td.parent(), treeOpts = tree.options, colIdx = getColIdx( $tr, $td ), $tdNext = null; switch ( keyCode ) { case "left": $tdNext = treeOpts.rtl ? $td.next() : $td.prev(); break; case "right": $tdNext = treeOpts.rtl ? $td.prev() : $td.next(); break; case "up": case "down": while ( true ) { $tr = keyCode === "up" ? $tr.prev() : $tr.next(); if ( !$tr.length ) { break; } // Skip hidden rows if ( $tr.is( ":hidden" ) ) { continue; } // Find adjacent cell in the same column $tdNext = findTdAtColIdx( $tr, colIdx ); break; } break; case "ctrl+home": $tdNext = findTdAtColIdx( $tr.siblings().first(), colIdx ); if ( $tdNext.is( ":hidden" ) ) { $tdNext = findNeighbourTd( tree, $tdNext.parent(), "down" ); } break; case "ctrl+end": $tdNext = findTdAtColIdx( $tr.siblings().last(), colIdx ); if ( $tdNext.is( ":hidden" ) ) { $tdNext = findNeighbourTd( tree, $tdNext.parent(), "up" ); } break; case "home": $tdNext = treeOpts.rtl ? $tr.children( "td" ).last() : $tr.children( "td" ).first(); break; case "end": $tdNext = treeOpts.rtl ? $tr.children( "td" ).first() : $tr.children( "td" ).last(); break; } return ( $tdNext && $tdNext.length ) ? $tdNext : null; } /* Return a descriptive string of the current mode. */ function getGridNavMode( tree ) { if ( tree.$activeTd ) { return tree.forceNavMode ? "cell-nav" : "cell-edit"; } return "row"; } /* .*/ function activateEmbeddedLink( $td ) { // $td.find( "a" )[ 0 ].click(); // does not work (always)? // $td.find( "a" ).click(); var event = document.createEvent( "MouseEvent" ); event = new CustomEvent( "click" ); var a = $td.find( "a" )[ 0 ]; // document.getElementById('nameOfID'); a.dispatchEvent( event ); } /** * [ext-ariagrid] Set active cell and activate cell-nav or cell-edit mode if needed. * Pass $td=null to enter row-mode. * * See also FancytreeNode#setActive(flag, {cell: idx}) * * @param {jQuery | Element | integer} [$td] * @param {Event|null} [orgEvent=null] * @alias Fancytree#activateCell * @requires jquery.fancytree.ariagrid.js * @since 2.23 */ $.ui.fancytree._FancytreeClass.prototype.activateCell = function( $td, orgEvent ) { var colIdx, $input, $tr, res, tree = this, $prevTd = this.$activeTd || null, newNode = $td ? FT.getNode( $td ) : null, prevNode = $prevTd ? FT.getNode( $prevTd ) : null, anyNode = newNode || prevNode, $prevTr = $prevTd ? $prevTd.closest( "tr" ) : null; anyNode.debug( "activateCell(" + ( $prevTd ? $prevTd.text() : "null" ) + " -> " + ( $td ? $td.text() : "OFF" ) ); // Make available as event if ( $td ) { FT.assert( $td.length, "Invalid active cell" ); colIdx = getColIdx( $( newNode.tr ), $td ); res = this._triggerNodeEvent( "activateCell", newNode, orgEvent, { activeTd: tree.$activeTd, colIdx: colIdx, mode: null // editMode ? "cell-edit" : "cell-nav" }); if ( res === false ) { return false; } this.$container.addClass( clsFancytreeCellMode ); this.$container.toggleClass( clsFancytreeCellNavMode, !!this.forceNavMode ); $tr = $td.closest( "tr" ); if ( $prevTd ) { // cell-mode => cell-mode if ( $prevTd.is( $td ) ) { return; } $prevTd .removeAttr( "tabindex" ) .removeClass( clsFancytreeActiveCell ); if ( !$prevTr.is( $tr ) ) { // We are moving to a different row: only the inputs in the // active row should be tabbable $prevTr.find( ">td :input,a" ).attr( "tabindex", "-1" ); } } $tr.find( ">td :input:enabled,a" ).attr( "tabindex", "0" ); newNode.setActive(); $td.addClass( clsFancytreeActiveCell ); this.$activeTd = $td; $input = $td.find( ":input:enabled,a" ); this.debug( "Focus input", $input ); if ( $input.length ) { $input.focus(); setActiveDescendant( this, $input ); } else { $td.attr( "tabindex", "-1" ).focus(); setActiveDescendant( this, $td ); } } else { res = this._triggerNodeEvent( "activateCell", prevNode, orgEvent, { activeTd: null, colIdx: null, mode: "row" }); if ( res === false ) { return false; } // $td == null: switch back to row-mode this.$container.removeClass( clsFancytreeCellMode + " " + clsFancytreeCellNavMode ); // console.log("activateCell: set row-mode for " + this.activeNode, $prevTd); if ( $prevTd ) { // cell-mode => row-mode $prevTd .removeAttr( "tabindex" ) .removeClass( clsFancytreeActiveCell ); // In row-mode, only embedded inputs of the active row are tabbable $prevTr.find( "td" ) .blur() // we need to blur first, because otherwise the focus frame is not reliably removed(?) .removeAttr( "tabindex" ); $prevTr.find( ">td :input,a" ).attr( "tabindex", "-1" ); this.$activeTd = null; // The cell lost focus, but the tree still needs to capture keys: this.activeNode.setFocus(); setActiveDescendant( this, $tr ); } else { // row-mode => row-mode (nothing to do) } } }; /******************************************************************************* * Extension code */ $.ui.fancytree.registerExtension({ name: "ariagrid", version: "2.30.0", // Default options for this extension. options: { // Internal behavior flags activateCellOnDoubelclick: true, cellFocus: "allow", // TODO: use a global tree option `name` or `title` instead?: label: "Tree Grid" // Added as `aria-label` attribute }, treeInit: function( ctx ) { var tree = ctx.tree, treeOpts = ctx.options, opts = treeOpts.ariagrid; // ariagrid requires the table extension to be loaded before itself this._requireExtension( "table", true, true ); if ( !treeOpts.aria ) { $.error( "ext-ariagrid requires `aria: true`" ); } if ( $.inArray( opts.cellFocus, VALID_MODES ) < 0 ) { $.error( "Invalid `cellFocus` option" ); } this._superApply( arguments ); // The combination of $activeTd and forceNavMode determines the current // navigation mode: this.$activeTd = null; // active cell (null in row-mode) this.forceNavMode = true; this.$container .addClass( "fancytree-ext-ariagrid" ) .toggleClass( clsFancytreeCellNavMode, !!this.forceNavMode ) .attr( "aria-label", "" + opts.label ); this.$container.find( "thead > tr > th" ) .attr( "role", "columnheader" ); // Store table options for easier evaluation of default actions // depending of active cell column this.nodeColumnIdx = treeOpts.table.nodeColumnIdx; this.checkboxColumnIdx = treeOpts.table.checkboxColumnIdx; if ( this.checkboxColumnIdx == null ) { this.checkboxColumnIdx = this.nodeColumnIdx; } this.$container.on( "focusin", function( event ) { // Activate node if embedded input gets focus (due to a click) var node = FT.getNode( event.target ), $td = $( event.target ).closest( "td" ); // tree.debug( "focusin: " + ( node ? node.title : "null" ) + // ", target: " + ( $td ? $td.text() : null ) + // ", node was active: " + ( node && node.isActive() ) + // ", last cell: " + ( tree.$activeTd ? tree.$activeTd.text() : null ) ); // tree.debug( "focusin: target", event.target ); // TODO: add ":input" as delegate filter instead of testing here if ( node && !$td.is( tree.$activeTd ) && $( event.target ).is( ":input" ) ) { node.debug( "Activate cell on INPUT focus event" ); tree.activateCell( $td ); } }).on( "fancytreeinit", function( event, data ) { if ( opts.cellFocus === "start" || opts.cellFocus === "force" ) { tree.debug( "Enforce cell-mode on init" ); tree.debug( "init", ( tree.getActiveNode() || tree.getFirstChild() ) ); ( tree.getActiveNode() || tree.getFirstChild() ) .setActive( true, { cell: tree.nodeColumnIdx }); tree.debug( "init2", ( tree.getActiveNode() || tree.getFirstChild() ) ); } }).on( "fancytreefocustree", function( event, data ) { // Enforce cell-mode when container gets focus if ( opts.cellFocus === "force" && !tree.activeTd ) { var node = tree.getActiveNode() || tree.getFirstChild(); tree.debug( "Enforce cell-mode on focusTree event" ); node.setActive( true, { cell: 0 }); } }); }, nodeClick: function( ctx ) { var targetType = ctx.targetType, tree = ctx.tree, node = ctx.node, event = ctx.originalEvent, $td = $( event.target ).closest( "td" ); tree.debug( "nodeClick: node: " + ( node ? node.title : "null" ) + ", targetType: " + targetType + ", target: " + ( $td.length ? $td.text() : null ) + ", node was active: " + ( node && node.isActive() ) + ", last cell: " + ( tree.$activeTd ? tree.$activeTd.text() : null ) ); if ( tree.$activeTd ) { // If already in cell-mode, activate new cell tree.activateCell( $td ); if ( $( event.target ).is( ":input" ) ) { return; } return false; } return this._superApply( arguments ); }, nodeDblclick: function( ctx ) { var tree = ctx.tree, treeOpts = ctx.options, opts = treeOpts.ariagrid, event = ctx.originalEvent, $td = $( event.target ).closest( "td" ); // console.log("nodeDblclick", tree.$activeTd, ctx.options.ariagrid.cellFocus) if ( opts.activateCellOnDoubelclick && !tree.$activeTd && opts.cellFocus === "allow" ) { // If in row-mode, activate new cell tree.activateCell( $td ); return false; } return this._superApply( arguments ); }, nodeRenderStatus: function( ctx ) { // Set classes for current status var res, node = ctx.node, $tr = $( node.tr ); res = this._super( ctx ); if ( node.parent ) { $tr .attr( "aria-level", node.getLevel() ) .attr( "aria-setsize", node.parent.children.length ) .attr( "aria-posinset", node.getIndex() + 1 ); // 2018-06-24: not required according to // https://github.com/w3c/aria-practices/issues/132#issuecomment-397698250 // if ( $tr.is( ":hidden" ) ) { // $tr.attr( "aria-hidden", true ); // } else { // $tr.removeAttr( "aria-hidden" ); // } // this.debug("nodeRenderStatus: " + this.$activeTd + ", " + $tr.attr("aria-expanded")); // In cell-mode, move aria-expanded attribute from TR to first child TD if ( this.$activeTd && $tr.attr( "aria-expanded" ) != null ) { $tr.remove( "aria-expanded" ); $tr.find( "td" ).eq( this.nodeColumnIdx ) .attr( "aria-expanded", node.isExpanded() ); } else { $tr.find( "td" ).eq( this.nodeColumnIdx ).removeAttr( "aria-expanded" ); } } return res; }, nodeSetActive: function( ctx, flag, callOpts ) { var $td, node = ctx.node, tree = ctx.tree, $tr = $( node.tr ); flag = ( flag !== false ); node.debug( "nodeSetActive(" + flag + ")", callOpts ); // Support custom `cell` option if ( flag && callOpts && callOpts.cell != null ) { // `cell` may be a col-index, <td>, or `$(td)` if ( typeof callOpts.cell === "number" ) { $td = findTdAtColIdx( $tr, callOpts.cell ); } else { $td = $( callOpts.cell ); } tree.activateCell( $td ); return; } // tree.debug( "nodeSetActive: activeNode " + this.activeNode ); return this._superApply( arguments ); }, nodeKeydown: function( ctx ) { var handleKeys, inputType, res, $td, $embeddedCheckbox = null, tree = ctx.tree, node = ctx.node, treeOpts = ctx.options, opts = treeOpts.ariagrid, event = ctx.originalEvent, eventString = FT.eventToString( event ), $target = $( event.target ), $activeTd = this.$activeTd, $activeTr = $activeTd ? $activeTd.closest( "tr" ) : null, colIdx = $activeTd ? getColIdx( $activeTr, $activeTd ) : -1, forceNav = $activeTd && tree.forceNavMode && $.inArray( eventString, NAV_KEYS ) >= 0; if ( opts.cellFocus === "off" ) { return this._superApply( arguments ); } if ( $target.is( ":input:enabled" ) ) { inputType = $target.prop( "type" ); } else if ( $target.is( "a" ) ) { inputType = "link"; } if ( $activeTd && $activeTd.find( ":checkbox:enabled" ).length === 1 ) { $embeddedCheckbox = $activeTd.find( ":checkbox:enabled" ); inputType = "checkbox"; } ctx.tree.debug( "nodeKeydown(" + eventString + "), activeTd: '" + ( $activeTd && $activeTd.text() ) + "', inputType: " + inputType ); if ( inputType && eventString !== "esc" && !forceNav ) { handleKeys = INPUT_KEYS[ inputType ]; if ( handleKeys && $.inArray( eventString, handleKeys ) >= 0 ) { return; // Let input control handle the key } } switch ( eventString ) { case "right": if ( $activeTd ) { // Cell mode: move to neighbour (stop on right border) $td = findNeighbourTd( tree, $activeTd, eventString ); if ( $td ) { tree.activateCell( $td ); } } else if ( node && !node.isExpanded() && node.hasChildren() !== false ) { // Row mode and current node can be expanded: // default handling will expand. break; } else { // Row mode: switch to cell-mode $td = $( node.tr ).find( ">td:first" ); tree.activateCell( $td ); } return false; // no default handling case "left": case "home": case "end": case "ctrl+home": case "ctrl+end": case "up": case "down": if ( $activeTd ) { // Cell mode: move to neighbour $td = findNeighbourTd( tree, $activeTd, eventString ); // Note: $td may be null if we move outside bounds. In this case // we switch back to row-mode (i.e. call activateCell(null) ). if ( !$td && "left right".indexOf( eventString ) < 0 ) { // Only switch to row-mode if left/right hits the bounds return false; } if ( $td || opts.cellFocus !== "force" ) { tree.activateCell( $td ); } return false; } break; case "esc": if ( $activeTd && !tree.forceNavMode ) { // Switch from cell-edit-mode to cell-nav-mode // $target.closest( "td" ).focus(); tree.forceNavMode = true; tree.debug( "Enter cell-nav-mode" ); tree.$container.toggleClass( clsFancytreeCellNavMode, !!tree.forceNavMode ); return false; } else if ( $activeTd && opts.cellFocus !== "force" ) { // Switch back from cell-mode to row-mode tree.activateCell( null ); return false; } // tree.$container.toggleClass( clsFancytreeCellNavMode, !!tree.forceNavMode ); break; case "return": // Let user override the default action. // This event is triggered in row-mode and cell-mode res = tree._triggerNodeEvent( "defaultGridAction", node, event, { activeTd: tree.$activeTd ? tree.$activeTd[ 0 ] : null, colIdx: colIdx, mode: getGridNavMode( tree ) }); if ( res === false ) { return false; } // Implement default actions (for cell-mode only). if ( $activeTd ) { // Apply 'default action' for embedded cell control if ( colIdx === this.nodeColumnIdx ) { node.toggleExpanded(); } else if ( colIdx === this.checkboxColumnIdx ) { // TODO: only in checkbox mode! node.toggleSelected(); } else if ( $embeddedCheckbox ) { // Embedded checkboxes are always toggled (ignoring `autoFocusInput`) $embeddedCheckbox.prop( "checked", !$embeddedCheckbox.prop( "checked" ) ); } else if ( tree.forceNavMode && $target.is( ":input" ) ) { tree.forceNavMode = false; tree.$container.removeClass( clsFancytreeCellNavMode ); tree.debug( "enable cell-edit-mode" ); } else if ( $activeTd.find( "a" ).length === 1 ) { activateEmbeddedLink( $activeTd ); } } else { // ENTER in row-mode: Switch from row-mode to cell-mode // TODO: it was also suggested to expand/collapse instead // https://github.com/w3c/aria-practices/issues/132#issuecomment-407634891 $td = $( node.tr ).find( ">td:nth(" + this.nodeColumnIdx + ")" ); tree.activateCell( $td ); } return false; // no default handling case "space": if ( $activeTd ) { if ( colIdx === this.checkboxColumnIdx ) { node.toggleSelected(); } else if ( $embeddedCheckbox ) { $embeddedCheckbox.prop( "checked", !$embeddedCheckbox.prop( "checked" ) ); } return false; // no default handling } break; default: // Allow to focus input by typing alphanum keys } return this._superApply( arguments ); }, treeSetOption: function( ctx, key, value ) { var tree = ctx.tree, opts = tree.options.ariagrid; if ( key === "ariagrid" ) { // User called `$().fancytree("option", "ariagrid.SUBKEY", VALUE)` if ( value.cellFocus !== opts.cellFocus ) { if ( $.inArray( value.cellFocus, VALID_MODES ) < 0 ) { $.error( "Invalid `cellFocus` option" ); } // TODO: fix current focus and mode } } return this._superApply( arguments ); } }); // Value returned by `require('jquery.fancytree..')` return $.ui.fancytree; }) ); // End of closure