define([
|
'dojo/_base/declare',
|
'dojo/aspect',
|
'dojo/dom-class',
|
'dojo/on',
|
'dojo/_base/lang',
|
'dojo/has',
|
'./util/misc',
|
'dojo/_base/sniff'
|
], function (declare, aspect, domClass, on, lang, has, miscUtil) {
|
|
var delegatingInputTypes = {
|
checkbox: 1,
|
radio: 1,
|
button: 1
|
},
|
hasGridCellClass = /\bdgrid-cell\b/,
|
hasGridRowClass = /\bdgrid-row\b/;
|
|
var Keyboard = declare(null, {
|
// summary:
|
// Adds keyboard navigation capability to a list or grid.
|
|
// pageSkip: Number
|
// Number of rows to jump by when page up or page down is pressed.
|
pageSkip: 10,
|
|
tabIndex: 0,
|
|
// keyMap: Object
|
// Hash which maps key codes to functions to be executed (in the context
|
// of the instance) for key events within the grid's body.
|
keyMap: null,
|
|
// headerKeyMap: Object
|
// Hash which maps key codes to functions to be executed (in the context
|
// of the instance) for key events within the grid's header row.
|
headerKeyMap: null,
|
|
postMixInProperties: function () {
|
this.inherited(arguments);
|
|
if (!this.keyMap) {
|
this.keyMap = lang.mixin({}, Keyboard.defaultKeyMap);
|
}
|
if (!this.headerKeyMap) {
|
this.headerKeyMap = lang.mixin({}, Keyboard.defaultHeaderKeyMap);
|
}
|
},
|
|
postCreate: function () {
|
this.inherited(arguments);
|
var grid = this;
|
|
function handledEvent(event) {
|
// Text boxes and other inputs that can use direction keys should be ignored
|
// and not affect cell/row navigation
|
var target = event.target;
|
return target.type && (!delegatingInputTypes[target.type] || event.keyCode === 32);
|
}
|
|
function enableNavigation(areaNode) {
|
var cellNavigation = grid.cellNavigation,
|
isFocusableClass = cellNavigation ? hasGridCellClass : hasGridRowClass,
|
isHeader = areaNode === grid.headerNode,
|
initialNode = areaNode;
|
|
function initHeader() {
|
if (grid._focusedHeaderNode) {
|
// Remove the tab index for the node that previously had it.
|
grid._focusedHeaderNode.tabIndex = -1;
|
}
|
if (grid.showHeader) {
|
if (cellNavigation) {
|
// Get the focused element. Ensure that the focused element
|
// is actually a grid cell, not a column-set-cell or some
|
// other cell that should not be focused
|
var elements = grid.headerNode.getElementsByTagName('th');
|
for (var i = 0, element; (element = elements[i]); ++i) {
|
if (isFocusableClass.test(element.className)) {
|
grid._focusedHeaderNode = initialNode = element;
|
break;
|
}
|
}
|
}
|
else {
|
grid._focusedHeaderNode = initialNode = grid.headerNode;
|
}
|
|
// Set the tab index only if the header is visible.
|
if (initialNode) {
|
initialNode.tabIndex = grid.tabIndex;
|
}
|
}
|
}
|
|
function afterContentAdded() {
|
// Ensures the first element of a grid is always keyboard selectable after data has been
|
// retrieved if there is not already a valid focused element.
|
|
var focusedNode = grid._focusedNode || initialNode;
|
|
// do not update the focused element if we already have a valid one
|
if (isFocusableClass.test(focusedNode.className) && areaNode.contains(focusedNode)) {
|
return;
|
}
|
|
// ensure that the focused element is actually a grid cell, not a
|
// dgrid-preload or dgrid-content element, which should not be focusable,
|
// even when data is loaded asynchronously
|
var elements = areaNode.getElementsByTagName('*');
|
for (var i = 0, element; (element = elements[i]); ++i) {
|
if (isFocusableClass.test(element.className)) {
|
focusedNode = grid._focusedNode = element;
|
break;
|
}
|
}
|
|
initialNode.tabIndex = -1;
|
focusedNode.tabIndex = grid.tabIndex; // This is initialNode if nothing focusable was found
|
return;
|
}
|
|
if (isHeader) {
|
// Initialize header now (since it's already been rendered),
|
// and aspect after future renderHeader calls to reset focus.
|
initHeader();
|
aspect.after(grid, 'renderHeader', initHeader, true);
|
}
|
else {
|
aspect.after(grid, 'renderArray', afterContentAdded, true);
|
aspect.after(grid, '_onNotification', function (rows, event) {
|
if (event.totalLength === 0) {
|
areaNode.tabIndex = 0;
|
}
|
else if (event.totalLength === 1 && event.type === 'add') {
|
afterContentAdded();
|
}
|
}, true);
|
}
|
|
grid._listeners.push(on(areaNode, 'mousedown', function (event) {
|
if (!handledEvent(event)) {
|
grid._focusOnNode(event.target, isHeader, event);
|
}
|
}));
|
|
grid._listeners.push(on(areaNode, 'keydown', function (event) {
|
// For now, don't squash browser-specific functionalities by letting
|
// ALT and META function as they would natively
|
if (event.metaKey || event.altKey) {
|
return;
|
}
|
|
var handler = grid[isHeader ? 'headerKeyMap' : 'keyMap'][event.keyCode];
|
|
// Text boxes and other inputs that can use direction keys should be ignored
|
// and not affect cell/row navigation
|
if (handler && !handledEvent(event)) {
|
handler.call(grid, event);
|
}
|
}));
|
}
|
|
if (this.tabableHeader) {
|
enableNavigation(this.headerNode);
|
on(this.headerNode, 'dgrid-cellfocusin', function () {
|
grid.scrollTo({ x: this.scrollLeft });
|
});
|
}
|
enableNavigation(this.contentNode);
|
|
this._debouncedEnsureScroll = miscUtil.debounce(this._ensureScroll, this);
|
},
|
|
_pruneRow: function () {
|
// If rows are being pruned for scrolling, then don't try to restore focus.
|
var savedFocusedNode = this._focusedNode;
|
this._focusedNode = null;
|
this.inherited(arguments);
|
this._focusedNode = savedFocusedNode;
|
},
|
|
removeRow: function (rowElement) {
|
if (!this._focusedNode) {
|
// Nothing special to do if we have no record of anything focused
|
return this.inherited(arguments);
|
}
|
|
var self = this,
|
isActive = document.activeElement === this._focusedNode,
|
focusedTarget = this[this.cellNavigation ? 'cell' : 'row'](this._focusedNode),
|
focusedRow = focusedTarget.row || focusedTarget,
|
sibling;
|
rowElement = rowElement.element || rowElement;
|
|
// If removed row previously had focus, temporarily store information
|
// to be handled in an immediately-following insertRow call, or next turn
|
if (rowElement === focusedRow.element) {
|
sibling = this.down(focusedRow, 1, true);
|
|
// Check whether down call returned the same row, or failed to return
|
// any (e.g. during a partial unrendering)
|
if (!sibling || sibling.element === rowElement) {
|
sibling = this.up(focusedRow, 1, true);
|
}
|
|
this._removedFocus = {
|
active: isActive,
|
rowId: focusedRow.id,
|
columnId: focusedTarget.column && focusedTarget.column.id,
|
siblingId: !sibling || sibling.element === rowElement ? undefined : sibling.id
|
};
|
|
// Call _restoreFocus on next turn, to restore focus to sibling
|
// if no replacement row was immediately inserted.
|
// Pass original row's id in case it was re-inserted in a renderArray
|
// call (and thus was found, but couldn't be focused immediately)
|
setTimeout(function () {
|
if (self._removedFocus) {
|
self._restoreFocus(focusedRow.id);
|
}
|
}, 0);
|
|
// Clear _focusedNode until _restoreFocus is called, to avoid
|
// needlessly re-running this logic
|
this._focusedNode = null;
|
}
|
|
this.inherited(arguments);
|
},
|
|
insertRow: function () {
|
var rowElement = this.inherited(arguments);
|
if (this._removedFocus && !this._removedFocus.wait) {
|
this._restoreFocus(rowElement);
|
}
|
return rowElement;
|
},
|
|
_restoreFocus: function (row) {
|
// summary:
|
// Restores focus to the newly inserted row if it matches the
|
// previously removed row, or to the nearest sibling otherwise.
|
var focusInfo = this._removedFocus,
|
newTarget,
|
cell;
|
|
row = row && this.row(row);
|
newTarget = row && row.element && row.id === focusInfo.rowId ? row :
|
typeof focusInfo.siblingId !== 'undefined' && this.row(focusInfo.siblingId);
|
|
if (newTarget && newTarget.element) {
|
if (!newTarget.element.parentNode.parentNode) {
|
// This was called from renderArray, so the row hasn't
|
// actually been placed in the DOM yet; handle it on the next
|
// turn (called from removeRow).
|
focusInfo.wait = true;
|
return;
|
}
|
// Should focus be on a cell?
|
if (typeof focusInfo.columnId !== 'undefined') {
|
cell = this.cell(newTarget, focusInfo.columnId);
|
if (cell && cell.element) {
|
newTarget = cell;
|
}
|
}
|
if (focusInfo.active && newTarget.element.offsetHeight !== 0) {
|
// Row/cell was previously focused and is visible, so focus the new one immediately
|
this._focusOnNode(newTarget, false, null);
|
}
|
else {
|
// Row/cell was not focused or is not visible, but we still need to
|
// update _focusedNode and the element's tabIndex/class
|
domClass.add(newTarget.element, 'dgrid-focus');
|
newTarget.element.tabIndex = this.tabIndex;
|
this._focusedNode = newTarget.element;
|
}
|
}
|
|
delete this._removedFocus;
|
},
|
|
addKeyHandler: function (key, callback, isHeader) {
|
// summary:
|
// Adds a handler to the keyMap on the instance.
|
// Supports binding additional handlers to already-mapped keys.
|
// key: Number
|
// Key code representing the key to be handled.
|
// callback: Function
|
// Callback to be executed (in instance context) when the key is pressed.
|
// isHeader: Boolean
|
// Whether the handler is to be added for the grid body (false, default)
|
// or the header (true).
|
|
// Aspects may be about 10% slower than using an array-based appraoch,
|
// but there is significantly less code involved (here and above).
|
return aspect.after( // Handle
|
this[isHeader ? 'headerKeyMap' : 'keyMap'], key, callback, true);
|
},
|
|
_ensureRowScroll: function (rowElement) {
|
// summary:
|
// Ensures that the entire row is visible within the viewport.
|
// Called for cell navigation in complex structures.
|
|
var scrollY = this.getScrollPosition().y;
|
if (scrollY > rowElement.offsetTop) {
|
// Row starts above the viewport
|
this.scrollTo({ y: rowElement.offsetTop });
|
}
|
else if (scrollY + this.contentNode.offsetHeight < rowElement.offsetTop + rowElement.offsetHeight) {
|
// Row ends below the viewport
|
this.scrollTo({ y: rowElement.offsetTop - this.contentNode.offsetHeight + rowElement.offsetHeight });
|
}
|
},
|
|
_ensureColumnScroll: function (cellElement) {
|
// summary:
|
// Ensures that the entire cell is visible in the viewport.
|
// Called in cases where the grid can scroll horizontally.
|
|
var scrollX = this.getScrollPosition().x;
|
var cellLeft = cellElement.offsetLeft;
|
if (scrollX > cellLeft) {
|
this.scrollTo({ x: cellLeft });
|
}
|
else {
|
var bodyWidth = this.bodyNode.clientWidth;
|
var cellWidth = cellElement.offsetWidth;
|
var cellRight = cellLeft + cellWidth;
|
if (scrollX + bodyWidth < cellRight) {
|
// Adjust so that the right side of the cell and grid body align,
|
// unless the cell is actually wider than the body - then align the left sides
|
this.scrollTo({ x: bodyWidth > cellWidth ? cellRight - bodyWidth : cellLeft });
|
}
|
}
|
},
|
|
_ensureScroll: function (rowOrCell, isHeader) {
|
// summary:
|
// Corrects scroll based on the position of the newly-focused row/cell
|
// as necessary based on grid configuration and dimensions.
|
var isRow = !rowOrCell.column && !rowOrCell.row && rowOrCell.data && rowOrCell.element;
|
|
if (isRow) {
|
this._ensureRowScroll(rowOrCell.element);
|
}
|
else {
|
if (this.cellNavigation && (this.columnSets || this.subRows.length > 1) && !isHeader) {
|
this._ensureRowScroll(rowOrCell.row.element);
|
}
|
if (this.bodyNode.clientWidth < this.contentNode.offsetWidth) {
|
this._ensureColumnScroll(rowOrCell.element);
|
}
|
}
|
},
|
|
_focusOnNode: function (element, isHeader, event) {
|
var focusedNodeProperty = '_focused' + (isHeader ? 'Header' : '') + 'Node',
|
focusedNode = this[focusedNodeProperty],
|
cellOrRowType = this.cellNavigation ? 'cell' : 'row',
|
cell = this[cellOrRowType](element),
|
inputs,
|
input,
|
numInputs,
|
inputFocused,
|
i;
|
|
element = cell && cell.element;
|
if (!element) {
|
return;
|
}
|
|
if (this.cellNavigation) {
|
inputs = element.getElementsByTagName('input');
|
for (i = 0, numInputs = inputs.length; i < numInputs; i++) {
|
input = inputs[i];
|
if ((input.tabIndex !== -1 || '_dgridLastValue' in input) && !input.disabled) {
|
input.focus();
|
inputFocused = true;
|
break;
|
}
|
}
|
}
|
|
// Set up event information for dgrid-cellfocusout/in events.
|
// Note that these events are not fired for _restoreFocus.
|
if (event !== null) {
|
event = lang.mixin({ grid: this }, event);
|
if (event.type) {
|
event.parentType = event.type;
|
}
|
if (!event.bubbles) {
|
// IE doesn't always have a bubbles property already true.
|
// Opera throws if you try to set it to true if it is already true.
|
event.bubbles = true;
|
}
|
}
|
|
if (focusedNode) {
|
// Clean up previously-focused element
|
// Remove the class name and the tabIndex attribute
|
domClass.remove(focusedNode, 'dgrid-focus');
|
focusedNode.removeAttribute('tabindex');
|
|
// Expose object representing focused cell or row losing focus, via
|
// event.cell or event.row; which is set depends on cellNavigation.
|
if (event) {
|
event[cellOrRowType] = this[cellOrRowType](focusedNode);
|
on.emit(focusedNode, 'dgrid-cellfocusout', event);
|
}
|
}
|
focusedNode = this[focusedNodeProperty] = element;
|
|
if (event) {
|
// Expose object representing focused cell or row gaining focus, via
|
// event.cell or event.row; which is set depends on cellNavigation.
|
// Note that yes, the same event object is being reused; on.emit
|
// performs a shallow copy of properties into a new event object.
|
event[cellOrRowType] = cell;
|
}
|
|
var isFocusableClass = this.cellNavigation ? hasGridCellClass : hasGridRowClass;
|
if (!inputFocused && isFocusableClass.test(element.className)) {
|
element.tabIndex = this.tabIndex;
|
element.focus();
|
}
|
domClass.add(element, 'dgrid-focus');
|
|
if (event) {
|
on.emit(focusedNode, 'dgrid-cellfocusin', event);
|
}
|
|
this._debouncedEnsureScroll(cell, isHeader);
|
},
|
|
focusHeader: function (element) {
|
this._focusOnNode(element || this._focusedHeaderNode, true);
|
},
|
|
focus: function (element) {
|
var node = element || this._focusedNode;
|
if (node) {
|
this._focusOnNode(node, false);
|
}
|
else {
|
if (this._removedFocus) {
|
this._removedFocus.active = true;
|
}
|
this.contentNode.focus();
|
}
|
}
|
});
|
|
// Common functions used in default keyMap (called in instance context)
|
|
var moveFocusVertical = Keyboard.moveFocusVertical = function (event, steps) {
|
var cellNavigation = this.cellNavigation,
|
target = this[cellNavigation ? 'cell' : 'row'](event),
|
columnId = cellNavigation && target.column.id,
|
next = this.down(this._focusedNode, steps, true);
|
|
// Navigate within same column if cell navigation is enabled
|
if (cellNavigation) {
|
next = this.cell(next, columnId);
|
}
|
this._focusOnNode(next, false, event);
|
|
event.preventDefault();
|
};
|
|
var moveFocusUp = Keyboard.moveFocusUp = function (event) {
|
moveFocusVertical.call(this, event, -1);
|
};
|
|
var moveFocusDown = Keyboard.moveFocusDown = function (event) {
|
moveFocusVertical.call(this, event, 1);
|
};
|
|
var moveFocusPageUp = Keyboard.moveFocusPageUp = function (event) {
|
moveFocusVertical.call(this, event, -this.pageSkip);
|
};
|
|
var moveFocusPageDown = Keyboard.moveFocusPageDown = function (event) {
|
moveFocusVertical.call(this, event, this.pageSkip);
|
};
|
|
var moveFocusHorizontal = Keyboard.moveFocusHorizontal = function (event, steps) {
|
if (!this.cellNavigation) {
|
return;
|
}
|
var isHeader = !this.row(event), // header reports row as undefined
|
currentNode = this['_focused' + (isHeader ? 'Header' : '') + 'Node'];
|
|
this._focusOnNode(this.right(currentNode, steps), isHeader, event);
|
event.preventDefault();
|
};
|
|
var moveFocusLeft = Keyboard.moveFocusLeft = function (event) {
|
moveFocusHorizontal.call(this, event, -1);
|
};
|
|
var moveFocusRight = Keyboard.moveFocusRight = function (event) {
|
moveFocusHorizontal.call(this, event, 1);
|
};
|
|
var moveHeaderFocusEnd = Keyboard.moveHeaderFocusEnd = function (event, scrollToBeginning) {
|
// Header case is always simple, since all rows/cells are present
|
var nodes;
|
if (this.cellNavigation) {
|
nodes = this.headerNode.getElementsByTagName('th');
|
this._focusOnNode(nodes[scrollToBeginning ? 0 : nodes.length - 1], true, event);
|
}
|
// In row-navigation mode, there's nothing to do - only one row in header
|
|
// Prevent browser from scrolling entire page
|
event.preventDefault();
|
};
|
|
var moveHeaderFocusHome = Keyboard.moveHeaderFocusHome = function (event) {
|
moveHeaderFocusEnd.call(this, event, true);
|
};
|
|
var moveFocusEnd = Keyboard.moveFocusEnd = function (event, scrollToTop) {
|
// summary:
|
// Handles requests to scroll to the beginning or end of the grid.
|
|
var cellNavigation = this.cellNavigation,
|
contentNode = this.contentNode,
|
contentPos = scrollToTop ? 0 : contentNode.scrollHeight,
|
scrollPos = contentNode.scrollTop + contentPos,
|
endChild = contentNode[scrollToTop ? 'firstChild' : 'lastChild'],
|
hasPreload = endChild.className.indexOf('dgrid-preload') > -1,
|
endTarget = hasPreload ? endChild[(scrollToTop ? 'next' : 'previous') + 'Sibling'] : endChild,
|
handle;
|
|
// Scroll explicitly rather than relying on native browser scrolling
|
// (which might use smooth scrolling, which could incur extra renders for OnDemandList)
|
event.preventDefault();
|
this.scrollTo({
|
y: scrollPos
|
});
|
|
if (hasPreload) {
|
// Find the nearest dgrid-row to the relevant end of the grid
|
while (endTarget && endTarget.className.indexOf('dgrid-row') < 0) {
|
endTarget = endTarget[(scrollToTop ? 'next' : 'previous') + 'Sibling'];
|
}
|
// If none is found, there are no rows, and nothing to navigate
|
if (!endTarget) {
|
return;
|
}
|
}
|
|
// Grid content may be lazy-loaded, so check if content needs to be
|
// loaded first
|
if (!hasPreload || endChild.offsetHeight < 1) {
|
// End row is loaded; focus the first/last row/cell now
|
if (cellNavigation) {
|
// Preserve column that was currently focused
|
endTarget = this.cell(endTarget, this.cell(event).column.id);
|
}
|
this._focusOnNode(endTarget, false, event);
|
}
|
else {
|
// In IE < 9, the event member references will become invalid by the time
|
// _focusOnNode is called, so make a (shallow) copy up-front
|
if (!has('dom-addeventlistener')) {
|
event = lang.mixin({}, event);
|
}
|
|
// If the topmost/bottommost row rendered doesn't reach the top/bottom of
|
// the contentNode, we are using OnDemandList and need to wait for more
|
// data to render, then focus the first/last row in the new content.
|
handle = aspect.after(this, 'renderArray', function (rows) {
|
var target = rows[scrollToTop ? 0 : rows.length - 1];
|
if (cellNavigation) {
|
// Preserve column that was currently focused
|
target = this.cell(target, this.cell(event).column.id);
|
}
|
this._focusOnNode(target, false, event);
|
handle.remove();
|
return rows;
|
});
|
}
|
};
|
|
var moveFocusHome = Keyboard.moveFocusHome = function (event) {
|
moveFocusEnd.call(this, event, true);
|
};
|
|
function preventDefault(event) {
|
event.preventDefault();
|
}
|
|
Keyboard.defaultKeyMap = {
|
32: preventDefault, // space
|
33: moveFocusPageUp, // page up
|
34: moveFocusPageDown, // page down
|
35: moveFocusEnd, // end
|
36: moveFocusHome, // home
|
37: moveFocusLeft, // left
|
38: moveFocusUp, // up
|
39: moveFocusRight, // right
|
40: moveFocusDown // down
|
};
|
|
// Header needs fewer default bindings (no vertical), so bind it separately
|
Keyboard.defaultHeaderKeyMap = {
|
32: preventDefault, // space
|
35: moveHeaderFocusEnd, // end
|
36: moveHeaderFocusHome, // home
|
37: moveFocusLeft, // left
|
39: moveFocusRight // right
|
};
|
|
return Keyboard;
|
});
|