Yahoo! UI Library

node-focusmanager  3.0.0

Yahoo! UI Library > node-focusmanager > node-focusmanager.js (source view)
Search:
 
Filters
/**
* <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>
*  
* @module node-focusmanager
*/

	//	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
	*/
	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();
			

			if (nDescendants > 1) {

				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;

					}

					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.

				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]) {
			event.preventDefault();
		}
		
	},


	/**
	* @method _preventScroll
	* @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() > 1) {

			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.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.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) {

		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} Optional. Number representing the index of the 
	* descendant to be set as the active descendant.
	* @param index {Node} Optional. 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();
		
	}
	
});


NodeFocusManager.NAME = "nodeFocusManager";
NodeFocusManager.NS = "focusManager";

Y.namespace("Plugin");
Y.Plugin.NodeFocusManager = NodeFocusManager;

Copyright © 2009 Yahoo! Inc. All rights reserved.