messengercustom-servicesmacoslinuxwindowsinboxwhatsappicloudtweetdeckhipchattelegramhangoutsslackgmailskypefacebook-workplaceoutlookemailmicrosoft-teamsdiscord
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
442 lines
14 KiB
442 lines
14 KiB
/** |
|
* @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', { |
|
mixins: [ |
|
'Ext.util.Observable', |
|
'Ext.mixin.Factoryable' |
|
], |
|
|
|
alias: 'view.navigation.default', |
|
|
|
/** |
|
* @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 (me.dataSource !== dataSource) { |
|
me.dataSource = dataSource; |
|
listeners = me.getStoreListeners(); |
|
listeners.destroyable = true; |
|
me.dataSourceListeners = view.dataSource.on(listeners); |
|
} |
|
listeners = me.getViewListeners(); |
|
listeners.destroyable = true; |
|
me.viewListeners = me.viewListeners || []; |
|
me.viewListeners.push(view.on(listeners)); |
|
}, |
|
|
|
getStoreListeners: function() { |
|
var me = this; |
|
|
|
return { |
|
clear: me.onStoreClear, |
|
remove: me.onStoreRemove, |
|
scope: me |
|
}; |
|
}, |
|
|
|
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, |
|
tab: me.onKeyTab, |
|
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) { |
|
return event; |
|
}, |
|
|
|
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 already focused, do not disturb the focus. |
|
if (this.view.containsFocus) { |
|
mousedownEvent.preventDefault(); |
|
} |
|
}, |
|
|
|
onItemMouseDown: function(view, record, item, index, mousedownEvent) { |
|
var parentEvent = mousedownEvent.parentEvent; |
|
|
|
// If the ExtJS mousedown event is a translated touchstart, leave it until the click to focus |
|
if (!parentEvent || parentEvent.type !== 'touchstart') { |
|
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); |
|
} |
|
}, |
|
|
|
/** |
|
* @template |
|
* @protected |
|
* Called by {@link Ext.view.AbstractView#method-refresh} before refresh to allow |
|
* the current focus position to be cached. |
|
*/ |
|
beforeViewRefresh: function() { |
|
this.focusRestorePosition = this.view.dataSource.isBufferedStore ? this.recordIndex : this.record; |
|
}, |
|
|
|
/** |
|
* @template |
|
* @protected |
|
* Called by {@link Ext.view.AbstractView#method-refresh} after refresh to allow |
|
* cached focus position to be restored. |
|
*/ |
|
onViewRefresh: function() { |
|
if (this.focusRestorePosition != null) { |
|
this.setPosition(this.focusRestorePosition); |
|
this.focusRestorePosition = null; |
|
} |
|
}, |
|
|
|
// Store clearing removes focus |
|
onStoreClear: function() { |
|
this.setPosition(); |
|
}, |
|
|
|
// On record remove, it might have bumped the selection upwards. |
|
// Pass the "preventSelection" flag. |
|
onStoreRemove: function() { |
|
this.setPosition(this.getRecord(), null, null, true); |
|
}, |
|
|
|
setPosition: function(recordIndex, keyEvent, suppressEvent, preventNavigation) { |
|
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 { |
|
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; |
|
|
|
if (recordIndex != null && recordIndex !== -1) { |
|
if (recordIndex.isEntity) { |
|
recordIndex = me.view.dataSource.indexOf(recordIndex); |
|
} |
|
me.item = me.view.all.item(recordIndex); |
|
if (me.item) { |
|
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); |
|
}, |
|
|
|
// As per WAI-ARIA requirements, a grid should support two modes: Navigable (default), |
|
// and Actionable. In Navigable mode, pressing Tab key inside the grid should move focus |
|
// to the next tabbable element outside the grid. In Actionable mode, pressing Tab key |
|
// should move focus to the next tabbable/actionable element within the grid, wrapping over |
|
// row end to the next row, and over last row end to the first row. |
|
// See http://www.w3.org/TR/2013/WD-wai-aria-practices-20130307/#grid |
|
// In this method we implement the first (Navigable) part, which is shared between |
|
// Grids and Views. |
|
onKeyTab: function(keyEvent) { |
|
var view = this.view; |
|
|
|
// To prevent Tab key from moving focus to the next element inside the grid |
|
// in Navigable mode, we make all elements untabbable so the focus flows out |
|
// following the natural tab order. |
|
view.toggleChildrenTabbability(false); |
|
|
|
// Enable further event propagation |
|
return true; |
|
}, |
|
|
|
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() { |
|
var me = this; |
|
Ext.destroy(me.dataSourceListeners, me.viewListeners, me.keyNav); |
|
me.keyNav = me.dataSourceListeners = me.viewListeners = me.dataSource = null; |
|
me.callParent(); |
|
} |
|
}); |