/** * @class Ext.FocusManager The FocusManager is responsible for globally: 1. Managing component focus 2. Providing basic keyboard navigation 3. (optional) Provide a visual cue for focused components, in the form of a focus ring/frame. To activate the FocusManager, simply call {@link #enable `Ext.FocusManager.enable();`}. In turn, you may deactivate the FocusManager by subsequently calling {@link #disable `Ext.FocusManager.disable();`}. The FocusManager is disabled by default. To enable the optional focus frame, pass `true` or `{focusFrame: true}` to {@link #enable}. Another feature of the FocusManager is to provide basic keyboard focus navigation scoped to any {@link Ext.container.Container} that would like to have navigation between its child {@link Ext.Component}'s. The {@link Ext.container.Container} can simply call {@link #subscribe Ext.FocusManager.subscribe} to take advantage of this feature, and can at any time call {@link #unsubscribe Ext.FocusManager.unsubscribe} to turn the navigation off. * @singleton * @markdown * @author Jarred Nicholls <jarred@sencha.com> * @docauthor Jarred Nicholls <jarred@sencha.com> */ Ext.define('Ext.FocusManager', { singleton: true, alternateClassName: 'Ext.FocusMgr', mixins: { observable: 'Ext.util.Observable' }, requires: [ 'Ext.ComponentManager', 'Ext.ComponentQuery', 'Ext.util.HashMap', 'Ext.util.KeyNav' ], /** * @property {Boolean} enabled * Whether or not the FocusManager is currently enabled */ enabled: false, /** * @property {Ext.Component} focusedCmp * The currently focused component. Defaults to `undefined`. * @markdown */ focusElementCls: Ext.baseCSSPrefix + 'focus-element', focusFrameCls: Ext.baseCSSPrefix + 'focus-frame', /** * @property {Array} whitelist * A list of xtypes that should ignore certain navigation input keys and * allow for the default browser event/behavior. These input keys include: * * 1. Backspace * 2. Delete * 3. Left * 4. Right * 5. Up * 6. Down * * The FocusManager will not attempt to navigate when a component is an xtype (or descendents thereof) * that belongs to this whitelist. E.g., an {@link Ext.form.field.Text} should allow * the user to move the input cursor left and right, and to delete characters, etc. * * This whitelist currently defaults to `['textfield']`. * @markdown */ whitelist: [ 'textfield' ], tabIndexWhitelist: [ 'a', 'button', 'embed', 'frame', 'iframe', 'img', 'input', 'object', 'select', 'textarea' ], constructor: function() { var me = this, CQ = Ext.ComponentQuery; me.addEvents( /** * @event beforecomponentfocus * Fires before a component becomes focused. Return `false` to prevent * the component from gaining focus. * @param {Ext.FocusManager} fm A reference to the FocusManager singleton * @param {Ext.Component} cmp The component that is being focused * @param {Ext.Component} previousCmp The component that was previously focused, * or `undefined` if there was no previously focused component. * @markdown */ 'beforecomponentfocus', /** * @event componentfocus * Fires after a component becomes focused. * @param {Ext.FocusManager} fm A reference to the FocusManager singleton * @param {Ext.Component} cmp The component that has been focused * @param {Ext.Component} previousCmp The component that was previously focused, * or `undefined` if there was no previously focused component. * @markdown */ 'componentfocus', /** * @event disable * Fires when the FocusManager is disabled * @param {Ext.FocusManager} fm A reference to the FocusManager singleton */ 'disable', /** * @event enable * Fires when the FocusManager is enabled * @param {Ext.FocusManager} fm A reference to the FocusManager singleton */ 'enable' ); // Setup KeyNav that's bound to document to catch all // unhandled/bubbled key events for navigation me.keyNav = Ext.create('Ext.util.KeyNav', Ext.getDoc(), { disabled: true, scope: me, backspace: me.focusLast, enter: me.navigateIn, esc: me.navigateOut, tab: me.navigateSiblings //space: me.navigateIn, //del: me.focusLast, //left: me.navigateSiblings, //right: me.navigateSiblings, //down: me.navigateSiblings, //up: me.navigateSiblings }); me.focusData = {}; me.subscribers = Ext.create('Ext.util.HashMap'); me.focusChain = {}; // Setup some ComponentQuery pseudos Ext.apply(CQ.pseudos, { focusable: function(cmps) { var len = cmps.length, results = [], i = 0, c, isFocusable = function(x) { return x && x.focusable !== false && CQ.is(x, '[rendered]:not([destroying]):not([isDestroyed]):not([disabled]){isVisible(true)}{el && c.el.dom && c.el.isVisible()}'); }; for (; i < len; i++) { c = cmps[i]; if (isFocusable(c)) { results.push(c); } } return results; }, nextFocus: function(cmps, idx, step) { step = step || 1; idx = parseInt(idx, 10); var len = cmps.length, i = idx + step, c; for (; i != idx; i += step) { if (i >= len) { i = 0; } else if (i < 0) { i = len - 1; } c = cmps[i]; if (CQ.is(c, ':focusable')) { return [c]; } else if (c.placeholder && CQ.is(c.placeholder, ':focusable')) { return [c.placeholder]; } } return []; }, prevFocus: function(cmps, idx) { return this.nextFocus(cmps, idx, -1); }, root: function(cmps) { var len = cmps.length, results = [], i = 0, c; for (; i < len; i++) { c = cmps[i]; if (!c.ownerCt) { results.push(c); } } return results; } }); }, /** * Adds the specified xtype to the {@link #whitelist}. * @param {String/Array} xtype Adds the xtype(s) to the {@link #whitelist}. */ addXTypeToWhitelist: function(xtype) { var me = this; if (Ext.isArray(xtype)) { Ext.Array.forEach(xtype, me.addXTypeToWhitelist, me); return; } if (!Ext.Array.contains(me.whitelist, xtype)) { me.whitelist.push(xtype); } }, clearComponent: function(cmp) { clearTimeout(this.cmpFocusDelay); if (!cmp.isDestroyed) { cmp.blur(); } }, /** * Disables the FocusManager by turning of all automatic focus management and keyboard navigation */ disable: function() { var me = this; if (!me.enabled) { return; } delete me.options; me.enabled = false; Ext.ComponentManager.all.un('add', me.onComponentCreated, me); me.removeDOM(); // Stop handling key navigation me.keyNav.disable(); // disable focus for all components me.setFocusAll(false); me.fireEvent('disable', me); }, /** * Enables the FocusManager by turning on all automatic focus management and keyboard navigation * @param {Boolean/Object} options Either `true`/`false` to turn on the focus frame, or an object of the following options: - focusFrame : Boolean `true` to show the focus frame around a component when it is focused. Defaults to `false`. * @markdown */ enable: function(options) { var me = this; if (options === true) { options = { focusFrame: true }; } me.options = options = options || {}; if (me.enabled) { return; } // Handle components that are newly added after we are enabled Ext.ComponentManager.all.on('add', me.onComponentCreated, me); me.initDOM(options); // Start handling key navigation me.keyNav.enable(); // enable focus for all components me.setFocusAll(true, options); // Finally, let's focus our global focus el so we start fresh me.focusEl.focus(); delete me.focusedCmp; me.enabled = true; me.fireEvent('enable', me); }, focusLast: function(e) { var me = this; if (me.isWhitelisted(me.focusedCmp)) { return true; } // Go back to last focused item if (me.previousFocusedCmp) { me.previousFocusedCmp.focus(); } }, getRootComponents: function() { var me = this, CQ = Ext.ComponentQuery, inline = CQ.query(':focusable:root:not([floating])'), floating = CQ.query(':focusable:root[floating]'); // Floating items should go to the top of our root stack, and be ordered // by their z-index (highest first) floating.sort(function(a, b) { return a.el.getZIndex() > b.el.getZIndex(); }); return floating.concat(inline); }, initDOM: function(options) { var me = this, sp = ' ', cls = me.focusFrameCls; if (!Ext.isReady) { Ext.onReady(me.initDOM, me); return; } // Create global focus element if (!me.focusEl) { me.focusEl = Ext.getBody().createChild({ tabIndex: '-1', cls: me.focusElementCls, html: sp }); } // Create global focus frame if (!me.focusFrame && options.focusFrame) { me.focusFrame = Ext.getBody().createChild({ cls: cls, children: [ { cls: cls + '-top' }, { cls: cls + '-bottom' }, { cls: cls + '-left' }, { cls: cls + '-right' } ], style: 'top: -100px; left: -100px;' }); me.focusFrame.setVisibilityMode(Ext.core.Element.DISPLAY); me.focusFrameWidth = me.focusFrame.child('.' + cls + '-top').getHeight(); me.focusFrame.hide().setLeftTop(0, 0); } }, isWhitelisted: function(cmp) { return cmp && Ext.Array.some(this.whitelist, function(x) { return cmp.isXType(x); }); }, navigateIn: function(e) { var me = this, focusedCmp = me.focusedCmp, rootCmps, firstChild; if (!focusedCmp) { // No focus yet, so focus the first root cmp on the page rootCmps = me.getRootComponents(); if (rootCmps.length) { rootCmps[0].focus(); } } else { // Drill into child ref items of the focused cmp, if applicable. // This works for any Component with a getRefItems implementation. firstChild = Ext.ComponentQuery.query('>:focusable', focusedCmp)[0]; if (firstChild) { firstChild.focus(); } else { // Let's try to fire a click event, as if it came from the mouse if (Ext.isFunction(focusedCmp.onClick)) { e.button = 0; focusedCmp.onClick(e); focusedCmp.focus(); } } } }, navigateOut: function(e) { var me = this, parent; if (!me.focusedCmp || !(parent = me.focusedCmp.up(':focusable'))) { me.focusEl.focus(); return; } parent.focus(); }, navigateSiblings: function(e, source, parent) { var me = this, src = source || me, key = e.getKey(), EO = Ext.EventObject, goBack = e.shiftKey || key == EO.LEFT || key == EO.UP, checkWhitelist = key == EO.LEFT || key == EO.RIGHT || key == EO.UP || key == EO.DOWN, nextSelector = goBack ? 'prev' : 'next', idx, next, focusedCmp; focusedCmp = (src.focusedCmp && src.focusedCmp.comp) || src.focusedCmp; if (!focusedCmp && !parent) { return; } if (checkWhitelist && me.isWhitelisted(focusedCmp)) { return true; } parent = parent || focusedCmp.up(); if (parent) { idx = focusedCmp ? Ext.Array.indexOf(parent.getRefItems(), focusedCmp) : -1; next = Ext.ComponentQuery.query('>:' + nextSelector + 'Focus(' + idx + ')', parent)[0]; if (next && focusedCmp !== next) { next.focus(); return next; } } }, onComponentBlur: function(cmp, e) { var me = this; if (me.focusedCmp === cmp) { me.previousFocusedCmp = cmp; delete me.focusedCmp; } if (me.focusFrame) { me.focusFrame.hide(); } }, onComponentCreated: function(hash, id, cmp) { this.setFocus(cmp, true, this.options); }, onComponentDestroy: function(cmp) { this.setFocus(cmp, false); }, onComponentFocus: function(cmp, e) { var me = this, chain = me.focusChain; if (!Ext.ComponentQuery.is(cmp, ':focusable')) { me.clearComponent(cmp); // Check our focus chain, so we don't run into a never ending recursion // If we've attempted (unsuccessfully) to focus this component before, // then we're caught in a loop of child->parent->...->child and we // need to cut the loop off rather than feed into it. if (chain[cmp.id]) { return; } // Try to focus the parent instead var parent = cmp.up(); if (parent) { // Add component to our focus chain to detect infinite focus loop // before we fire off an attempt to focus our parent. // See the comments above. chain[cmp.id] = true; parent.focus(); } return; } // Clear our focus chain when we have a focusable component me.focusChain = {}; // Defer focusing for 90ms so components can do a layout/positioning // and give us an ability to buffer focuses clearTimeout(me.cmpFocusDelay); if (arguments.length !== 2) { me.cmpFocusDelay = Ext.defer(me.onComponentFocus, 90, me, [cmp, e]); return; } if (me.fireEvent('beforecomponentfocus', me, cmp, me.previousFocusedCmp) === false) { me.clearComponent(cmp); return; } me.focusedCmp = cmp; // If we have a focus frame, show it around the focused component if (me.shouldShowFocusFrame(cmp)) { var cls = '.' + me.focusFrameCls + '-', ff = me.focusFrame, fw = me.focusFrameWidth, box = cmp.el.getPageBox(), // Size the focus frame's t/b/l/r according to the box // This leaves a hole in the middle of the frame so user // interaction w/ the mouse can continue bt = box.top, bl = box.left, bw = box.width, bh = box.height, ft = ff.child(cls + 'top'), fb = ff.child(cls + 'bottom'), fl = ff.child(cls + 'left'), fr = ff.child(cls + 'right'); ft.setWidth(bw - 2).setLeftTop(bl + 1, bt); fb.setWidth(bw - 2).setLeftTop(bl + 1, bt + bh - fw); fl.setHeight(bh - 2).setLeftTop(bl, bt + 1); fr.setHeight(bh - 2).setLeftTop(bl + bw - fw, bt + 1); ff.show(); } me.fireEvent('componentfocus', me, cmp, me.previousFocusedCmp); }, onComponentHide: function(cmp) { var me = this, CQ = Ext.ComponentQuery, cmpHadFocus = false, focusedCmp, parent; if (me.focusedCmp) { focusedCmp = CQ.query('[id=' + me.focusedCmp.id + ']', cmp)[0]; cmpHadFocus = me.focusedCmp.id === cmp.id || focusedCmp; if (focusedCmp) { me.clearComponent(focusedCmp); } } me.clearComponent(cmp); if (cmpHadFocus) { parent = CQ.query('^:focusable', cmp)[0]; if (parent) { parent.focus(); } } }, removeDOM: function() { var me = this; // If we are still enabled globally, or there are still subscribers // then we will halt here, since our DOM stuff is still being used if (me.enabled || me.subscribers.length) { return; } Ext.destroy( me.focusEl, me.focusFrame ); delete me.focusEl; delete me.focusFrame; delete me.focusFrameWidth; }, /** * Removes the specified xtype from the {@link #whitelist}. * @param {String/Array} xtype Removes the xtype(s) from the {@link #whitelist}. */ removeXTypeFromWhitelist: function(xtype) { var me = this; if (Ext.isArray(xtype)) { Ext.Array.forEach(xtype, me.removeXTypeFromWhitelist, me); return; } Ext.Array.remove(me.whitelist, xtype); }, setFocus: function(cmp, focusable, options) { var me = this, el, dom, data, needsTabIndex = function(n) { return !Ext.Array.contains(me.tabIndexWhitelist, n.tagName.toLowerCase()) && n.tabIndex <= 0; }; options = options || {}; // Come back and do this after the component is rendered if (!cmp.rendered) { cmp.on('afterrender', Ext.pass(me.setFocus, arguments, me), me, { single: true }); return; } el = cmp.getFocusEl(); dom = el.dom; // Decorate the component's focus el for focus-ability if ((focusable && !me.focusData[cmp.id]) || (!focusable && me.focusData[cmp.id])) { if (focusable) { data = { focusFrame: options.focusFrame }; // Only set -1 tabIndex if we need it // inputs, buttons, and anchor tags do not need it, // and neither does any DOM that has it set already // programmatically or in markup. if (needsTabIndex(dom)) { data.tabIndex = dom.tabIndex; dom.tabIndex = -1; } el.on({ focus: data.focusFn = Ext.bind(me.onComponentFocus, me, [cmp], 0), blur: data.blurFn = Ext.bind(me.onComponentBlur, me, [cmp], 0), scope: me }); cmp.on({ hide: me.onComponentHide, close: me.onComponentHide, beforedestroy: me.onComponentDestroy, scope: me }); me.focusData[cmp.id] = data; } else { data = me.focusData[cmp.id]; if ('tabIndex' in data) { dom.tabIndex = data.tabIndex; } el.un('focus', data.focusFn, me); el.un('blur', data.blurFn, me); cmp.un('hide', me.onComponentHide, me); cmp.un('close', me.onComponentHide, me); cmp.un('beforedestroy', me.onComponentDestroy, me); delete me.focusData[cmp.id]; } } }, setFocusAll: function(focusable, options) { var me = this, cmps = Ext.ComponentManager.all.getArray(), len = cmps.length, cmp, i = 0; for (; i < len; i++) { me.setFocus(cmps[i], focusable, options); } }, setupSubscriberKeys: function(container, keys) { var me = this, el = container.getFocusEl(), scope = keys.scope, handlers = { backspace: me.focusLast, enter: me.navigateIn, esc: me.navigateOut, scope: me }, navSiblings = function(e) { if (me.focusedCmp === container) { // Root the sibling navigation to this container, so that we // can automatically dive into the container, rather than forcing // the user to hit the enter key to dive in. return me.navigateSiblings(e, me, container); } else { return me.navigateSiblings(e); } }; Ext.iterate(keys, function(key, cb) { handlers[key] = function(e) { var ret = navSiblings(e); if (Ext.isFunction(cb) && cb.call(scope || container, e, ret) === true) { return true; } return ret; }; }, me); return Ext.create('Ext.util.KeyNav', el, handlers); }, shouldShowFocusFrame: function(cmp) { var me = this, opts = me.options || {}; if (!me.focusFrame || !cmp) { return false; } // Global trumps if (opts.focusFrame) { return true; } if (me.focusData[cmp.id].focusFrame) { return true; } return false; }, /** * Subscribes an {@link Ext.container.Container} to provide basic keyboard focus navigation between its child {@link Ext.Component}'s. * @param {Ext.container.Container} container A reference to the {@link Ext.container.Container} on which to enable keyboard functionality and focus management. * @param {Boolean/Object} options An object of the following options: - keys : Array/Object An array containing the string names of navigation keys to be supported. The allowed values are: - 'left' - 'right' - 'up' - 'down' Or, an object containing those key names as keys with `true` or a callback function as their value. A scope may also be passed. E.g.: { left: this.onLeftKey, right: this.onRightKey, scope: this } - focusFrame : Boolean (optional) `true` to show the focus frame around a component when it is focused. Defaults to `false`. * @markdown */ subscribe: function(container, options) { var me = this, EA = Ext.Array, data = {}, subs = me.subscribers, // Recursively add focus ability as long as a descendent container isn't // itself subscribed to the FocusManager, or else we'd have unwanted side // effects for subscribing a descendent container twice. safeSetFocus = function(cmp) { if (cmp.isContainer && !subs.containsKey(cmp.id)) { EA.forEach(cmp.query('>'), safeSetFocus); me.setFocus(cmp, true, options); cmp.on('add', data.onAdd, me); } else if (!cmp.isContainer) { me.setFocus(cmp, true, options); } }; // We only accept containers if (!container || !container.isContainer) { return; } if (!container.rendered) { container.on('afterrender', Ext.pass(me.subscribe, arguments, me), me, { single: true }); return; } // Init the DOM, incase this is the first time it will be used me.initDOM(options); // Create key navigation for subscriber based on keys option data.keyNav = me.setupSubscriberKeys(container, options.keys); // We need to keep track of components being added to our subscriber // and any containers nested deeply within it (omg), so let's do that. // Components that are removed are globally handled. // Also keep track of destruction of our container for auto-unsubscribe. data.onAdd = function(ct, cmp, idx) { safeSetFocus(cmp); }; container.on('beforedestroy', me.unsubscribe, me); // Now we setup focusing abilities for the container and all its components safeSetFocus(container); // Add to our subscribers list subs.add(container.id, data); }, /** * Unsubscribes an {@link Ext.container.Container} from keyboard focus management. * @param {Ext.container.Container} container A reference to the {@link Ext.container.Container} to unsubscribe from the FocusManager. * @markdown */ unsubscribe: function(container) { var me = this, EA = Ext.Array, subs = me.subscribers, data, // Recursively remove focus ability as long as a descendent container isn't // itself subscribed to the FocusManager, or else we'd have unwanted side // effects for unsubscribing an ancestor container. safeSetFocus = function(cmp) { if (cmp.isContainer && !subs.containsKey(cmp.id)) { EA.forEach(cmp.query('>'), safeSetFocus); me.setFocus(cmp, false); cmp.un('add', data.onAdd, me); } else if (!cmp.isContainer) { me.setFocus(cmp, false); } }; if (!container || !subs.containsKey(container.id)) { return; } data = subs.get(container.id); data.keyNav.destroy(); container.un('beforedestroy', me.unsubscribe, me); subs.removeAtKey(container.id); safeSetFocus(container); me.removeDOM(); } });