/** Provides keyboard navigation of DataTable cells and support for adding other keyboard actions. @module datatable @submodule datatable-keynav */ var arrEach = Y.Array.each, /** A DataTable class extension that provides navigation via keyboard, based on WAI-ARIA recommendation for the [Grid widget](http://www.w3.org/WAI/PF/aria-practices/#grid) and extensible to support other actions. @class DataTable.KeyNav @for DataTable */ DtKeyNav = function (){}; /** Mapping of key codes to friendly key names that can be used in the [keyActions](#property_keyActions) property and [ARIA_ACTIONS](#property_ARIA_ACTIONS) property. It contains aliases for the following keys: <ul> <li>backspace</li> <li>tab</li> <li>enter</li> <li>esc</li> <li>space</li> <li>pgup</li> <li>pgdown</li> <li>end</li> <li>home</li> <li>left</li> <li>up</li> <li>right</li> <li>down</li> <li>f1 .. f12</li> </ul> @property KEY_NAMES @type {Object} @static **/ DtKeyNav.KEY_NAMES = { 8: 'backspace', 9: 'tab', 13: 'enter', 27: 'esc', 32: 'space', 33: 'pgup', 34: 'pgdown', 35: 'end', 36: 'home', 37: 'left', 38: 'up', 39: 'right', 40: 'down', 112:'f1', 113:'f2', 114:'f3', 115:'f4', 116:'f5', 117:'f6', 118:'f7', 119:'f8', 120:'f9', 121:'f10', 122:'f11', 123:'f12' }; /** Mapping of key codes to actions according to the WAI-ARIA suggestion for the [Grid Widget](http://www.w3.org/WAI/PF/aria-practices/#grid). The key for each entry is a key-code or [keyName](#property_KEY_NAMES) while the value can be a function that performs the action or a string. If a string, it can either correspond to the name of a method in this module (or any method in a DataTable instance) or the name of an event to fire. @property ARIA_ACTIONS @type Object @static */ DtKeyNav.ARIA_ACTIONS = { left: '_keyMoveLeft', right: '_keyMoveRight', up: '_keyMoveUp', down: '_keyMoveDown', home: '_keyMoveRowStart', end: '_keyMoveRowEnd', pgup: '_keyMoveColTop', pgdown: '_keyMoveColBottom' }; DtKeyNav.ATTRS = { /** Cell that's currently either focused or focusable when the DataTable gets the focus. @attribute focusedCell @type Node @default first cell in the table. **/ focusedCell: { setter: '_focusedCellSetter' }, /** Determines whether it is possible to navigate into the header area. The examples referenced in the document show both behaviors so it seems it is optional. @attribute keyIntoHeaders @type Boolean @default true */ keyIntoHeaders: { value: true } }; Y.mix( DtKeyNav.prototype, { /** Table of actions to be performed for each key. It is loaded with a clone of [ARIA_ACTIONS](#property_ARIA_ACTIONS) by default. The key for each entry is either a key-code or an alias from the [KEY_NAMES](#property_KEY_NAMES) table. They can be prefixed with any combination of the modifier keys `alt`, `ctrl`, `meta` or `shift` each followed by a hyphen, such as `"ctrl-shift-up"` (modifiers, if more than one, should appear in alphabetical order). The value for each entry should be a function or the name of a method in the DataTable instance. The method will receive the original keyboard EventFacade as its only argument. If the value is a string and it cannot be resolved into a method, it will be assumed to be the name of an event to fire. The listener for that event will receive an EventFacade containing references to the cell that has the focus, the row, column and, unless it is a header row, the record it corresponds to. The second argument will be the original EventFacade for the keyboard event. @property keyActions @type {Object} @default Y.DataTable.keyNav.ARIA_ACTIONS */ keyActions: null, /** Array containing the event handles to any event that might need to be detached on destruction. @property _keyNavSubscr @type Array @default null, @private */ _keyNavSubscr: null, /** Reference to the THead section that holds the headers for the datatable. For a Scrolling DataTable, it is the one visible to the user. @property _keyNavTHead @type Node @default: null @private */ _keyNavTHead: null, /** Indicates if the headers of the table are nested or not. Nested headers makes navigation in the headers much harder. @property _keyNavNestedHeaders @default false @private */ _keyNavNestedHeaders: false, /** CSS class name prefix for columns, used to search for a cell by key. @property _keyNavColPrefix @type String @default null (initialized via getClassname() ) @private */ _keyNavColPrefix:null, /** Regular expression to extract the column key from a cell via its CSS class name. @property _keyNavColRegExp @type RegExp @default null (initialized based on _keyNavColPrefix) @private */ _keyNavColRegExp:null, initializer: function () { this.onceAfter('render', this._afterKeyNavRender); this._keyNavSubscr = [ this.after('focusedCellChange', this._afterKeyNavFocusedCellChange), this.after('focusedChange', this._afterKeyNavFocusedChange) ]; this._keyNavColPrefix = this.getClassName('col', ''); this._keyNavColRegExp = new RegExp(this._keyNavColPrefix + '(.+?)(\\s|$)'); this.keyActions = Y.clone(DtKeyNav.ARIA_ACTIONS); }, destructor: function () { arrEach(this._keyNavSubscr, function (evHandle) { if (evHandle && evHandle.detach) { evHandle.detach(); } }); }, /** Sets the tabIndex on the focused cell and, if the DataTable has the focus, sets the focus on it. @method _afterFocusedCellChange @param e {EventFacade} @private */ _afterKeyNavFocusedCellChange: function (e) { var newVal = e.newVal, prevVal = e.prevVal; if (prevVal) { prevVal.set('tabIndex', -1); } if (newVal) { newVal.set('tabIndex', 0); if (this.get('focused')) { newVal.scrollIntoView(); newVal.focus(); } } else { this.set('focused', null); } }, /** When the DataTable gets the focus, it ensures the correct cell regains the focus. @method _afterKeyNavFocusedChange @param e {EventFacade} @private */ _afterKeyNavFocusedChange: function (e) { var cell = this.get('focusedCell'); if (e.newVal) { if (cell) { cell.scrollIntoView(); cell.focus(); } else { this._keyMoveFirst(); } } else { if (cell) { cell.blur(); } } }, /** Subscribes to the events on the DataTable elements once they have been rendered, finds out the header section and makes the top-left element focusable. @method _afterKeyNavRender @private */ _afterKeyNavRender: function () { var cbx = this.get('contentBox'); this._keyNavSubscr.push( cbx.on('keydown', this._onKeyNavKeyDown, this), cbx.on('click', this._onKeyNavClick, this) ); this._keyNavTHead = (this._yScrollHeader || this._tableNode).one('thead'); this._keyMoveFirst(); // determine if we have nested headers this._keyNavNestedHeaders = (this.get('columns').length !== this.head.theadNode.all('th').size()); }, /** In response to a click event, it sets the focus on the clicked cell @method _onKeyNavClick @param e {EventFacade} @private */ _onKeyNavClick: function (e) { var cell = e.target.ancestor((this.get('keyIntoHeaders') ? 'td, th': 'td'), true); if (cell) { this.focus(); this.set('focusedCell', cell); } }, /** Responds to a key down event by executing the action set in the [keyActions](#property_keyActions) table. @method _onKeyNavKeyDown @param e {EventFacade} @private */ _onKeyNavKeyDown: function (e) { var keyCode = e.keyCode, keyName = DtKeyNav.KEY_NAMES[keyCode] || keyCode, action; arrEach(['alt', 'ctrl', 'meta', 'shift'], function (modifier) { if (e[modifier + 'Key']) { keyCode = modifier + '-' + keyCode; keyName = modifier + '-' + keyName; } }); action = this.keyActions[keyCode] || this.keyActions[keyName]; if (typeof action === 'string') { if (this[action]) { this[action].call(this, e); } else { this._keyNavFireEvent(action, e); } } else { action.call(this, e); } }, /** If the action associated to a key combination is a string and no method by that name was found in this instance, this method will fire an event using that string and provides extra information to the listener. @method _keyNavFireEvent @param action {String} Name of the event to fire @param e {EventFacade} Original facade from the keydown event. @private */ _keyNavFireEvent: function (action, e) { var cell = e.target.ancestor('td, th', true); if (cell) { this.fire(action, { cell: cell, row: cell.ancestor('tr'), record: this.getRecord(cell), column: this.getColumn(cell.get('cellIndex')) }, e); } }, /** Sets the focus on the very first cell in the header of the table. @method _keyMoveFirst @private */ _keyMoveFirst: function () { this.set('focusedCell' , (this.get('keyIntoHeaders') ? this._keyNavTHead.one('th') : this._tbodyNode.one('td')), {src:'keyNav'}); }, /** Sets the focus on the cell to the left of the currently focused one. Does not wrap, following the WAI-ARIA recommendation. @method _keyMoveLeft @param e {EventFacade} Event Facade for the keydown event @private */ _keyMoveLeft: function (e) { var cell = this.get('focusedCell'), index = cell.get('cellIndex'), row = cell.ancestor(); e.preventDefault(); if (index === 0) { return; } cell = row.get('cells').item(index - 1); this.set('focusedCell', cell , {src:'keyNav'}); }, /** Sets the focus on the cell to the right of the currently focused one. Does not wrap, following the WAI-ARIA recommendation. @method _keyMoveRight @param e {EventFacade} Event Facade for the keydown event @private */ _keyMoveRight: function (e) { var cell = this.get('focusedCell'), row = cell.ancestor('tr'), section = row.ancestor(), inHead = section === this._keyNavTHead, nextCell, parent; e.preventDefault(); // a little special with nested headers /* +-------------+-------+ | ABC | DE | +-------+-----+---+---+ | AB | | | | +---+---+ | | | | A | B | C | D | E | +---+---+-----+---+---+ */ nextCell = cell.next(); if (row.get('rowIndex') !== 0 && inHead && this._keyNavNestedHeaders) { if (nextCell) { cell = nextCell; } else { //-- B -> C parent = this._getTHParent(cell); if (parent && parent.next()) { cell = parent.next(); } else { //-- E -> ... return; } } } else { if (!nextCell) { return; } else { cell = nextCell; } } this.set('focusedCell', cell, { src:'keyNav' }); }, /** Sets the focus on the cell above the currently focused one. It will move into the headers when the top of the data rows is reached. Does not wrap, following the WAI-ARIA recommendation. @method _keyMoveUp @param e {EventFacade} Event Facade for the keydown event @private */ _keyMoveUp: function (e) { var cell = this.get('focusedCell'), cellIndex = cell.get('cellIndex'), row = cell.ancestor('tr'), rowIndex = row.get('rowIndex'), section = row.ancestor(), sectionRows = section.get('rows'), inHead = section === this._keyNavTHead, parent; e.preventDefault(); if (!inHead) { rowIndex -= section.get('firstChild').get('rowIndex'); } if (rowIndex === 0) { if (inHead || !this.get('keyIntoHeaders')) { return; } section = this._keyNavTHead; sectionRows = section.get('rows'); if (this._keyNavNestedHeaders) { key = this._getCellColumnName(cell); cell = section.one('.' + this._keyNavColPrefix + key); cellIndex = cell.get('cellIndex'); row = cell.ancestor('tr'); } else { row = section.get('firstChild'); cell = row.get('cells').item(cellIndex); } } else { if (inHead && this._keyNavNestedHeaders) { key = this._getCellColumnName(cell); parent = this._columnMap[key]._parent; if (parent) { cell = section.one('#' + parent.id); } } else { row = sectionRows.item(rowIndex -1); cell = row.get('cells').item(cellIndex); } } this.set('focusedCell', cell); }, /** Sets the focus on the cell below the currently focused one. It will move into the data rows when the bottom of the header rows is reached. Does not wrap, following the WAI-ARIA recommendation. @method _keyMoveDown @param e {EventFacade} Event Facade for the keydown event @private */ _keyMoveDown: function (e) { var cell = this.get('focusedCell'), cellIndex = cell.get('cellIndex'), row = cell.ancestor('tr'), rowIndex = row.get('rowIndex') + 1, section = row.ancestor(), inHead = section === this._keyNavTHead, tbody = (this.body && this.body.tbodyNode), sectionRows = section.get('rows'), key, children; e.preventDefault(); if (inHead) { // focused cell is in the header if (this._keyNavNestedHeaders) { // the header is nested key = this._getCellColumnName(cell); children = this._columnMap[key].children; rowIndex += (cell.getAttribute('rowspan') || 1) - 1; if (children) { // stay in thead cell = section.one('#' + children[0].id); } else { // moving into tbody cell = tbody.one('.' + this._keyNavColPrefix + key); section = tbody; sectionRows = section.get('rows'); } cellIndex = cell.get('cellIndex'); } else { // the header is not nested row = tbody.one('tr'); cell = row.get('cells').item(cellIndex); } } // offset row index to tbody rowIndex -= sectionRows.item(0).get('rowIndex'); if (rowIndex >= sectionRows.size()) { if (!inHead) { // last row in tbody return; } section = tbody; row = section.one('tr'); } else { row = sectionRows.item(rowIndex); } this.set('focusedCell', row.get('cells').item(cellIndex)); }, /** Sets the focus on the left-most cell of the row containing the currently focused cell. @method _keyMoveRowStart @param e {EventFacade} Event Facade for the keydown event @private */ _keyMoveRowStart: function (e) { var row = this.get('focusedCell').ancestor(); this.set('focusedCell', row.get('firstChild'), {src:'keyNav'}); e.preventDefault(); }, /** Sets the focus on the right-most cell of the row containing the currently focused cell. @method _keyMoveRowEnd @param e {EventFacade} Event Facade for the keydown event @private */ _keyMoveRowEnd: function (e) { var row = this.get('focusedCell').ancestor(); this.set('focusedCell', row.get('lastChild'), {src:'keyNav'}); e.preventDefault(); }, /** Sets the focus on the top-most cell of the column containing the currently focused cell. It would normally be a header cell. @method _keyMoveColTop @param e {EventFacade} Event Facade for the keydown event @private */ _keyMoveColTop: function (e) { var cell = this.get('focusedCell'), cellIndex = cell.get('cellIndex'), key, header; e.preventDefault(); if (this._keyNavNestedHeaders && this.get('keyIntoHeaders')) { key = this._getCellColumnName(cell); header = this._columnMap[key]; while (header._parent) { header = header._parent; } cell = this._keyNavTHead.one('#' + header.id); } else { cell = (this.get('keyIntoHeaders') ? this._keyNavTHead: this._tbodyNode).get('firstChild').get('cells').item(cellIndex); } this.set('focusedCell', cell , {src:'keyNav'}); }, /** Sets the focus on the last cell of the column containing the currently focused cell. @method _keyMoveColBottom @param e {EventFacade} Event Facade for the keydown event @private */ _keyMoveColBottom: function (e) { var cell = this.get('focusedCell'), cellIndex = cell.get('cellIndex'); this.set('focusedCell', this._tbodyNode.get('lastChild').get('cells').item(cellIndex), {src:'keyNav'}); e.preventDefault(); }, /** Setter method for the [focusedCell](#attr_focusedCell) attribute. Checks that the passed value is a Node, either a TD or TH and is contained within the DataTable contentBox. @method _focusedCellSetter @param cell {Node} DataTable cell to receive the focus @return cell or Y.Attribute.INVALID_VALUE @private */ _focusedCellSetter: function (cell) { if (cell instanceof Y.Node) { var tag = cell.get('tagName').toUpperCase(); if ((tag === 'TD' || tag === 'TH') && this.get('contentBox').contains(cell) ) { return cell; } } else if (cell === null) { return cell; } return Y.Attribute.INVALID_VALUE; }, /** Retrieves the parent cell of the given TH cell. If there is no parent for the provided cell, null is returned. @protected @method _getTHParent @param {Node} thCell Cell to find parent of @return {Node} Parent of the cell provided or null */ _getTHParent: function (thCell) { var key = this._getCellColumnName(thCell), parent = this._columnMap[key] && this._columnMap[key]._parent; if (parent) { return thCell.ancestor().ancestor().one('.' + this._keyNavColPrefix + parent.key); } return null; }, /** Retrieves the column name based from the data attribute on the cell if available. Other wise, extracts the column name from the classname @protected @method _getCellColumnName @param {Node} cell Cell to get column name from @return String Column name of the provided cell */ _getCellColumnName: function (cell) { return cell.getData('yui3-col-id') || this._keyNavColRegExp.exec(cell.get('className'))[1]; } }); Y.DataTable.KeyNav = DtKeyNav; Y.Base.mix(Y.DataTable, [DtKeyNav]);