/**
* @class Ext.view.NavigationModel
* @private
* This class listens for key events fired from a {@link Ext.view.View DataView}, and moves
* the currently focused item by adding the class {@link #focusCls}.
*/
Ext.define('Ext.view.NavigationModel', {
alias: 'view.navigation.default',
mixins: [
'Ext.util.Observable',
'Ext.mixin.Factoryable',
'Ext.util.StoreHolder'
],
config: {
store: null
},
/**
* @event navigate Fired when a key has been used to navigate around the view.
* @param {Object} event
* @param {Ext.event.Event} keyEvent The key event which caused the navigation.
* @param {Number} event.previousRecordIndex The previously focused record index.
* @param {Ext.data.Model} event.previousRecord The previously focused record.
* @param {HTMLElement} event.previousItem The previously focused view item.
* @param {Number} event.recordIndex The newly focused record index.
* @param {Ext.data.Model} event.record the newly focused record.
* @param {HTMLElement} event.item the newly focused view item.
*/
/**
* @private
*/
focusCls: Ext.baseCSSPrefix + 'view-item-focused',
constructor: function() {
this.mixins.observable.constructor.call(this);
},
bindComponent: function(view) {
if (this.view !== view) {
this.view = view;
this.bindView(view);
}
},
bindView: function(view) {
var me = this,
dataSource = view.dataSource,
listeners;
me.initKeyNav(view);
if (!dataSource.isEmptyStore) {
me.setStore(dataSource);
}
listeners = me.getViewListeners();
listeners.destroyable = true;
me.viewListeners = me.viewListeners || [];
me.viewListeners.push(view.on(listeners));
},
updateStore: function(store) {
this.mixins.storeholder.bindStore.apply(this, [store]);
},
getViewListeners: function() {
var me = this;
return {
containermousedown: me.onContainerMouseDown,
itemmousedown: me.onItemMouseDown,
// We focus on click if the mousedown handler did not focus
// because it was a translated "touchstart" event.
itemclick: me.onItemClick,
itemcontextmenu: me.onItemMouseDown,
scope: me
};
},
initKeyNav: function(view) {
var me = this;
// Drive the KeyNav off the View's itemkeydown event so that beforeitemkeydown listeners
// may veto. By default KeyNav uses defaultEventAction: 'stopEvent', and this is required
// for movement keys which by default affect scrolling.
me.keyNav = new Ext.util.KeyNav({
target: view,
ignoreInputFields: true,
eventName: 'itemkeydown',
defaultEventAction: 'stopEvent',
processEvent: me.processViewEvent,
up: me.onKeyUp,
down: me.onKeyDown,
right: me.onKeyRight,
left: me.onKeyLeft,
pageDown: me.onKeyPageDown,
pageUp: me.onKeyPageUp,
home: me.onKeyHome,
end: me.onKeyEnd,
space: me.onKeySpace,
enter: me.onKeyEnter,
A: {
ctrl: true,
// Need a separate function because we don't want the key
// events passed on to selectAll (causes event suppression).
handler: me.onSelectAllKeyPress
},
scope: me
});
},
processViewEvent: function(view, record, node, index, event) {
if (event.target === node) {
return event;
}
return null;
},
addKeyBindings: function(binding) {
this.keyNav.addBindings(binding);
},
enable: function() {
this.keyNav.enable();
this.disabled = false;
},
disable: function() {
this.keyNav.disable();
this.disabled = true;
},
onContainerMouseDown: function(view, mousedownEvent) {
// If the mousedown in the view element is NOT inside the client region,
// that is, it was on a scrollbar, then prevent default.
//
// Mousedowning on a scrollbar will focus the View.
// If they have scrolled to the bottom, then onFocusEnter will
// try to focus the lastFocused or first item. This is undesirable.
// So on mousedown outside of view client area, prevent the default focus behaviour.
// See Ext.view.Table#onFocusEnter for this being acted upon.
if (Ext.scrollbar.width()) {
if (!view.el.getClientRegion().contains(mousedownEvent.getPoint())) {
mousedownEvent.preventDefault();
view.lastFocused = 'scrollbar';
}
}
},
onItemMouseDown: function(view, record, item, index, mousedownEvent) {
// If the event is a touchstart, leave it until the click to focus.
if (mousedownEvent.pointerType !== 'touch') {
this.setPosition(index);
}
},
onItemClick: function(view, record, item, index, clickEvent) {
// If the mousedown that initiated the click has navigated us to the correct spot,
// just fire the event
if (this.record === record) {
this.fireNavigateEvent(clickEvent);
}
else {
this.setPosition(index, clickEvent);
}
},
setPosition: function(recordIndex, keyEvent, suppressEvent, preventNavigation, preventFocus) {
var me = this,
view = me.view,
selModel = view.getSelectionModel(),
dataSource = view.dataSource,
newRecord,
newRecordIndex;
if (recordIndex == null || !view.all.getCount()) {
me.record = me.recordIndex = null;
}
else {
if (typeof recordIndex === 'number') {
newRecordIndex = Math.max(Math.min(recordIndex, dataSource.getCount() - 1), 0);
newRecord = dataSource.getAt(recordIndex);
}
// row is a Record
else if (recordIndex.isEntity) {
newRecord = dataSource.getById(recordIndex.id);
newRecordIndex = dataSource.indexOf(newRecord);
// Previous record is no longer present; revert to first.
if (newRecordIndex === -1) {
newRecord = dataSource.getAt(0);
newRecordIndex = 0;
}
}
// row is a view item
else if (recordIndex.tagName) {
newRecord = view.getRecord(recordIndex);
newRecordIndex = dataSource.indexOf(newRecord);
}
else {
newRecord = newRecordIndex = null;
}
}
// No change; just ensure the correct item is focused and return early.
// Do not push current position into previous position, do not fire events.
// We must check record instances, not indices because of store reloads
// (combobox remote filtering).
// If there's a new record, focus it. Note that the index may be different even though
// the record is the same (filtering, sorting)
if (newRecord === me.record) {
me.recordIndex = newRecordIndex;
return me.focusPosition(newRecordIndex);
}
if (me.item) {
me.item.removeCls(me.focusCls);
}
// Track the last position.
// Used by SelectionModels as the navigation "from" position.
me.previousRecordIndex = me.recordIndex;
me.previousRecord = me.record;
me.previousItem = me.item;
// Update our position
me.recordIndex = newRecordIndex;
me.record = newRecord;
// Prevent navigation if focus has not moved
preventNavigation = preventNavigation || me.record === me.lastFocused;
// Maintain lastFocused, so that on non-specific focus of the View,
// we can focus the correct descendant.
if (newRecord) {
me.focusPosition(me.recordIndex);
}
else if (!preventFocus) {
me.item = null;
}
if (!suppressEvent) {
selModel.fireEvent('focuschange', selModel, me.previousRecord, me.record);
}
// If we have moved, fire an event
if (!preventNavigation && keyEvent) {
me.fireNavigateEvent(keyEvent);
}
},
/**
* @private
* Focuses the currently active position.
* This is used on view refresh and on replace.
*/
focusPosition: function(recordIndex) {
var me = this,
lastFocusedItem;
if (recordIndex != null && recordIndex !== -1) {
if (recordIndex.isEntity) {
recordIndex = me.view.dataSource.indexOf(recordIndex);
}
me.item = me.view.all.item(recordIndex);
if (me.item) {
if (me.view.tabInnerItems) {
lastFocusedItem = Ext.fly(me.view.getNodeByRecord(me.lastFocused));
if (lastFocusedItem && lastFocusedItem.el !== me.item.el) {
// Save the tabbable state of the current component,
// including itself and excluding any previously saved state.
// - `skipSelf: false` includes the current component itself,
// only capturing the tabbable state of its children.
// - `includeSaved: false` ensures that any previously saved
// tabbable state is not included in this update.
lastFocusedItem.saveTabbableState({
skipSelf: false,
includeSaved: false
});
}
// When we focus the dataitem for the first time, tab-index is not saved, hence
// explicitly setting it.
me.item.setTabIndex(0);
// Restore the previously saved tabbable state.
// - skipSelf: true because we need to make currently focused item,
// to be tabbable, for Shift + Tab
me.item.restoreTabbableState({
skipSelf: false
});
}
me.lastFocused = me.record;
me.lastFocusedIndex = me.recordIndex;
me.focusItem(me.item);
}
else {
me.record = null;
}
}
else {
me.item = null;
}
},
/**
* @template
* @protected
* Called to focus an item in the client {@link Ext.view.View DataView}.
* The default implementation adds the {@link #focusCls} to the passed item focuses it.
* Subclasses may choose to keep focus in another target.
*
* For example {@link Ext.view.BoundListKeyNav} maintains focus in the input field.
* @param {Ext.dom.Element} item
* @return {undefined}
*/
focusItem: function(item) {
item.addCls(this.focusCls);
item.focus();
},
getPosition: function() {
return this.record ? this.recordIndex : null;
},
getRecordIndex: function() {
return this.recordIndex;
},
getItem: function() {
return this.item;
},
getRecord: function() {
return this.record;
},
getLastFocused: function() {
// No longer there. The caller must fall back to a default.
if (this.view.dataSource.indexOf(this.lastFocused) === -1) {
return null;
}
return this.lastFocused;
},
onKeyUp: function(keyEvent) {
var newPosition = this.recordIndex - 1;
if (newPosition < 0) {
newPosition = this.view.all.getCount() - 1;
}
this.setPosition(newPosition, keyEvent);
},
onKeyDown: function(keyEvent) {
var newPosition = this.recordIndex + 1;
if (newPosition > this.view.all.getCount() - 1) {
newPosition = 0;
}
this.setPosition(newPosition, keyEvent);
},
onKeyRight: function(keyEvent) {
var newPosition = this.recordIndex + 1;
if (newPosition > this.view.all.getCount() - 1) {
newPosition = 0;
}
this.setPosition(newPosition, keyEvent);
},
onKeyLeft: function(keyEvent) {
var newPosition = this.recordIndex - 1;
if (newPosition < 0) {
newPosition = this.view.all.getCount() - 1;
}
this.setPosition(newPosition, keyEvent);
},
onKeyPageDown: Ext.emptyFn,
onKeyPageUp: Ext.emptyFn,
onKeyHome: function(keyEvent) {
this.setPosition(0, keyEvent);
},
onKeyEnd: function(keyEvent) {
this.setPosition(this.view.all.getCount() - 1, keyEvent);
},
onKeySpace: function(keyEvent) {
this.fireNavigateEvent(keyEvent);
},
// ENTER emulates an itemclick event at the View level
onKeyEnter: function(keyEvent) {
// Stop the keydown event so that an ENTER keyup does not get delivered to
// any element which focus is transferred to in a click handler.
keyEvent.stopEvent();
keyEvent.view.fireEvent('itemclick', keyEvent.view, keyEvent.record, keyEvent.item,
keyEvent.recordIndex, keyEvent);
},
onSelectAllKeyPress: function(keyEvent) {
this.fireNavigateEvent(keyEvent);
},
fireNavigateEvent: function(keyEvent) {
var me = this;
me.fireEvent('navigate', {
navigationModel: me,
keyEvent: keyEvent,
previousRecordIndex: me.previousRecordIndex,
previousRecord: me.previousRecord,
previousItem: me.previousItem,
recordIndex: me.recordIndex,
record: me.record,
item: me.item
});
},
destroy: function() {
this.setStore(null);
Ext.destroy(this.viewListeners, this.keyNav);
this.callParent();
}
});