/** * <p>The Focus Manager Node Plugin makes it easy to manage focus among * a Node's descendants. Primarily intended to help with widget development, * the Focus Manager Node Plugin can be used to improve the keyboard * accessibility of widgets.</p> * * <p> * When designing widgets that manage a set of descendant controls (i.e. buttons * in a toolbar, tabs in a tablist, menuitems in a menu, etc.) it is important to * limit the number of descendants in the browser's default tab flow. The fewer * number of descendants in the default tab flow, the easier it is for keyboard * users to navigate between widgets by pressing the tab key. When a widget has * focus it should provide a set of shortcut keys (typically the arrow keys) * to move focus among its descendants. * </p> * * <p> * To this end, the Focus Manager Node Plugin makes it easy to define a Node's * focusable descendants, define which descendant should be in the default tab * flow, and define the keys that move focus among each descendant. * Additionally, as the CSS * <a href="http://www.w3.org/TR/CSS21/selector.html#x38"><code>:focus</code></a> * pseudo class is not supported on all elements in all * <a href="http://developer.yahoo.com/yui/articles/gbs/">A-Grade browsers</a>, * the Focus Manager Node Plugin provides an easy, cross-browser means of * styling focus. * </p> * DEPRECATED: The FocusManager Node Plugin has been deprecated as of YUI 3.9.0. This module will be removed from the library in a future version. If you require functionality similar to the one provided by this module, consider taking a look at the various modules in the YUI Gallery <http://yuilibrary.com/gallery/>. * @module node-focusmanager * @deprecated 3.9.0 */ // Frequently used strings var ACTIVE_DESCENDANT = "activeDescendant", ID = "id", DISABLED = "disabled", TAB_INDEX = "tabIndex", FOCUSED = "focused", FOCUS_CLASS = "focusClass", CIRCULAR = "circular", UI = "UI", KEY = "key", ACTIVE_DESCENDANT_CHANGE = ACTIVE_DESCENDANT + "Change", HOST = "host", // Collection of keys that, when pressed, cause the browser viewport // to scroll. scrollKeys = { 37: true, 38: true, 39: true, 40: true }, clickableElements = { "a": true, "button": true, "input": true, "object": true }, // Library shortcuts Lang = Y.Lang, UA = Y.UA, /** * The NodeFocusManager class is a plugin for a Node instance. The class is used * via the <a href="Node.html#method_plug"><code>plug</code></a> method of Node * and should not be instantiated directly. * @namespace plugin * @class NodeFocusManager */ NodeFocusManager = function () { NodeFocusManager.superclass.constructor.apply(this, arguments); }; NodeFocusManager.ATTRS = { /** * Boolean indicating that one of the descendants is focused. * * @attribute focused * @readOnly * @default false * @type boolean */ focused: { value: false, readOnly: true }, /** * String representing the CSS selector used to define the descendant Nodes * whose focus should be managed. * * @attribute descendants * @type Y.NodeList */ descendants: { getter: function (value) { return this.get(HOST).all(value); } }, /** * <p>Node, or index of the Node, representing the descendant that is either * focused or is focusable (<code>tabIndex</code> attribute is set to 0). * The value cannot represent a disabled descendant Node. Use a value of -1 * to remove all descendant Nodes from the default tab flow. * If no value is specified, the active descendant will be inferred using * the following criteria:</p> * <ol> * <li>Examining the <code>tabIndex</code> attribute of each descendant and * using the first descendant whose <code>tabIndex</code> attribute is set * to 0</li> * <li>If no default can be inferred then the value is set to either 0 or * the index of the first enabled descendant.</li> * </ol> * * @attribute activeDescendant * @type Number */ activeDescendant: { setter: function (value) { var isNumber = Lang.isNumber, INVALID_VALUE = Y.Attribute.INVALID_VALUE, descendantsMap = this._descendantsMap, descendants = this._descendants, nodeIndex, returnValue, oNode; if (isNumber(value)) { nodeIndex = value; returnValue = nodeIndex; } else if ((value instanceof Y.Node) && descendantsMap) { nodeIndex = descendantsMap[value.get(ID)]; if (isNumber(nodeIndex)) { returnValue = nodeIndex; } else { // The user passed a reference to a Node that wasn't one // of the descendants. returnValue = INVALID_VALUE; } } else { returnValue = INVALID_VALUE; } if (descendants) { oNode = descendants.item(nodeIndex); if (oNode && oNode.get("disabled")) { // Setting the "activeDescendant" attribute to the index // of a disabled descendant is invalid. returnValue = INVALID_VALUE; } } return returnValue; } }, /** * Object literal representing the keys to be used to navigate between the * next/previous descendant. The format for the attribute's value is * <code>{ next: "down:40", previous: "down:38" }</code>. The value for the * "next" and "previous" properties are used to attach * <a href="event/#keylistener"><code>key</code></a> event listeners. See * the <a href="event/#keylistener">Using the key Event</a> section of * the Event documentation for more information on "key" event listeners. * * @attribute keys * @type Object */ keys: { value: { next: null, previous: null } }, /** * String representing the name of class applied to the focused active * descendant Node. Can also be an object literal used to define both the * class name, and the Node to which the class should be applied. If using * an object literal, the format is: * <code>{ className: "focus", fn: myFunction }</code>. The function * referenced by the <code>fn</code> property in the object literal will be * passed a reference to the currently focused active descendant Node. * * @attribute focusClass * @type String|Object */ focusClass: { }, /** * Boolean indicating if focus should be set to the first/last descendant * when the end or beginning of the descendants has been reached. * * @attribute circular * @type Boolean * @default true */ circular: { value: true } }; Y.extend(NodeFocusManager, Y.Plugin.Base, { // Protected properties // Boolean indicating if the NodeFocusManager is active. _stopped: true, // NodeList representing the descendants selected via the // "descendants" attribute. _descendants: null, // Object literal mapping the IDs of each descendant to its index in the // "_descendants" NodeList. _descendantsMap: null, // Reference to the Node instance to which the focused class (defined // by the "focusClass" attribute) is currently applied. _focusedNode: null, // Number representing the index of the last descendant Node. _lastNodeIndex: 0, // Array of handles for event handlers used for a NodeFocusManager instance. _eventHandlers: null, // Protected methods /** * @method _initDescendants * @description Sets the <code>tabIndex</code> attribute of all of the * descendants to -1, except the active descendant, whose * <code>tabIndex</code> attribute is set to 0. * @protected */ _initDescendants: function () { var descendants = this.get("descendants"), descendantsMap = {}, nFirstEnabled = -1, nDescendants, nActiveDescendant = this.get(ACTIVE_DESCENDANT), oNode, sID, i = 0; if (Lang.isUndefined(nActiveDescendant)) { nActiveDescendant = -1; } if (descendants) { nDescendants = descendants.size(); for (i = 0; i < nDescendants; i++) { oNode = descendants.item(i); if (nFirstEnabled === -1 && !oNode.get(DISABLED)) { nFirstEnabled = i; } // If the user didn't specify a value for the // "activeDescendant" attribute try to infer it from // the markup. // Need to pass "2" when using "getAttribute" for IE to get // the attribute value as it is set in the markup. // Need to use "parseInt" because IE always returns the // value as a number, whereas all other browsers return // the attribute as a string when accessed // via "getAttribute". if (nActiveDescendant < 0 && parseInt(oNode.getAttribute(TAB_INDEX, 2), 10) === 0) { nActiveDescendant = i; } if (oNode) { oNode.set(TAB_INDEX, -1); } sID = oNode.get(ID); if (!sID) { sID = Y.guid(); oNode.set(ID, sID); } descendantsMap[sID] = i; } // If the user didn't specify a value for the // "activeDescendant" attribute and no default value could be // determined from the markup, then default to 0. if (nActiveDescendant < 0) { nActiveDescendant = 0; } oNode = descendants.item(nActiveDescendant); // Check to make sure the active descendant isn't disabled, // and fall back to the first enabled descendant if it is. if (!oNode || oNode.get(DISABLED)) { oNode = descendants.item(nFirstEnabled); nActiveDescendant = nFirstEnabled; } this._lastNodeIndex = nDescendants - 1; this._descendants = descendants; this._descendantsMap = descendantsMap; this.set(ACTIVE_DESCENDANT, nActiveDescendant); // Need to set the "tabIndex" attribute here, since the // "activeDescendantChange" event handler used to manage // the setting of the "tabIndex" attribute isn't wired up yet. if (oNode) { oNode.set(TAB_INDEX, 0); } } }, /** * @method _isDescendant * @description Determines if the specified Node instance is a descendant * managed by the Focus Manager. * @param node {Node} Node instance to be checked. * @return {Boolean} Boolean indicating if the specified Node instance is a * descendant managed by the Focus Manager. * @protected */ _isDescendant: function (node) { return (node.get(ID) in this._descendantsMap); }, /** * @method _removeFocusClass * @description Removes the class name representing focus (as specified by * the "focusClass" attribute) from the Node instance to which it is * currently applied. * @protected */ _removeFocusClass: function () { var oFocusedNode = this._focusedNode, focusClass = this.get(FOCUS_CLASS), sClassName; if (focusClass) { sClassName = Lang.isString(focusClass) ? focusClass : focusClass.className; } if (oFocusedNode && sClassName) { oFocusedNode.removeClass(sClassName); } }, /** * @method _detachKeyHandler * @description Detaches the "key" event handlers used to support the "keys" * attribute. * @protected */ _detachKeyHandler: function () { var prevKeyHandler = this._prevKeyHandler, nextKeyHandler = this._nextKeyHandler; if (prevKeyHandler) { prevKeyHandler.detach(); } if (nextKeyHandler) { nextKeyHandler.detach(); } }, /** * @method _preventScroll * @description Prevents the viewport from scolling when the user presses * the up, down, left, or right key. * @protected */ _preventScroll: function (event) { if (scrollKeys[event.keyCode] && this._isDescendant(event.target)) { event.preventDefault(); } }, /** * @method _fireClick * @description Fires the click event if the enter key is pressed while * focused on an HTML element that is not natively clickable. * @protected */ _fireClick: function (event) { var oTarget = event.target, sNodeName = oTarget.get("nodeName").toLowerCase(); if (event.keyCode === 13 && (!clickableElements[sNodeName] || (sNodeName === "a" && !oTarget.getAttribute("href")))) { Y.log(("Firing click event for node:" + oTarget.get("id")), "info", "nodeFocusManager"); oTarget.simulate("click"); } }, /** * @method _attachKeyHandler * @description Attaches the "key" event handlers used to support the "keys" * attribute. * @protected */ _attachKeyHandler: function () { this._detachKeyHandler(); var sNextKey = this.get("keys.next"), sPrevKey = this.get("keys.previous"), oNode = this.get(HOST), aHandlers = this._eventHandlers; if (sPrevKey) { this._prevKeyHandler = Y.on(KEY, Y.bind(this._focusPrevious, this), oNode, sPrevKey); } if (sNextKey) { this._nextKeyHandler = Y.on(KEY, Y.bind(this._focusNext, this), oNode, sNextKey); } // In Opera it is necessary to call the "preventDefault" method in // response to the user pressing the arrow keys in order to prevent // the viewport from scrolling when the user is moving focus among // the focusable descendants. if (UA.opera) { aHandlers.push(oNode.on("keypress", this._preventScroll, this)); } // For all browsers except Opera: HTML elements that are not natively // focusable but made focusable via the tabIndex attribute don't // fire a click event when the user presses the enter key. It is // possible to work around this problem by simplying dispatching a // click event in response to the user pressing the enter key. if (!UA.opera) { aHandlers.push(oNode.on("keypress", this._fireClick, this)); } }, /** * @method _detachEventHandlers * @description Detaches all event handlers used by the Focus Manager. * @protected */ _detachEventHandlers: function () { this._detachKeyHandler(); var aHandlers = this._eventHandlers; if (aHandlers) { Y.Array.each(aHandlers, function (handle) { handle.detach(); }); this._eventHandlers = null; } }, /** * @method _detachEventHandlers * @description Attaches all event handlers used by the Focus Manager. * @protected */ _attachEventHandlers: function () { var descendants = this._descendants, aHandlers, oDocument, handle; if (descendants && descendants.size()) { aHandlers = this._eventHandlers || []; oDocument = this.get(HOST).get("ownerDocument"); if (aHandlers.length === 0) { Y.log("Attaching base set of event handlers.", "info", "nodeFocusManager"); aHandlers.push(oDocument.on("focus", this._onDocFocus, this)); aHandlers.push(oDocument.on("mousedown", this._onDocMouseDown, this)); aHandlers.push( this.after("keysChange", this._attachKeyHandler)); aHandlers.push( this.after("descendantsChange", this._initDescendants)); aHandlers.push( this.after(ACTIVE_DESCENDANT_CHANGE, this._afterActiveDescendantChange)); // For performance: defer attaching all key-related event // handlers until the first time one of the specified // descendants receives focus. handle = this.after("focusedChange", Y.bind(function (event) { if (event.newVal) { Y.log("Attaching key event handlers.", "info", "nodeFocusManager"); this._attachKeyHandler(); // Detach this "focusedChange" handler so that the // key-related handlers only get attached once. handle.detach(); } }, this)); aHandlers.push(handle); } this._eventHandlers = aHandlers; } }, // Protected event handlers /** * @method _onDocMouseDown * @description "mousedown" event handler for the owner document of the * Focus Manager's Node. * @protected * @param event {Object} Object representing the DOM event. */ _onDocMouseDown: function (event) { var oHost = this.get(HOST), oTarget = event.target, bChildNode = oHost.contains(oTarget), node, getFocusable = function (node) { var returnVal = false; if (!node.compareTo(oHost)) { returnVal = this._isDescendant(node) ? node : getFocusable.call(this, node.get("parentNode")); } return returnVal; }; if (bChildNode) { // Check to make sure that the target isn't a child node of one // of the focusable descendants. node = getFocusable.call(this, oTarget); if (node) { oTarget = node; } else if (!node && this.get(FOCUSED)) { // The target was a non-focusable descendant of the root // node, so the "focused" attribute should be set to false. this._set(FOCUSED, false); this._onDocFocus(event); } } if (bChildNode && this._isDescendant(oTarget)) { // Fix general problem in Webkit: mousing down on a button or an // anchor element doesn't focus it. // For all browsers: makes sure that the descendant that // was the target of the mousedown event is now considered the // active descendant. this.focus(oTarget); } else if (UA.webkit && this.get(FOCUSED) && (!bChildNode || (bChildNode && !this._isDescendant(oTarget)))) { // Fix for Webkit: // Document doesn't receive focus in Webkit when the user mouses // down on it, so the "focused" attribute won't get set to the // correct value. // The goal is to force a blur if the user moused down on // either: 1) A descendant node, but not one that managed by // the FocusManager, or 2) an element outside of the // FocusManager this._set(FOCUSED, false); this._onDocFocus(event); } }, /** * @method _onDocFocus * @description "focus" event handler for the owner document of the * Focus Manager's Node. * @protected * @param event {Object} Object representing the DOM event. */ _onDocFocus: function (event) { var oTarget = this._focusTarget || event.target, bFocused = this.get(FOCUSED), focusClass = this.get(FOCUS_CLASS), oFocusedNode = this._focusedNode, bInCollection; if (this._focusTarget) { this._focusTarget = null; } if (this.get(HOST).contains(oTarget)) { // The target is a descendant of the root Node. bInCollection = this._isDescendant(oTarget); if (!bFocused && bInCollection) { // The user has focused a focusable descendant. bFocused = true; } else if (bFocused && !bInCollection) { // The user has focused a child of the root Node that is // not one of the descendants managed by this Focus Manager // so clear the currently focused descendant. bFocused = false; } } else { // The target is some other node in the document. bFocused = false; } if (focusClass) { if (oFocusedNode && (!oFocusedNode.compareTo(oTarget) || !bFocused)) { this._removeFocusClass(); } if (bInCollection && bFocused) { if (focusClass.fn) { oTarget = focusClass.fn(oTarget); oTarget.addClass(focusClass.className); } else { oTarget.addClass(focusClass); } this._focusedNode = oTarget; } } this._set(FOCUSED, bFocused); }, /** * @method _focusNext * @description Keydown event handler that moves focus to the next * enabled descendant. * @protected * @param event {Object} Object representing the DOM event. * @param activeDescendant {Number} Number representing the index of the * next descendant to be focused */ _focusNext: function (event, activeDescendant) { var nActiveDescendant = activeDescendant || this.get(ACTIVE_DESCENDANT), oNode; if (this._isDescendant(event.target) && (nActiveDescendant <= this._lastNodeIndex)) { nActiveDescendant = nActiveDescendant + 1; if (nActiveDescendant === (this._lastNodeIndex + 1) && this.get(CIRCULAR)) { nActiveDescendant = 0; } oNode = this._descendants.item(nActiveDescendant); if (oNode) { if (oNode.get("disabled")) { this._focusNext(event, nActiveDescendant); } else { this.focus(nActiveDescendant); } } } this._preventScroll(event); }, /** * @method _focusPrevious * @description Keydown event handler that moves focus to the previous * enabled descendant. * @protected * @param event {Object} Object representing the DOM event. * @param activeDescendant {Number} Number representing the index of the * next descendant to be focused. */ _focusPrevious: function (event, activeDescendant) { var nActiveDescendant = activeDescendant || this.get(ACTIVE_DESCENDANT), oNode; if (this._isDescendant(event.target) && nActiveDescendant >= 0) { nActiveDescendant = nActiveDescendant - 1; if (nActiveDescendant === -1 && this.get(CIRCULAR)) { nActiveDescendant = this._lastNodeIndex; } oNode = this._descendants.item(nActiveDescendant); if (oNode) { if (oNode.get("disabled")) { this._focusPrevious(event, nActiveDescendant); } else { this.focus(nActiveDescendant); } } } this._preventScroll(event); }, /** * @method _afterActiveDescendantChange * @description afterChange event handler for the * "activeDescendant" attribute. * @protected * @param event {Object} Object representing the change event. */ _afterActiveDescendantChange: function (event) { var oNode = this._descendants.item(event.prevVal); if (oNode) { oNode.set(TAB_INDEX, -1); } oNode = this._descendants.item(event.newVal); if (oNode) { oNode.set(TAB_INDEX, 0); } }, // Public methods initializer: function (config) { Y.log("WARNING: node-focusmanager is a deprecated module as of YUI 3.9.0. This module will be removed from a later version of the library.", "warn"); this.start(); }, destructor: function () { this.stop(); this.get(HOST).focusManager = null; }, /** * @method focus * @description Focuses the active descendant and sets the * <code>focused</code> attribute to true. * @param index {Number|Node} Optional. Number representing the index of the * descendant to be set as the active descendant or Node instance * representing the descendant to be set as the active descendant. */ focus: function (index) { if (Lang.isUndefined(index)) { index = this.get(ACTIVE_DESCENDANT); } this.set(ACTIVE_DESCENDANT, index, { src: UI }); var oNode = this._descendants.item(this.get(ACTIVE_DESCENDANT)); if (oNode) { oNode.focus(); // In Opera focusing a <BUTTON> element programmatically // will result in the document-level focus event handler // "_onDocFocus" being called, resulting in the handler // incorrectly setting the "focused" Attribute to false. To fix // this, set a flag ("_focusTarget") that the "_onDocFocus" method // can look for to properly handle this edge case. if (UA.opera && oNode.get("nodeName").toLowerCase() === "button") { this._focusTarget = oNode; } } }, /** * @method blur * @description Blurs the current active descendant and sets the * <code>focused</code> attribute to false. */ blur: function () { var oNode; if (this.get(FOCUSED)) { oNode = this._descendants.item(this.get(ACTIVE_DESCENDANT)); if (oNode) { oNode.blur(); // For Opera and Webkit: Blurring an element in either browser // doesn't result in another element (such as the document) // being focused. Therefore, the "_onDocFocus" method // responsible for managing the application and removal of the // focus indicator class name is never called. this._removeFocusClass(); } this._set(FOCUSED, false, { src: UI }); } }, /** * @method start * @description Enables the Focus Manager. */ start: function () { if (this._stopped) { this._initDescendants(); this._attachEventHandlers(); this._stopped = false; } }, /** * @method stop * @description Disables the Focus Manager by detaching all event handlers. */ stop: function () { if (!this._stopped) { this._detachEventHandlers(); this._descendants = null; this._focusedNode = null; this._lastNodeIndex = 0; this._stopped = true; } }, /** * @method refresh * @description Refreshes the Focus Manager's descendants by re-executing the * CSS selector query specified by the <code>descendants</code> attribute. */ refresh: function () { this._initDescendants(); if (!this._eventHandlers) { this._attachEventHandlers(); } } }); NodeFocusManager.NAME = "nodeFocusManager"; NodeFocusManager.NS = "focusManager"; Y.namespace("Plugin"); Y.Plugin.NodeFocusManager = NodeFocusManager;