3 Copyright 2012 Yahoo! Inc. All rights reserved.
4 Licensed under the BSD License.
5 http://yuilibrary.com/license/
7 YUI.add('node-focusmanager', function(Y) {
10 * <p>The Focus Manager Node Plugin makes it easy to manage focus among
11 * a Node's descendants. Primarily intended to help with widget development,
12 * the Focus Manager Node Plugin can be used to improve the keyboard
13 * accessibility of widgets.</p>
16 * When designing widgets that manage a set of descendant controls (i.e. buttons
17 * in a toolbar, tabs in a tablist, menuitems in a menu, etc.) it is important to
18 * limit the number of descendants in the browser's default tab flow. The fewer
19 * number of descendants in the default tab flow, the easier it is for keyboard
20 * users to navigate between widgets by pressing the tab key. When a widget has
21 * focus it should provide a set of shortcut keys (typically the arrow keys)
22 * to move focus among its descendants.
26 * To this end, the Focus Manager Node Plugin makes it easy to define a Node's
27 * focusable descendants, define which descendant should be in the default tab
28 * flow, and define the keys that move focus among each descendant.
29 * Additionally, as the CSS
30 * <a href="http://www.w3.org/TR/CSS21/selector.html#x38"><code>:focus</code></a>
31 * pseudo class is not supported on all elements in all
32 * <a href="http://developer.yahoo.com/yui/articles/gbs/">A-Grade browsers</a>,
33 * the Focus Manager Node Plugin provides an easy, cross-browser means of
37 * @module node-focusmanager
40 // Frequently used strings
42 var ACTIVE_DESCENDANT = "activeDescendant",
44 DISABLED = "disabled",
45 TAB_INDEX = "tabIndex",
47 FOCUS_CLASS = "focusClass",
48 CIRCULAR = "circular",
51 ACTIVE_DESCENDANT_CHANGE = ACTIVE_DESCENDANT + "Change",
54 // Collection of keys that, when pressed, cause the browser viewport
76 * The NodeFocusManager class is a plugin for a Node instance. The class is used
77 * via the <a href="Node.html#method_plug"><code>plug</code></a> method of Node
78 * and should not be instantiated directly.
80 * @class NodeFocusManager
82 NodeFocusManager = function () {
84 NodeFocusManager.superclass.constructor.apply(this, arguments);
89 NodeFocusManager.ATTRS = {
92 * Boolean indicating that one of the descendants is focused.
108 * String representing the CSS selector used to define the descendant Nodes
109 * whose focus should be managed.
111 * @attribute descendants
116 getter: function (value) {
118 return this.get(HOST).all(value);
126 * <p>Node, or index of the Node, representing the descendant that is either
127 * focused or is focusable (<code>tabIndex</code> attribute is set to 0).
128 * The value cannot represent a disabled descendant Node. Use a value of -1
129 * to remove all descendant Nodes from the default tab flow.
130 * If no value is specified, the active descendant will be inferred using
131 * the following criteria:</p>
133 * <li>Examining the <code>tabIndex</code> attribute of each descendant and
134 * using the first descendant whose <code>tabIndex</code> attribute is set
136 * <li>If no default can be inferred then the value is set to either 0 or
137 * the index of the first enabled descendant.</li>
140 * @attribute activeDescendant
145 setter: function (value) {
147 var isNumber = Lang.isNumber,
148 INVALID_VALUE = Y.Attribute.INVALID_VALUE,
149 descendantsMap = this._descendantsMap,
150 descendants = this._descendants,
156 if (isNumber(value)) {
158 returnValue = nodeIndex;
160 else if ((value instanceof Y.Node) && descendantsMap) {
162 nodeIndex = descendantsMap[value.get(ID)];
164 if (isNumber(nodeIndex)) {
165 returnValue = nodeIndex;
169 // The user passed a reference to a Node that wasn't one
170 // of the descendants.
171 returnValue = INVALID_VALUE;
177 returnValue = INVALID_VALUE;
183 oNode = descendants.item(nodeIndex);
185 if (oNode && oNode.get("disabled")) {
187 // Setting the "activeDescendant" attribute to the index
188 // of a disabled descendant is invalid.
189 returnValue = INVALID_VALUE;
203 * Object literal representing the keys to be used to navigate between the
204 * next/previous descendant. The format for the attribute's value is
205 * <code>{ next: "down:40", previous: "down:38" }</code>. The value for the
206 * "next" and "previous" properties are used to attach
207 * <a href="event/#keylistener"><code>key</code></a> event listeners. See
208 * the <a href="event/#keylistener">Using the key Event</a> section of
209 * the Event documentation for more information on "key" event listeners.
228 * String representing the name of class applied to the focused active
229 * descendant Node. Can also be an object literal used to define both the
230 * class name, and the Node to which the class should be applied. If using
231 * an object literal, the format is:
232 * <code>{ className: "focus", fn: myFunction }</code>. The function
233 * referenced by the <code>fn</code> property in the object literal will be
234 * passed a reference to the currently focused active descendant Node.
236 * @attribute focusClass
237 * @type String|Object
243 * Boolean indicating if focus should be set to the first/last descendant
244 * when the end or beginning of the descendants has been reached.
246 * @attribute circular
256 Y.extend(NodeFocusManager, Y.Plugin.Base, {
258 // Protected properties
260 // Boolean indicating if the NodeFocusManager is active.
263 // NodeList representing the descendants selected via the
264 // "descendants" attribute.
267 // Object literal mapping the IDs of each descendant to its index in the
268 // "_descendants" NodeList.
269 _descendantsMap: null,
271 // Reference to the Node instance to which the focused class (defined
272 // by the "focusClass" attribute) is currently applied.
275 // Number representing the index of the last descendant Node.
278 // Array of handles for event handlers used for a NodeFocusManager instance.
279 _eventHandlers: null,
286 * @method _initDescendants
287 * @description Sets the <code>tabIndex</code> attribute of all of the
288 * descendants to -1, except the active descendant, whose
289 * <code>tabIndex</code> attribute is set to 0.
292 _initDescendants: function () {
294 var descendants = this.get("descendants"),
298 nActiveDescendant = this.get(ACTIVE_DESCENDANT),
305 if (Lang.isUndefined(nActiveDescendant)) {
306 nActiveDescendant = -1;
312 nDescendants = descendants.size();
315 for (i = 0; i < nDescendants; i++) {
317 oNode = descendants.item(i);
319 if (nFirstEnabled === -1 && !oNode.get(DISABLED)) {
324 // If the user didn't specify a value for the
325 // "activeDescendant" attribute try to infer it from
328 // Need to pass "2" when using "getAttribute" for IE to get
329 // the attribute value as it is set in the markup.
330 // Need to use "parseInt" because IE always returns the
331 // value as a number, whereas all other browsers return
332 // the attribute as a string when accessed
333 // via "getAttribute".
335 if (nActiveDescendant < 0 &&
336 parseInt(oNode.getAttribute(TAB_INDEX, 2), 10) === 0) {
338 nActiveDescendant = i;
343 oNode.set(TAB_INDEX, -1);
353 descendantsMap[sID] = i;
358 // If the user didn't specify a value for the
359 // "activeDescendant" attribute and no default value could be
360 // determined from the markup, then default to 0.
362 if (nActiveDescendant < 0) {
363 nActiveDescendant = 0;
367 oNode = descendants.item(nActiveDescendant);
369 // Check to make sure the active descendant isn't disabled,
370 // and fall back to the first enabled descendant if it is.
372 if (!oNode || oNode.get(DISABLED)) {
373 oNode = descendants.item(nFirstEnabled);
374 nActiveDescendant = nFirstEnabled;
377 this._lastNodeIndex = nDescendants - 1;
378 this._descendants = descendants;
379 this._descendantsMap = descendantsMap;
381 this.set(ACTIVE_DESCENDANT, nActiveDescendant);
383 // Need to set the "tabIndex" attribute here, since the
384 // "activeDescendantChange" event handler used to manage
385 // the setting of the "tabIndex" attribute isn't wired up yet.
388 oNode.set(TAB_INDEX, 0);
397 * @method _isDescendant
398 * @description Determines if the specified Node instance is a descendant
399 * managed by the Focus Manager.
400 * @param node {Node} Node instance to be checked.
401 * @return {Boolean} Boolean indicating if the specified Node instance is a
402 * descendant managed by the Focus Manager.
405 _isDescendant: function (node) {
407 return (node.get(ID) in this._descendantsMap);
413 * @method _removeFocusClass
414 * @description Removes the class name representing focus (as specified by
415 * the "focusClass" attribute) from the Node instance to which it is
419 _removeFocusClass: function () {
421 var oFocusedNode = this._focusedNode,
422 focusClass = this.get(FOCUS_CLASS),
426 sClassName = Lang.isString(focusClass) ?
427 focusClass : focusClass.className;
430 if (oFocusedNode && sClassName) {
431 oFocusedNode.removeClass(sClassName);
438 * @method _detachKeyHandler
439 * @description Detaches the "key" event handlers used to support the "keys"
443 _detachKeyHandler: function () {
445 var prevKeyHandler = this._prevKeyHandler,
446 nextKeyHandler = this._nextKeyHandler;
448 if (prevKeyHandler) {
449 prevKeyHandler.detach();
452 if (nextKeyHandler) {
453 nextKeyHandler.detach();
460 * @method _preventScroll
461 * @description Prevents the viewport from scolling when the user presses
462 * the up, down, left, or right key.
465 _preventScroll: function (event) {
467 if (scrollKeys[event.keyCode] && this._isDescendant(event.target)) {
468 event.preventDefault();
476 * @description Fires the click event if the enter key is pressed while
477 * focused on an HTML element that is not natively clickable.
480 _fireClick: function (event) {
482 var oTarget = event.target,
483 sNodeName = oTarget.get("nodeName").toLowerCase();
485 if (event.keyCode === 13 && (!clickableElements[sNodeName] ||
486 (sNodeName === "a" && !oTarget.getAttribute("href")))) {
488 Y.log(("Firing click event for node:" + oTarget.get("id")), "info", "nodeFocusManager");
490 oTarget.simulate("click");
498 * @method _attachKeyHandler
499 * @description Attaches the "key" event handlers used to support the "keys"
503 _attachKeyHandler: function () {
505 this._detachKeyHandler();
507 var sNextKey = this.get("keys.next"),
508 sPrevKey = this.get("keys.previous"),
509 oNode = this.get(HOST),
510 aHandlers = this._eventHandlers;
513 this._prevKeyHandler =
514 Y.on(KEY, Y.bind(this._focusPrevious, this), oNode, sPrevKey);
518 this._nextKeyHandler =
519 Y.on(KEY, Y.bind(this._focusNext, this), oNode, sNextKey);
523 // In Opera it is necessary to call the "preventDefault" method in
524 // response to the user pressing the arrow keys in order to prevent
525 // the viewport from scrolling when the user is moving focus among
526 // the focusable descendants.
529 aHandlers.push(oNode.on("keypress", this._preventScroll, this));
533 // For all browsers except Opera: HTML elements that are not natively
534 // focusable but made focusable via the tabIndex attribute don't
535 // fire a click event when the user presses the enter key. It is
536 // possible to work around this problem by simplying dispatching a
537 // click event in response to the user pressing the enter key.
540 aHandlers.push(oNode.on("keypress", this._fireClick, this));
547 * @method _detachEventHandlers
548 * @description Detaches all event handlers used by the Focus Manager.
551 _detachEventHandlers: function () {
553 this._detachKeyHandler();
555 var aHandlers = this._eventHandlers;
559 Y.Array.each(aHandlers, function (handle) {
563 this._eventHandlers = null;
571 * @method _detachEventHandlers
572 * @description Attaches all event handlers used by the Focus Manager.
575 _attachEventHandlers: function () {
577 var descendants = this._descendants,
582 if (descendants && descendants.size()) {
584 aHandlers = this._eventHandlers || [];
585 oDocument = this.get(HOST).get("ownerDocument");
588 if (aHandlers.length === 0) {
590 Y.log("Attaching base set of event handlers.", "info", "nodeFocusManager");
592 aHandlers.push(oDocument.on("focus", this._onDocFocus, this));
594 aHandlers.push(oDocument.on("mousedown",
595 this._onDocMouseDown, this));
598 this.after("keysChange", this._attachKeyHandler));
601 this.after("descendantsChange", this._initDescendants));
604 this.after(ACTIVE_DESCENDANT_CHANGE,
605 this._afterActiveDescendantChange));
608 // For performance: defer attaching all key-related event
609 // handlers until the first time one of the specified
610 // descendants receives focus.
612 handle = this.after("focusedChange", Y.bind(function (event) {
616 Y.log("Attaching key event handlers.", "info", "nodeFocusManager");
618 this._attachKeyHandler();
620 // Detach this "focusedChange" handler so that the
621 // key-related handlers only get attached once.
629 aHandlers.push(handle);
634 this._eventHandlers = aHandlers;
641 // Protected event handlers
644 * @method _onDocMouseDown
645 * @description "mousedown" event handler for the owner document of the
646 * Focus Manager's Node.
648 * @param event {Object} Object representing the DOM event.
650 _onDocMouseDown: function (event) {
652 var oHost = this.get(HOST),
653 oTarget = event.target,
654 bChildNode = oHost.contains(oTarget),
657 getFocusable = function (node) {
659 var returnVal = false;
661 if (!node.compareTo(oHost)) {
663 returnVal = this._isDescendant(node) ? node :
664 getFocusable.call(this, node.get("parentNode"));
675 // Check to make sure that the target isn't a child node of one
676 // of the focusable descendants.
678 node = getFocusable.call(this, oTarget);
683 else if (!node && this.get(FOCUSED)) {
685 // The target was a non-focusable descendant of the root
686 // node, so the "focused" attribute should be set to false.
688 this._set(FOCUSED, false);
689 this._onDocFocus(event);
696 if (bChildNode && this._isDescendant(oTarget)) {
698 // Fix general problem in Webkit: mousing down on a button or an
699 // anchor element doesn't focus it.
701 // For all browsers: makes sure that the descendant that
702 // was the target of the mousedown event is now considered the
703 // active descendant.
707 else if (UA.webkit && this.get(FOCUSED) &&
708 (!bChildNode || (bChildNode && !this._isDescendant(oTarget)))) {
712 // Document doesn't receive focus in Webkit when the user mouses
713 // down on it, so the "focused" attribute won't get set to the
716 // The goal is to force a blur if the user moused down on
717 // either: 1) A descendant node, but not one that managed by
718 // the FocusManager, or 2) an element outside of the
721 this._set(FOCUSED, false);
722 this._onDocFocus(event);
730 * @method _onDocFocus
731 * @description "focus" event handler for the owner document of the
732 * Focus Manager's Node.
734 * @param event {Object} Object representing the DOM event.
736 _onDocFocus: function (event) {
738 var oTarget = this._focusTarget || event.target,
739 bFocused = this.get(FOCUSED),
740 focusClass = this.get(FOCUS_CLASS),
741 oFocusedNode = this._focusedNode,
744 if (this._focusTarget) {
745 this._focusTarget = null;
749 if (this.get(HOST).contains(oTarget)) {
751 // The target is a descendant of the root Node.
753 bInCollection = this._isDescendant(oTarget);
755 if (!bFocused && bInCollection) {
757 // The user has focused a focusable descendant.
762 else if (bFocused && !bInCollection) {
764 // The user has focused a child of the root Node that is
765 // not one of the descendants managed by this Focus Manager
766 // so clear the currently focused descendant.
775 // The target is some other node in the document.
784 if (oFocusedNode && (!oFocusedNode.compareTo(oTarget) || !bFocused)) {
785 this._removeFocusClass();
788 if (bInCollection && bFocused) {
791 oTarget = focusClass.fn(oTarget);
792 oTarget.addClass(focusClass.className);
795 oTarget.addClass(focusClass);
798 this._focusedNode = oTarget;
805 this._set(FOCUSED, bFocused);
812 * @description Keydown event handler that moves focus to the next
813 * enabled descendant.
815 * @param event {Object} Object representing the DOM event.
816 * @param activeDescendant {Number} Number representing the index of the
817 * next descendant to be focused
819 _focusNext: function (event, activeDescendant) {
821 var nActiveDescendant = activeDescendant || this.get(ACTIVE_DESCENDANT),
825 if (this._isDescendant(event.target) &&
826 (nActiveDescendant <= this._lastNodeIndex)) {
828 nActiveDescendant = nActiveDescendant + 1;
830 if (nActiveDescendant === (this._lastNodeIndex + 1) &&
831 this.get(CIRCULAR)) {
833 nActiveDescendant = 0;
837 oNode = this._descendants.item(nActiveDescendant);
841 if (oNode.get("disabled")) {
842 this._focusNext(event, nActiveDescendant);
845 this.focus(nActiveDescendant);
852 this._preventScroll(event);
858 * @method _focusPrevious
859 * @description Keydown event handler that moves focus to the previous
860 * enabled descendant.
862 * @param event {Object} Object representing the DOM event.
863 * @param activeDescendant {Number} Number representing the index of the
864 * next descendant to be focused.
866 _focusPrevious: function (event, activeDescendant) {
868 var nActiveDescendant = activeDescendant || this.get(ACTIVE_DESCENDANT),
871 if (this._isDescendant(event.target) && nActiveDescendant >= 0) {
873 nActiveDescendant = nActiveDescendant - 1;
875 if (nActiveDescendant === -1 && this.get(CIRCULAR)) {
876 nActiveDescendant = this._lastNodeIndex;
879 oNode = this._descendants.item(nActiveDescendant);
883 if (oNode.get("disabled")) {
884 this._focusPrevious(event, nActiveDescendant);
887 this.focus(nActiveDescendant);
894 this._preventScroll(event);
900 * @method _afterActiveDescendantChange
901 * @description afterChange event handler for the
902 * "activeDescendant" attribute.
904 * @param event {Object} Object representing the change event.
906 _afterActiveDescendantChange: function (event) {
908 var oNode = this._descendants.item(event.prevVal);
911 oNode.set(TAB_INDEX, -1);
914 oNode = this._descendants.item(event.newVal);
917 oNode.set(TAB_INDEX, 0);
926 initializer: function (config) {
932 destructor: function () {
935 this.get(HOST).focusManager = null;
942 * @description Focuses the active descendant and sets the
943 * <code>focused</code> attribute to true.
944 * @param index {Number} Optional. Number representing the index of the
945 * descendant to be set as the active descendant.
946 * @param index {Node} Optional. Node instance representing the
947 * descendant to be set as the active descendant.
949 focus: function (index) {
951 if (Lang.isUndefined(index)) {
952 index = this.get(ACTIVE_DESCENDANT);
955 this.set(ACTIVE_DESCENDANT, index, { src: UI });
957 var oNode = this._descendants.item(this.get(ACTIVE_DESCENDANT));
963 // In Opera focusing a <BUTTON> element programmatically
964 // will result in the document-level focus event handler
965 // "_onDocFocus" being called, resulting in the handler
966 // incorrectly setting the "focused" Attribute to false. To fix
967 // this, set a flag ("_focusTarget") that the "_onDocFocus" method
968 // can look for to properly handle this edge case.
970 if (UA.opera && oNode.get("nodeName").toLowerCase() === "button") {
971 this._focusTarget = oNode;
981 * @description Blurs the current active descendant and sets the
982 * <code>focused</code> attribute to false.
988 if (this.get(FOCUSED)) {
990 oNode = this._descendants.item(this.get(ACTIVE_DESCENDANT));
996 // For Opera and Webkit: Blurring an element in either browser
997 // doesn't result in another element (such as the document)
998 // being focused. Therefore, the "_onDocFocus" method
999 // responsible for managing the application and removal of the
1000 // focus indicator class name is never called.
1002 this._removeFocusClass();
1006 this._set(FOCUSED, false, { src: UI });
1014 * @description Enables the Focus Manager.
1016 start: function () {
1018 if (this._stopped) {
1020 this._initDescendants();
1021 this._attachEventHandlers();
1023 this._stopped = false;
1032 * @description Disables the Focus Manager by detaching all event handlers.
1036 if (!this._stopped) {
1038 this._detachEventHandlers();
1040 this._descendants = null;
1041 this._focusedNode = null;
1042 this._lastNodeIndex = 0;
1043 this._stopped = true;
1052 * @description Refreshes the Focus Manager's descendants by re-executing the
1053 * CSS selector query specified by the <code>descendants</code> attribute.
1055 refresh: function () {
1057 this._initDescendants();
1059 if (!this._eventHandlers) {
1060 this._attachEventHandlers();
1068 NodeFocusManager.NAME = "nodeFocusManager";
1069 NodeFocusManager.NS = "focusManager";
1071 Y.namespace("Plugin");
1072 Y.Plugin.NodeFocusManager = NodeFocusManager;
1075 }, '3.5.0' ,{requires:['attribute', 'node', 'plugin', 'node-event-simulate', 'event-key', 'event-focus']});