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; });