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