X-Git-Url: http://git.ithinksw.org/extjs.git/blobdiff_plain/0494b8d9b9bb03ab6c22b34dae81261e3cd7e3e6..7a654f8d43fdb43d78b63d90528bed6e86b608cc:/src/FocusManager.js diff --git a/src/FocusManager.js b/src/FocusManager.js new file mode 100644 index 00000000..cf5645ba --- /dev/null +++ b/src/FocusManager.js @@ -0,0 +1,859 @@ +/** + * @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 + * @docauthor Jarred Nicholls + */ +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(); + } +}); \ No newline at end of file