/** * @class Ext.dataview.NavigationModel * @private * This class listens for events fired from a {@link Ext.dataview.DataView DataView}, * and tracks the currently focused item. */Ext.define('Ext.dataview.NavigationModel', { extend: 'Ext.Evented', alias: 'navmodel.dataview', mixins: [ 'Ext.mixin.Factoryable', 'Ext.mixin.Bufferable' ], requires: [ 'Ext.dataview.Location' ], factoryConfig: { type: 'navmodel', defaultType: 'dataview', instanceProp: 'isNavigationModel' }, isNavigationModel: true, config: { view: null, disabled: false }, bufferableMethods: { // buffer response to view's triggerEvent when that event is a focusing gesture // to allow focus to be processed before we go into selection. handleChildTrigger: 1 }, /** * @protected * @property {String} [locationClass=Ext.dataview.Location] * The name of the location class for this NavigationModel. This may be overridden in * subclasses. */ locationClass: 'Ext.dataview.Location', /** * @property {Ext.dataview.Location} lastLocation * This is the location that we last positively focused upon, whether or not focus * has been lost from the view, and the location has been cleared. * * Contrast this with {@link #property!previousLocation). */ /** * @property {Ext.dataview.Location} prevLocation * This is the location that we previously *`set`*, whether it was `null` or not. * So if focus is not currently in the view, this will be null. * * Contrast this with {@link #property!lastLocation). */ /** * Focuses the passed position, and optionally selects that position. * @param {Ext.dataview.Location/Ext.data.Model/Number/Ext.dom.Element} location The location * to focus. * @param {Object} [options] * @param {Object} [options.event] The UI event which caused the navigation if any. * @param {Object} [options.select] Pass as `true` to also select the location. * @param {Object} [options.animation] Pass as `true` or an animation config to animate to * the location. */ setLocation: function(location, options) { var me = this, view = me.getView(), oldLocation = me.location, animation = options && options.animation, scroller, child, record, itemContainer, childFloatStyle, locationView; if (location == null) { return me.clearLocation(); } if (!location.isDataViewLocation) { location = this.createLocation(location); } locationView = location.view; // If it's a valid location, focus it. // Handling the consquences will happen in the onFocusMove // listener unless the synchronous options is passed. if (!location.equals(oldLocation)) { record = location.record; child = location.child; // If the record is not rendered, ask to scroll to it and try again if (record && !child) { // TODO: column? return locationView.ensureVisible(record, { animation: animation }).then(function() { if (!me.destroyed) { locationView.getNavigationModel().setLocation({ record: record, column: location.column }, options); } }); } // Work out if they are using any of the ways to get the items // to flow inline. In which case, moving up requires extra work. if (child && me.floatingItems == null) { child = child.isComponent ? child.el : Ext.fly(child); itemContainer = child.up(); childFloatStyle = child.getStyleValue('float'); me.floatingItems = (view.getInline && view.getInline()) || child.isStyle('display', 'inline-block') || childFloatStyle === 'left' || childFloatStyle === 'right' || (itemContainer.isStyle('display', 'flex') && itemContainer.isStyle('flex-direction', 'row')); } // Use explicit scrolling rather than relying on the browser's focus behaviour. // Scroll on focus overscrolls. ensureVisible scrolls exactly correctly. scroller = locationView.getScrollable(); if (scroller) { scroller.ensureVisible(location.sourceElement, { animation: options && options.animation }); } // Handling the impending focus event is separated because it also needs to // happen in case of a focus move caused by assistive technologies. me.handleLocationChange(location, options); // Event handlers may have destroyed the view (and this) if (!me.destroyed) { me.doFocus(); } } }, clearLocation: function() { var me = this, targetElement; if (me.location) { me.lastLocation = me.location; targetElement = me.location.getFocusEl(); // If the reason we are being cleared is because our element has gone away // do not try to access the element. if (targetElement && !targetElement.destroyed) { Ext.fly(targetElement).removeCls(me.focusedCls); me.previousLocation = me.location; } else { me.previousLocation = null; } me.location = null; } }, getLocation: function() { return this.location; }, getPreviousLocation: function() { var result = this.previousLocation; if (result && (!result.sourceElement || !result.sourceElement.destroyed)) { result.refresh(); } return result; }, disable: function() { this.setDisabled(true); }, enable: function() { this.setDisabled(false); }, privates: { createLocation: function(source, options) { return Ext.create(this.locationClass, this.getView(), source, options); }, getKeyNavCfg: function(view) { var me = this; return { target: view.getFocusEl(), processEvent: me.processViewEvent, processEventScope: me, eventName: 'keydown', defaultEventAction: 'stopEvent', esc: me.onKeyEsc, f2: me.onKeyF2, 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, tab: me.onKeyTab, 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 }; }, updateView: function(view) { var me = this, keyNavCfg = me.getKeyNavCfg(view); me.focusedCls = view.focusedCls; // 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. if (keyNavCfg) { me.keyNav = new Ext.util.KeyNav(keyNavCfg); } me.viewListeners = view.on(me.getViewListeners(view)); }, getViewListeners: function(view) { var result = { scope: this }; result[view.getTriggerEvent()] = 'onChildTrigger'; return result; }, // We ignore input fields. processViewEvent: function(e) { var location = this.getLocation(), component; if (location && e.keyCode) { component = Ext.fly(e.target).component; // This flag indicates that the key event source is the dataview item. // Some key handlers only react in navigable mode. // TODO: implement actionable mode in DataViews. e.navigationMode = component && component.parent === this.getView(); e.setCurrentTarget(location.sourceElement); if (!Ext.fly(e.target).isInputField()) { return e; } } }, /** * @private * Focuses the passed location * May be overridden in subclasses which do not focus the targets */ doFocus: function(location) { location = location || this.location; // getElement returns the focusEl. // So for navigation mode, that's the navigation level element, ie // dataview item or grid cell. // For actionable mode, that's the focused sub-element. if (location && location.getFocusEl()) { location.getFocusEl().focus(); } }, // In the case of a focus move invoked by assistive technologies, // we have to react to that and maintain correct state. onFocusMove: function(e) { var location = this.createLocation(e); // If a setLocation call has been called with the synchronous option // handleLocationChange will already have been called. if (!location.equals(this.location)) { this.handleLocationChange(location, { event: e, navigate: false // we just navigated }); } }, handleLocationChange: function(location, options) { var me = this, oldLocation = me.location, view = me.getView(), target, item; // There is a subtle difference between previousLocation and lastLocation. // // previousLocation is where we focused previously whether null or not. So // when the location is cleared, for instance on view focusLeave, previousLocation // is cleared. // // // lastLocation is the last location that was positively focused. me.previousLocation = oldLocation; if (oldLocation) { me.lastLocation = oldLocation; // getFocusEl returns the focusEl. // So for navigation mode, that's the navigation level element, ie // dataview item or grid cell. // For actionable mode, that's the focused sub-element. // It may have been destroyed (eg 31 when month switches from Jan to Feb). target = oldLocation.getFocusEl(); if (target && !target.destroyed) { Ext.fly(target).removeCls(me.focusedCls); } } me.location = location; // If we are navigating to one of our navigable items, add our focused class to it. target = location && location.getFocusEl('dom'); if (target) { item = location.get(); if (item) { if (item.isWidget) { item = item.el; } else { item = Ext.get(item); } if (item && target === item.dom) { item.addCls(me.focusedCls); } if (options && (options.event || options.select) && options.navigate !== false) { me.onNavigate(options.event); } } } // Event handlers may destroy the view if (!view.destroyed) { view.fireEvent('navigate', view, location, oldLocation); } }, onKeyUp: function(e) { var me = this; // Do not scroll e.preventDefault(); if (me.location) { if (me.floatingItems) { me.moveUp(e); } else { me.movePrevious({ event: e }); } } else { me.setLocation(0); } }, onKeyDown: function(e) { var me = this; // Do not scroll e.preventDefault(); if (me.location) { if (me.floatingItems) { me.moveDown(e); } else { me.moveNext({ event: e }); } } else { me.setLocation(0); } }, onKeyLeft: function(e) { // Do not scroll e.preventDefault(); this.movePrevious({ event: e }); }, onKeyRight: function(e) { // Do not scroll e.preventDefault(); this.moveNext({ event: e }); }, onKeyF2: function(e) { return false; }, onKeyEsc: function(e) { return false; }, onKeyTab: function(e) { return !this.location.actionable; }, onKeyPageDown: function(e) { var me = this, candidate, view, y; // Do not scroll e.preventDefault(); if (!me.location.actionable && !me.floatingItems) { view = me.getView(); y = ( view.infinite ? view.getItemTop(me.location.child) : me.location.child.el.dom.offsetTop ) + view.el.getClientRegion().height; candidate = me.createLocation(view.getItemFromPoint(0, y)); // Might have landed on a non-focusable item. // The previous item moves to a focusable location. if (!(candidate.child && candidate.child.el.isFocusable())) { candidate = candidate.previous(); } // Go down by the visible page size me.setLocation(candidate, { event: e }); } }, onKeyPageUp: function(e) { var me = this, candidate, view, y; // Do not scroll e.preventDefault(); if (!me.location.actionable && !me.floatingItems) { view = me.getView(), y = ( view.infinite ? view.getItemTop(me.location.child) : me.location.child.el.dom.offsetTop ) - view.el.getClientRegion().height, candidate = me.createLocation(view.getItemFromPoint(0, y)); // Might have landed on a non-focusable item. // The next method advances to a focusable location. if (!(candidate.child && candidate.child.el.isFocusable())) { candidate = candidate.next(); } // Go up by the visible page size me.setLocation(candidate, { event: e }); } }, onKeyHome: function(e) { this.setLocation(0, { event: e }); }, onKeyEnd: function(e) { this.setLocation(this.getView().getStore().last(), { event: e }); }, onKeySpace: function(e) { this.onNavigate(e); }, // ENTER emulates an childtap event at the View level onKeyEnter: function(e) { // 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. e.stopEvent(); this.getView()._onChildTap(e); }, onSelectAllKeyPress: function(e) { var view = this.getView(), selModel = view.getSelectable(); // If there are items to select, select them, and do not allow any other // consequences to flow from CTRL/A, it would be confusing to the user. if (selModel && view.getStore().getCount()) { selModel[selModel.allSelected ? 'deselectAll' : 'selectAll'](); e.preventDefault(); return false; } }, // For use with inline DataViews, such as the KS. // We must see what's above moveUp: function(e) { var view = this.getView(), location = this.location, el = this.location.sourceElement, topCentre = Ext.fly(el).getAnchorXY('t'), item; // Look above the top centre of this item's element // Move 10pixels past any top/bottom padding; topCentre[1] -= (Ext.fly(el).getMargin('tb') + 10); item = view.getItemFromPagePoint(topCentre[0], topCentre[1], true); // Nothing above us, move to first, unless we are first, in which case, // wrap to last. if (!item || !item.isFocusable()) { item = location.isFirst() ? view.getLastItem() : view.getFirstItem(); } if (item) { this.setLocation(item, { event: e }); } }, // For use with inline DataViews, such as the KS. // We must see what's below moveDown: function(e) { var view = this.getView(), location = this.location, el = location.sourceElement, bottomCentre = Ext.fly(el).getAnchorXY('b'), item; // Look above the top centre of this item's element // Move 10pixels past any top/bottom padding; bottomCentre[1] += Ext.fly(el).getMargin('tb') + 10; item = view.getItemFromPagePoint(bottomCentre[0], bottomCentre[1]); // If we're on the last line, above blank space, go to last if (!item || !item.isFocusable()) { item = location.isLast() ? view.getFirstItem() : view.getLastItem(); } if (item) { this.setLocation(item, { event: e }); } }, moveNext: function(options) { var location = this.getLocation(); if (location) { location = location.next(options); if (location) { this.setLocation(location, options); } } }, movePrevious: function(options) { var location = this.getLocation(); if (location) { location = location.previous(options); if (location) { this.setLocation(location, options); } } }, onChildTrigger: function(view, location) { var e = location.event, isFocusingEvent = (e.type === ((e.pointerType === 'touch') ? 'tap' : 'touchstart')); // The selection event handler must run after any navigation caused by the // event has been processed. // For mouse click events this won't have an effect, mousedown will have focused and // navigated before the click. If the triggerEvent is ever configured to // 'childtouchstart' then on a mousedown event, focus will not have moved, so this // will become important. // For touch gestures, its's the tap that focuses, so we must wait until // the impending focusMove notification has done the navigation. if (isFocusingEvent) { this.handleChildTrigger(view, location); } else { this.doHandleChildTrigger(view, location); } }, doHandleChildTrigger: function(view, location) { var myLocation = this.location, event = location.event, compareMethod = location.isGridLocation ? 'equalCell' : 'equals'; // This is the selection gesture for the view. // We do not navigate on this gesture, navigation is driven by response to focus. // If that gesture results in fous, well and good - it will find the // location already selected. // We just call onNavigate which is how we go into selection // in response to navigation. Unless we have navigated already to this // location. if (myLocation && myLocation[compareMethod](location)) { this.onNavigate(event); } // If we ever get here and there has been no focus-driven navigation // navigate now. Synthetic events can do this. else { this.setLocation(location, { event: event }); } }, onNavigate: function(event) { var me = this, location = me.location; // Fake up an event if we have no event, but are just being commanded to select if (!event) { event = new Ext.event.Event({ target: location.sourceElement }); } Ext.apply(event, { navigationModel: me, from: me.previousLocation, to: location }); me.getView().onNavigate(event); }, updateDisabled: function(disabled) { // If the view is not focusable, (or, in the case of a BoundList, if it does // not have access to its ownerField - eg unit tests) then there will be no key // event source and so no keyNav. if (this.keyNav) { if (disabled) { this.keyNav.disable(); } else { this.keyNav.enable(); } } } }});