/** * Instances of this class encapsulate a position in a grid's row/column coordinate system. * * Cell addresses are configured using the owning {@link #property!record} and * {@link #property!column} for robustness. * * The column may be moved, the store may be sorted, and the grid Location will still * reference the same *logical* cell. Be aware that due to buffered rendering the *physical* * cell may be recycled and used by another record. * * Be careful not to make `grid Location` objects *too* persistent. If the owning record * is removed or filtered out, or the owning column is removed, the reference will be stale. * * Note that due to buffered rendering, a valid `location` object might be scrolled out of * visibility, and therefore derendered, and may not not have a corresponding rendered * row. * * Also, when using {@link Ext.data.virtual.Store virtual stores}, the record referenced may * not be present in the store, and may require an asynchronous load to bring it into the * store before the location can be realized. * * Freshly created Location objects, such as those exposed by events from the * {@link Ext.grid.Grid#cfg!selectable grid selection model} are safe to use until your * application mutates the store, or changes the column set. * * @since 6.5.0 */Ext.define('Ext.grid.Location', { extend: 'Ext.dataview.Location', /** * @property {Boolean} isGridLocation * @readonly * `true` in this class to identify an object this type, or subclass thereof. */ isGridLocation: true, /** * @property {Boolean} actionable * `true` if this is an actionable location. */ actionable: false, /** * @property {Ext.grid.cell.Base} [cell] * The cell. */ cell: null, /** * @property {Ext.grid.column.Column} [column] * The column. */ column: null, /** * @property {Number} [columnIndex] * The visible column index. */ columnIndex: -1, /** * @property {Boolean} summary * `true` if this is a {@link Ext.grid.SummaryRow summary row}. */ summary: false, /** * @property {Ext.grid.Row} [row] * The row. */ row: null, /** * @property {Ext.grid.RowBody} [rowBody] * The row body. */ rowBody: null, /** * @property {Boolean} [isTreeLocation] * `true` if this is a {@link Ext.grid.cell.Tree tree cell} location. */ isTreeLocation: false, inheritableStatics: { /** * @private */ defineProtoProperty: function(propName, getterName) { Object.defineProperty(this.prototype, propName, { get: function() { // This refers to the class instance var v = this[getterName](); // Mask the proto defineProperty with the instance value Object.defineProperty(this, propName, { value: v, configurable: true }); return v; } }); } }, /** * @method constructor * * Create a new Location * @param {Ext.grid.Grid} view The view. * @param {Object} source The source for the location. It can be: * * - `Ext.event.Event` - An event from the view. * - `Ext.dom.Element/HTMLElement` - An element from the view. * - `Ext.Widget` - A child component from the view. * - `Ext.data.Model` - A record from the view. * - `Number` - The record index. * - `Object` - An object with 2 properties, the `record` and `column`. */ attach: function(source) { var me = this, view = me.view, store = view.store, item, cell, column, columns, sourceRec, sourceCol, first; if (source.constructor === Object) { sourceRec = source.record; if (typeof sourceRec === 'number') { sourceRec = store.getAt(Math.max(Math.min(sourceRec, store.getCount() - 1), 0)); } sourceCol = source.column; if (typeof sourceCol === 'number') { columns = view.getVisibleColumns(); sourceCol = columns[Math.max(Math.min(sourceCol, columns.length - 1), 0)]; } if (!(sourceRec && sourceCol)) { if (sourceRec) { sourceCol = view.getFirstVisibleColumn(); } else { sourceRec = store.getAt(0); } } cell = view.mapToCell(sourceRec, sourceCol); if (cell) { source = cell.element; } else { me._setColumn(sourceCol); source = sourceRec; } } me.callParent([source]); item = me.item; if (item && item.isGridRow) { me.row = item; me.summary = item.isSummaryRow; if (!cell) { cell = view.mapToCell(source); if (!cell) { columns = view.getVisibleColumns(); first = columns[0]; if (first) { cell = item.getCellByColumn(first); } } } me.cell = cell; if (cell) { me.column = column = cell.getColumn(); columns = columns || view.getVisibleColumns(); me.columnIndex = columns.indexOf(column); me.isTreeLocation = !!cell.isTreeCell; } else { me.rowBody = view.mapToRowBody(source); } } }, /** * Creates a clone of this Location, optionally moving either the record or column * to a different position. * @param {Object} [options] The different `record` or `column` to attach the clone to. * @param {Ext.data.Model/Number/Ext.dom.Element} [options.record] The different `record` or * DataView item to attach the clone to. * @param {Ext.grid.column.Column/Number} [options.column] The different `column` to which to * attach the clone. * @return {Ext.grid.Location} The clone, optionally repositioned using the options parameter. */ clone: function(options) { var me = this, ret = me.callParent(), record, column, cell; if (options) { if (options.record !== undefined) { record = options.record; } if (options.column !== undefined) { column = options.column; } delete ret.sourceElement; } if (record != null) { delete me.source; me.superclass.attach.call(ret, record); ret.row = ret.item; } else { ret.row = ret.child = me.row; ret.summary = me.summary; ret.rowBody = me.rowBody; } if (column != null) { ret._setColumn(column); } else { ret.cell = cell = me.cell; ret.column = me.column; ret.columnIndex = me.columnIndex; me.isTreeLocation = !!(cell && cell.isTreeCell); } return ret; }, cloneForColumn: function(column) { return this.clone({ column: column }); }, /** * Compares this grid Location object to another grid Location to see if they refer to the same * cell *and*, if actionable, the same element in the same cell. So an actionable location * focused upon a {@link Ext.Tool} inside a cell will not be equal to the raw Location of that * cell. * @param {Ext.grid.Location} other The grid Location to compare. * @return {Boolean} `true` if the other cell Location references the same cell in the same * action/navigation mode as this. */ equals: function(other) { var me = this; if (other && other.view === me.view && other.isGridLocation) { // Actionable state must match. if (me.actionable !== other.actionable) { return false; } // Only actionable locations depend on the source element for equivalence if (me.sourceElement && me.actionable) { return other.sourceElement === me.sourceElement; } // We only get here if this location refers to an unrendered location, // or we are in navigation mode. Which means that we just test // recordIndex and column identity. // We'll always have a recordIndex (even if it's -1 due to virtual stores). // Therefore it's valid to check both record indices. If they differ, the locations can // never be equal. // And if index is the same but records are different, our location needs to be updated. if ((me.recordIndex !== other.recordIndex) || me.record !== other.record) { return false; } // If we get here, it is an unrendered location which can only be created // by encapsulating a record or recordIndex, so we will not be comparing // locations such as headers or footers or rowbodies. // We must be talking about cells. return me.column === other.column; } return false; }, /** * Compares this grid Location object to another grid Location to see if they refer to the same * cell address. * @param {Ext.grid.Location} other The grid Location to compare. * @return {Boolean} `true` if the other cell Location references the same cell address as this. */ equalCell: function(other) { var me = this; return other && other.view === me.view && other.isGridLocation && me.recordIndex === other.recordIndex && me.column === other.column; }, getFocusEl: function(as) { var me = this, ret; if (me.actionable) { ret = me.sourceElement; } else { ret = me.get(); ret = ret && !ret.destroyed && ret.el.dom; } // Only return the element if it is still in the document. return Ext.getBody().contains(ret) ? me.as(ret, as || 'el') : null; }, /** * Returns the {@link Ext.grid.Cell cell} Component referenced *at the time of calling*. * Note that grid DOM is recycled, and the cell referenced may be repurposed for use by * another record. * * @param {"cmp"/"dom"/"el"} [as=el] Pass `"dom"` to always return the cell's `HTMLElement`. * Pass `"el"` to return the cell's `Ext.dom.Element` . Pass `"cmp"` to * return the cell `Ext.Component` reference for this location (if one exists). * @return {Ext.grid.cell.Cell} The Cell component referenced by this Location. */ getCell: function(as) { var result = this.cell, ret = null; if (result) { ret = (as === 'dom' || as === true) ? result.el.dom : (as === 'cmp' ? result : result.el); } return ret; }, /** * Returns the {@link Ext.grid.cell.Cell cell} Component referenced *at the time of * calling*. Note that grid DOM is recycled, and the cell referenced may be repurposed * for use by another record. * * @return {Ext.grid.cell.Cell} The Cell component referenced by this Location. */ get: function() { return this.cell; }, isFirstColumn: function() { var column = this.column, ret = false; if (column) { ret = this.view.isFirstVisibleColumn(column); } return ret; }, isLastColumn: function() { var column = this.column, ret = false; if (column) { ret = this.view.isLastVisibleColumn(column); } return ret; }, /** * Re-orientates this Location according to the existing settings. If for example * a row has been deleted, or moved by a sort, or a column has been moved, this will * resync internal values with reality. */ refresh: function() { var me = this, column = me.column, oldColumnIndex = me.columnIndex, newColumnIndex = me.view.getHeaderContainer().indexOfLeaf(column), location; if (newColumnIndex === -1) { newColumnIndex = (oldColumnIndex === -1) ? 0 : oldColumnIndex; } // Restore connection to an item using superclass. location = me.callParent(); // Reconnect to the column return location._setColumn(newColumnIndex); }, /** * Navigates to the next visible Location. * @param {Boolean/Object} [options] An options object or a boolean flag meaning wrap * @param {Boolean} [options.wrap] `true` to wrap from the last to the first Location. * @return {Ext.grid.Location} A Location object representing the new location. */ next: function(options) { var me = this, candidate; if (me.actionable) { return me.navigate(); } else { for (candidate = me.nextCell(options); candidate && !candidate.get().el.isFocusable(); candidate = candidate.nextCell(options)) { // just skips non-focusable Locations... } return candidate || me; } }, /** * Navigates to the previous visible Location. * @param {Boolean/Object} [options] An options object or a boolean flag meaning wrap * @param {Boolean} [options.wrap] `true` to wrap from the first to the last Location. * @returns {Ext.grid.Location} A Location object representing the new location. */ previous: function(options) { var me = this, candidate; if (me.actionable) { return me.navigate(true); } else { for (candidate = me.previousCell(options); candidate && !candidate.get().el.isFocusable(); candidate = candidate.previousCell(options)) { // just skips non-focusable Locations... } return candidate || me; } }, /** * Navigates to the next visible Location. * @param {Boolean/Object} [options] An options object or a boolean flag meaning wrap * @param {Boolean} [options.wrap] `true` to wrap from the last to the first Location. * @param {Number} [options.column] The column to move to if not the current column. * @returns {Ext.dataview.Location} A *new* Location object representing the new location. */ down: function(options) { var me = this, column = options && options.column || me.column, candidate = me.nextItem(options), cell; if (candidate) { candidate._setColumn(column); cell = candidate.get(); while (candidate && (!cell || !cell.el.isFocusable())) { candidate = candidate.nextItem(options); if (candidate) { candidate._setColumn(column); cell = candidate.get(); } } if (candidate && !candidate.equals(me)) { return candidate; } } return me; }, /** * Navigates to the previous visible Location. * @param {Boolean/Object} [options] An options object or a boolean flag meaning wrap * @param {Boolean} [options.wrap] `true` to wrap from the first to the last Location. * @param {Number} [options.column] The column to move to if not the current column. * @returns {Ext.dataview.Location} A *new* Location object representing the new location. */ up: function(options) { var me = this, column = options && options.column || me.column, candidate = me.previousItem(options), cell; if (candidate) { candidate._setColumn(column); cell = candidate.get(); while (candidate && (!cell || !cell.el.isFocusable())) { candidate = candidate.previousItem(options); if (candidate) { candidate._setColumn(column); cell = candidate.get(); } } } if (candidate && !candidate.equals(me)) { return candidate; } return me; }, privates: { determineActionable: function() { var target = this.sourceElement, cell = this.cell, actionable = false; // If targetElement is *not* one of our cells, then if it's focusable // this is an actionable location. // // For example, a floated menu or any focusable thing popped up by a widget in a cell. if (target && (!cell || cell.destroyed || cell.element.dom !== target)) { actionable = Ext.fly(target).isFocusable(true); } return actionable; }, /** * Navigates from the current position in actionable mode. * @param {Boolean} reverse * @return {Ext.grid.Location} The new actionable Location * @private */ navigate: function(reverse) { var me = this, activeEl = me.sourceElement, view = me.view, scrollable = view.getScrollable(), actionables = view.getNavigationModel().actionables, len = actionables && actionables.length, candidate = me.clone(), previousCandidate = me.clone(), testEl, visitOptions = { callback: function(el) { testEl = Ext.fly(el); if (!testEl.$isFocusTrap && testEl.isFocusable()) { component = Ext.Component.from(el); // If it's the focusEl of a disabled component, or the // focusTrap of a PickerField, skip it if (!component || !component.getDisabled()) { focusables.push(el); } } }, reverse: reverse, skipSelf: true }, focusables = [], i, result, component; // Loop in the passed direction until we either find a result, or we walk off the end of // the rendered block, in which case we have to give up. while (candidate && !result && candidate.get()) { // Collect focusables from the candidate cell arranged in the direction of travel. focusables.length = 0; candidate.get().el.visit(visitOptions); // See if there's a next focusable element in the current candidate activeEl = focusables[ activeEl ? (Ext.Array.indexOf(focusables, activeEl) + 1) : 0 ]; if (activeEl) { result = candidate; result.source = result.sourceElement = activeEl; delete result.actionable; // We want to see the whole item which contains the activeEl, so we must scroll // vertically to bring that into view, then scroll the activeEl into view (which // will most likely do nothing unless it's a horizontal scroll needed) if (candidate.child) { scrollable.ensureVisible(candidate.child.el); } scrollable.ensureVisible(activeEl); activeEl.focus(); } // If we found no next focusable internal element, move to next cell and ask // the Actionables whether they are interested in it. else { candidate = candidate[reverse ? 'previousCell' : 'nextCell'](); // The previousCell/nextCell methods return the same logical location to // indicate inability to navigate that direction so we're done, the loop through // candidates was unable to settle on an actionable location, so the "new" // location is me. if (candidate.equals(previousCandidate)) { return me; } // If we cannot navigate, previousCell and nextCell return the same Location if (candidate && len) { for (i = 0; !result && i < len; i++) { view.ensureVisible(candidate.record, { column: candidate.columnIndex, focus: true } ); result = actionables[i].activateCell(candidate); } } } previousCandidate = candidate; } return result || me; }, activate: function() { var me = this, view = me.view, scrollable = view.getScrollable(), actionables = view.getNavigationModel().actionables, len = actionables && actionables.length, candidate = me.clone(), activeEl, i, result; // Find the first focusable internal element candidate.get().el.visit({ callback: function(el) { if (Ext.fly(el).isFocusable()) { activeEl = el; return false; } }, skipSelf: true }); if (activeEl) { result = candidate; result.source = result.sourceElement = activeEl; delete result.actionable; // We want to see the whole item which *contains* the activeEl, so we must scroll // vertically to bring that into view, then scroll the activeEl into view (which // will most likely do nothing unless it's a horizontal scroll needed) if (candidate.child) { scrollable.ensureVisible(candidate.child.el); } scrollable.ensureVisible(activeEl); activeEl.focus(); } // If we found no tabbable internal elements, ask the Actionables whether they // are interested in it. else { for (i = 0; !result && i < len; i++) { result = actionables[i].activateCell(candidate); } } return result; }, getFocusables: function() { var focusables = [], element = this.sourceElement; if (element) { Ext.fly(element).visit({ callback: function(el) { if (Ext.fly(el).isFocusable()) { focusables.push(el); } }, skipSelf: true }); } return focusables; }, /** * Navigates to the next visible *cell* Location. * @param {Boolean/Object} options An options object or a boolean flag meaning wrap * @param {Boolean} [options.wrap] `true` to wrap from the last to the first Location. * @return {Ext.grid.Location} A Location object representing the new location. * @private */ nextCell: function(options) { var me = this, startPoint = me.clone(), result = me.clone(), columns = me.getVisibleColumns(), rowIndex = me.view.store.indexOf(me.row.getRecord()), targetIndex, wrap = false; if (options) { if (typeof options === 'boolean') { wrap = options; } else { wrap = options.wrap; } } do { // If we are at the start of the row, or it's not a grid row, // Go up to the last column. targetIndex = columns.indexOf(result.column) + 1; if (targetIndex === columns.length || !me.child.isGridRow) { // We need to move down only if current row index > 1 if (rowIndex < me.view.store.getData().count() - 1) { result = me.getUpdatedLocation(columns[0], rowIndex + 1); } else if (wrap) { result = me.getUpdatedLocation(columns[0], 0); } } else { result = me.getUpdatedLocation(columns[targetIndex], rowIndex); } // We've wrapped all the way round without finding a visible location. // Quit now. if (result && result.equals(startPoint)) { break; } if (!result) { result = startPoint; break; } } while (result && !result.sourceElement); return result; }, /** * Get all the visible columns including partners as well */ getVisibleColumns: function() { var me = this, view = me.view, partners = view.allPartners || [view], partnerLen = partners.length, i, visibleColumns = []; for (i = 0; i < partnerLen; i++) { visibleColumns = visibleColumns.concat(partners[i].getVisibleColumns()); } return visibleColumns; }, /** * Navigates to the previous visible *cell* Location. * @param {Boolean/Object} options An options object or a boolean flag meaning wrap * @param {Boolean} [options.wrap] `true` to wrap from the first to the last Location. * @returns {Ext.grid.Location} A Location object representing the new location. * @private */ previousCell: function(options) { var me = this, startPoint = me.clone(), result = me.clone(), columns = me.getVisibleColumns(), rowIndex = me.view.store.indexOf(me.row.getRecord()), targetIndex, wrap = false; if (options) { if (typeof options === 'boolean') { wrap = options; } else { wrap = options.wrap; } } do { // If we are at the start of the row, or it's not a grid row, // Go up to the last column. targetIndex = columns.indexOf(result.column) - 1; if (targetIndex === -1 || !me.child.isGridRow) { // We need to move up only if current row index > 1 if (rowIndex > 0) { result = me.getUpdatedLocation(columns[columns.length - 1], rowIndex - 1); } else if (wrap) { result = me.getUpdatedLocation(columns[columns.length - 1], me.view.store.getData().count() - 1); } } else { result = me.getUpdatedLocation(columns[targetIndex], rowIndex); } // We've wrapped all the way round without finding a visible location. // Quit now. if (result && result.equals(startPoint)) { break; } if (!result) { result = startPoint; break; } } while (result && !result.sourceElement); return result; }, /** * Function to get the new location object based on target columns and the row num. * @param {Ext.Grid.Column} Column target column in for which we need the location * @param {Int} targetRowIndex target row in which we need to find the column */ getUpdatedLocation: function(column, targetRowIndex) { var me = this, grid = column.getGrid(), targetRecord = me.view.store.getData().getAt(targetRowIndex), location = grid.getNavigationModel().createLocation(targetRecord); // Update location location.column = column; location.columnIndex = grid.getVisibleColumns().indexOf(column); location.cell = location.row && location.row.getCellByColumn(column); if (location.cell) { delete me.event; delete me.actionable; location.isTreeLocation = !!location.cell.isTreeCell; location.sourceElement = location.cell.el.dom; return location; } else { return null; } }, /** * Internal function to allow the clone method to mutate the clone * as requested in the options. * @private */ _setColumn: function(column) { var me = this, columns = me.view.getVisibleColumns(), index; if (typeof column === 'number') { index = column; column = columns[index]; } else { index = columns.indexOf(column); } delete me.event; delete me.actionable; me.column = column; me.columnIndex = index; me.cell = me.row && me.row.getCellByColumn(column); if (me.cell) { me.isTreeLocation = !!me.cell.isTreeCell; me.sourceElement = me.cell.el.dom; } return me; } } }, function(Cls) { Cls.defineProtoProperty('actionable', 'determineActionable');});