trilium/frontend/js/fancytree/src/jquery.fancytree.ariagrid.js
2017-05-22 20:32:19 -04:00

586 lines
18 KiB
JavaScript

/*!
* 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-2017, Martin Wendt (http://wwWendt.de)
*
* Released under the MIT license
* https://github.com/mar10/fancytree/wiki/LicenseInfo
*
* @version 2.22.5
* @date 2017-05-11T17:01:53Z
*/
;( function( $, window, document, undefined ) {
"use strict";
/*
- References:
- https://github.com/w3c/aria-practices/issues/132
- https://rawgit.com/w3c/aria-practices/treegrid/examples/treegrid/treegrid-1.html
- https://github.com/mar10/fancytree/issues/709
TODO:
- In strict mode, how can a user leave an embedded text input, if it is
the only control in a row?
- If rows are hidden I suggest aria-hidden="true" on them (may be optional)
=> aria-hidden currently not set (instead: style="display: none") needs to
be added to ext-table
- enable treeOpts.aria by default
=> requires some benchmarks, confirm it does not affect performance too much
- make ext-ariagrid part of ext-table (enable behavior with treeOpts.aria option)
=> Requires stable specification
*/
/*******************************************************************************
* Private functions and variables
*/
// Allow these navigation keys even when input controls are focused
var FT = $.ui.fancytree,
// TODO: define attribute- and class-names for better compression:
clsFancytreeActiveCell = "fancytree-active-cell",
clsFancytreeCellMode = "fancytree-cell-mode",
clsFancytreeCellNavMode = "fancytree-cell-nav-mode",
// Define which keys are handled by embedded control, and should *not* be
// passed to tree navigation handler:
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" ];
/* Calculate TD column index (considering colspans).*/
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;
}
/**
* [ext-ariagrid] Set active cell and activate cell-mode if needed.
* Pass $td=null to enter row-mode.
*
* See also FancytreeNode#setActive(flag, {cell: idx})
*
* @param {jQuery | Element | integer} [$td]
* @alias Fancytree#activateCell
* @requires jquery.fancytree.ariagrid.js
* @since 2.23
*/
$.ui.fancytree._FancytreeClass.prototype.activateCell = function( $td ) {
var $input, $tr,
treeOpts = this.options,
opts = treeOpts.ariagrid,
$prevTd = this.$activeTd || null,
$prevTr = $prevTd ? $prevTd.closest( "tr" ) : null;
// this.debug( "activateCell: " + ( $prevTd ? $prevTd.text() : "null" ) +
// " -> " + ( $td ? $td.text() : "OFF" ) );
// TODO: make available as event
// if( this._triggerNodeEvent("cellActivate", node, event, {activeTd: tree.$activeTd, colIdx: colIdx}) === false ) {
// return false;
// }
if ( $td ) {
FT.assert( $td.length, "Invalid active cell" );
this.$container.addClass( clsFancytreeCellMode );
$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" );
FT.getNode( $td ).setActive();
$td.addClass( clsFancytreeActiveCell );
this.$activeTd = $td;
$input = $td.find( ":input:enabled,a" );
this.debug( "Focus input", $input );
if ( opts.autoFocusInput && $input.length ) {
$input.focus();
setActiveDescendant( this, $input );
} else {
$td.attr( "tabindex", "-1" ).focus();
setActiveDescendant( this, $td );
}
} else {
// $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.22.5",
// Default options for this extension.
options: {
// Internal behavior flags, currently controlled via `extendedMode`
autoFocusInput: true, // true: user must hit Enter to focus control
activateCellOnDoubelclick: true,
enterToCellMode: false,
// End of internal flags
extendedMode: false,
cellFocus: "allow",
// TODO: document `defaultCellAction` event
// 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`" );
}
this._superApply( arguments );
this.$activeTd = null;
this.forceNavMode = false;
if ( opts.extendedMode ) {
opts.autoFocusInput = true; // false;
opts.enterToCellMode = true;
opts.activateCellOnDoubelclick = true;
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" );
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 );
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" ) {
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 );
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 );
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,
// treeOpts = ctx.options,
// opts = treeOpts.ariagrid,
$tr = $( node.tr );
flag = ( flag !== false );
// node.debug( "nodeSetActive(" + flag + ")" );
// 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, $td,
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 && opts.extendedMode &&
tree.forceNavMode && $.inArray( eventString, NAV_KEYS ) >= 0;
if ( $target.is( ":input:enabled" ) ) {
inputType = $target.prop( "type" );
} else if ( $target.is( "a" ) ) {
inputType = "link";
}
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
if ( $td || opts.cellFocus !== "force" ) {
tree.activateCell( $td );
}
return false;
}
break;
case "esc":
if ( opts.extendedMode && $activeTd && !tree.forceNavMode ) {
// Extended mode: switch from cell-edit-mode to cell-nav-mode
// $target.closest( "td" ).focus();
tree.forceNavMode = true;
tree.$container.addClass( clsFancytreeCellNavMode );
tree.debug( "Enter cell-edit-mode" );
return false;
} else if ( opts.extendedMode && $activeTd && opts.cellFocus !== "force" ) {
// Extended mode: switch back from cell-mode to row-mode
tree.activateCell( null );
return false;
}
break;
case "return":
// Let caller override the default action:
if ( tree._triggerNodeEvent( "defaultCellAction", node, event,
{ activeTd: tree.$activeTd, colIdx: colIdx }) === false ) {
return false;
}
if ( $activeTd ) {
// Apply 'default action' for embedded cell control
if ( colIdx === this.nodeColumnIdx ) {
node.toggleExpanded();
return false;
} else if ( colIdx === this.checkboxColumnIdx ) {
node.toggleSelected();
return false;
} else if ( $activeTd.find( ":checkbox:enabled" ).length ) {
// Embedded checkboxes are always toggled (ignoring `autoFocusInput`)
$activeTd.find( ":checkbox:enabled" ).click();
return false;
} else if ( opts.extendedMode && tree.forceNavMode && $target.is( ":input" ) ) {
tree.forceNavMode = false;
tree.$container.removeClass( clsFancytreeCellNavMode );
tree.debug( "enable cell-edit-mode" );
// Switch from input-mode back to cell-mode
// setTimeout( function() {
// $activeTd.focus();
// }, 0 );
// } else if ( !opts.autoFocusInput && !inputType &&
// $activeTd.find( ":input:enabled" ).length ) {
// // autoFocusInput is off:
// // If we don't focus embedded inputs on navigation by default,
// // we do it as ENTER default action
// $target.find( ":input:enabled" ).focus();
// return false;
} else if ( $activeTd.find( "a" ).length ) {
$activeTd.find( "a" )[ 0 ].click();
return false;
}
return false;
} else {
// ENTER in row-mode
if ( opts.enterToCellMode ) {
// Switch from row-mode to cell-mode
$td = $( node.tr ).find( ">td:nth(" + this.nodeColumnIdx + ")" );
tree.activateCell( $td );
return false; // no default handling
}
}
break;
case "space":
if ( $activeTd ) {
if ( colIdx === this.checkboxColumnIdx ) {
node.toggleSelected();
} else if ( $activeTd.find( ":checkbox:enabled" ).length ) {
$activeTd.find( ":checkbox:enabled" ).click();
}
return false; // no default handling
}
break;
default:
// Allow to focus input by typing alphanum keys:
// TODO: not so easy to simulate keytrokes on inputs!
// http://stackoverflow.com/questions/6124692/jquery-simulating-keypress-event-on-an-input-field
// if ( opts.extendedMode && $activeTd &&
// eventString.length === 1 &&
// !$target.is(":input") && $activeTd.find(":input:enabled").length ) {
// alert("in");
// return false; // no default handling
// }
}
return this._superApply( arguments );
}
});
})( jQuery, window, document );