Grid's Location.js
need to be this:
import DomHelper from '../../Core/helper/DomHelper.js';
import Widget from '../../Core/widget/Widget.js';
/**
* @module Grid/util/Location
*/
/**
* This class encapsulates a reference to a specific navigable grid location.
*
* This encapsulates a grid cell based upon the record and column, but in addition, it could represent
* an actionable location *within a cell** if the {@link #property-target} is not the grid cell in
* question.
*
* A Location is immutable. That is, once instantiated, the record and column which it references
* cannot be changed. The {@link #function-move} method returns a new instance.
*
* A `Location` that encapsulates a cell within the body of a grid will have the following
* read-only properties:
*
* - grid : `Grid` The Grid that owns the Location.
* - record : `Model` The record of the row that owns the Location. (`null` if the header).
* - rowIndex : `Number` The zero-based index of the row that owns the Location. (-1 means the header).
* - column : `Column` The Column that owns the Location.
* - columnIndex : `Number` The zero-based index of the column that owns the Location.
* - cell : `HTMLElement` The referenced cell element.
* - target : `HTMLElement` The focusable element. This may be the cell, or a child of the cell.
*
* If the location is a column *header*, the `record` will be `null` and the `rowIndex` will be `-1`.
*
*/
export default class Location {
/**
* The grid which this Location references.
* @config {Grid.view.Grid} grid
*/
/**
* The record which this Location references. (unless {@link #config-rowIndex} is used to configure)
* @config {Core.data.Model} record
*/
/**
*
* The row index which this Location references. (unless {@link #config-record} is used to configure).
*
* `-1` means the header row, in which case the {@link #config-record} will be `null`.
* @config {Number} rowIndex
*/
/**
* The Column which this location references. (unless {@link #config-columnIndex} or {@link #config-columnId} is used to configure)
* @config {Grid.column.Column} column
*/
/**
* The column id which this location references. (unless {@link #config-column} or {@link #config-columnIndex} is used to configure)
* @config {String|Number} columnId
*/
/**
* The column index which this location references. (unless {@link #config-column} or {@link #config-columnId} is used to configure)
* @config {Number} columnIndex
*/
/**
* The field of the column index which this location references. (unless another column identifier is used to configure)
* @config {String} field
*/
/**
* Initializes a new Location.
* @param {Object|HTMLElement} location A grid location specifier. This may be:
* * An element inside a grid cell or a grid cell.
* * An object identifying a cell location using the following properties:
* * grid
* * record
* * rowIndex
* * column
* * columnIndex
* @function constructor
*/
constructor(location) {
// Private usage of init means that we can create an un attached Location
// The move method does this.
if (location) {
// They passed us a Location, so they already know where to go.
if (location.isLocation) {
return location;
}
// Passed a DOM node.
if (location.nodeType === Node.ELEMENT_NODE) {
const
grid = Widget.fromElement(location, 'gridbase'),
cell = grid && location.closest(grid.focusableSelector);
// We are targeted on, or within a cell.
if (cell) {
const { dataset } = cell.parentNode;
this.init({
grid,
// A .b-grid-row will have a data-index
// If it' a column header, we use rowIndex -1
rowIndex : grid.store.includes(dataset.id) ? grid.store.indexOf(dataset.id) : (dataset.index || -1),
columnId : cell.dataset.columnId
});
this.initialTarget = location;
}
}
else {
this.init(location);
}
}
}
init(config) {
const me = this;
//<debug>
if (!config.grid) {
throw new Error('Grid Location must include grid property');
}
//</debug>
const
grid = me.grid = config.grid,
{ store, columns } = grid,
{ visibleColumns } = columns;
// If we have a target. This is usually only for actionable locations.
if (config.target) {
me.actionTargets = [me._target = config.target];
}
// Determine our record and rowIndex
if (config.record) {
me._id = config.record.id;
}
else if ('id' in config) {
me._id = config.id;
// Null means that the Location is in the grid header, so rowIndex -1
if (config.id == null) {
me._rowIndex = -1;
}
}
else {
const rowIndex = !isNaN(config.row) ? config.row : !isNaN(config.rowIndex) ? config.rowIndex : NaN;
//<debug>
if (isNaN(rowIndex)) {
throw new Error('Grid Location must include either record, or id or rowIndex property');
}
//</debug>
me._rowIndex = Math.max(Math.min(Number(rowIndex), store.count - 1), grid.hideHeaders ? 0 : -1);
me._id = store.getAt(me._rowIndex)?.id;
}
if (!('_rowIndex' in me)) {
me._rowIndex = store.indexOf(me.id);
}
// Cache value that we use now. We do not hold a reference to a record
me.isSpecialRow = me.record?.isSpecialRow;
// Determine our column and columnIndex
if ('columnId' in config) {
me._column = columns.getById(config.columnId);
}
else if ('field' in config) {
me._column = columns.get(config.field);
}
else {
const columnIndex = !isNaN(config.column) ? config.column : !isNaN(config.columnIndex) ? config.columnIndex : NaN;
if (!isNaN(columnIndex)) {
me._columnIndex = Math.min(Number(columnIndex), visibleColumns.length - 1);
me._column = visibleColumns[me._columnIndex];
}
// Fall back to using 'column' property either as index or the Column.
// If no column property, use column zero.
else {
me._column = ('column' in config) ? isNaN(config.column) ? config.column : visibleColumns[config.column] : visibleColumns[0];
}
}
if (!('_columnIndex' in me)) {
me._columnIndex = visibleColumns.indexOf(me._column);
}
}
// Class identity indicator. Usually added by extending Base, but we don't do that for perf.
get isLocation() {
return true;
}
equals(other) {
return other?.isLocation &&
other.grid === this.grid &&
other.record === this.record &&
other.column === this.column &&
other.target === this.target;
}
/**
* Yields the row index of this location.
* @property {Number}
* @readonly
*/
get rowIndex() {
const
{ _id } = this,
{ store } = this.grid;
// Return the up to date row index for our record
return store.includes(_id) ? store.indexOf(_id) : Math.min(this._rowIndex, store.count - 1);
}
/**
* Used by GridNavigation.
* @private
*/
get visibleRowIndex() {
const
{ rowManager } = this.grid,
{ rowIndex } = this;
return rowIndex === -1 ? rowIndex : Math.max(Math.min(rowIndex, rowManager.lastFullyVisibleTow.dataIndex), rowManager.firstFullyVisibleTow.dataIndex);
}
/**
* Yields `true` if the cell and row are selectable.
*
* That is if the record is present in the grid's store and it's not a group summary or group header record.
* @property {Boolean}
* @readonly
*/
get isSelectable() {
return this.grid.store.includes(this._id) && !this.isSpecialRow;
}
get record() {
// -1 means the header row
if (this._rowIndex > -1) {
const { store } = this.grid;
// Location's record no longer in store; fall back to record at same index.
if (!store.includes(this._id)) {
return store.getAt(this._rowIndex);
}
return store.getById(this._id);
}
}
get id() {
return this._id;
}
get column() {
const { visibleColumns } = this.grid.columns;
// Location's column no longer visible; fall back to column at same index.
if (!visibleColumns?.includes(this._column)) {
return visibleColumns?.[this.columnIndex];
}
return this._column;
}
get columnId() {
return this.column?.id;
}
get columnIndex() {
return Math.min(this._columnIndex, this.grid.columns.visibleColumns?.length - 1);
}
/**
* Returns a __*new *__ `Location` instance having moved from the current location in the
* mode specified.
* @param {Number} where Where to move from this Location. May be:
*
* - `Location.UP`
* - `Location.NEXT_CELL`
* - `Location.DOWN`
* - `Location.PREV_CELL`
* - `Location.FIRST_COLUMN`
* - `Location.LAST_COLUMN`
* - `Location.FIRST_CELL`
* - `Location.LAST_CELL`
* - `Location.PREV_PAGE`
* - `Location.NEXT_PAGE`
* @returns {Grid.util.Location} A Location object encapsulating the target location.
*/
move(where) {
const
me = this,
{
record,
column,
grid
} = me,
{ store } = grid,
columns = grid.columns.visibleColumns,
result = new Location();
let rowIndex = store.includes(record) ? store.indexOf(record) : me.rowIndex,
columnIndex = columns.includes(column) ? columns.indexOf(column) : me.columnIndex;
const
rowMin = grid.hideHeaders ? 0 : -1,
rowMax = store.count - 1,
colMax = columns.length - 1,
atFirstRow = rowIndex === rowMin,
atLastRow = rowIndex === rowMax,
atFirstColumn = columnIndex === 0,
atLastColumn = columnIndex === colMax;
switch (where) {
case Location.PREV_CELL:
if (atFirstColumn) {
if (!atFirstRow) {
columnIndex = colMax;
rowIndex--;
}
}
else {
columnIndex--;
}
break;
case Location.NEXT_CELL:
if (atLastColumn) {
if (!atLastRow) {
columnIndex = 0;
rowIndex++;
}
}
else {
columnIndex++;
}
break;
case Location.UP:
if (!atFirstRow) {
rowIndex--;
}
break;
case Location.DOWN:
if (!atLastRow) {
// From the col header, we drop to the topmost fully visible row.
if (rowIndex === -1) {
rowIndex = grid.rowManager.firstFullyVisibleRow.dataIndex;
}
else {
rowIndex++;
}
}
break;
case Location.FIRST_COLUMN:
columnIndex = 0;
break;
case Location.LAST_COLUMN:
columnIndex = colMax;
break;
case Location.FIRST_CELL:
rowIndex = rowMin;
columnIndex = 0;
break;
case Location.LAST_CELL:
rowIndex = rowMax;
columnIndex = colMax;
break;
case Location.PREV_PAGE:
rowIndex = Math.max(rowMin, rowIndex - Math.floor(grid.scrollable.clientHeight / grid.rowHeight));
break;
case Location.NEXT_PAGE:
rowIndex = Math.min(rowMax, rowIndex + Math.floor(grid.scrollable.clientHeight / grid.rowHeight));
break;
}
// Set the calculated coordinates in the result.
result.init({
grid,
rowIndex,
columnIndex
});
return result;
}
/**
* The cell DOM element which this Location references.
* @property {HTMLElement}
* @readonly
*/
get cell() {
const
me = this,
{
grid,
id,
_cell
} = me;
// Property value set
if (_cell) {
return _cell;
}
// On a header cell
if (id == null) {
return grid.columns.getById(me.columnId)?.element;
}
else {
const { row } = me;
if (row) {
return row.getCell(me.columnId) || row.getCell(grid.columns.getAt(me.columnIndex)?.id);
}
}
}
get row() {
// Use our record ID by preference, but fall back to our row index if not present
return this.grid.getRowById(this.id) || this.grid.getRow(this.rowIndex);
}
/**
* The DOM element which encapsulates the focusable target of this Location.
*
* This is usually the {@link #property-cell}, but if this is an actionable location, this
* may be another DOM element within the cell.
* @property {HTMLElement}
* @readonly
*/
get target() {
const
{ cell, _target } = this,
{ focusableFinder } = this.grid;
// We might be asked for our focusElement before we're fully rendered and painted.
if (cell) {
// Location was created in disableActionable mode with the target
// explicitly directed to the cell.
if (_target) {
return _target;
}
focusableFinder.currentNode = this.grid.focusableFinderCell = cell;
return focusableFinder.nextNode() || cell;
}
}
/**
* This property is `true` if the focus target is not the cell itself.
* @property {Boolean}
* @readonly
*/
get isActionable() {
const
{ cell, _target } = this,
containsFocus = cell.compareDocumentPosition(DomHelper.getActiveElement(cell)) & Node.DOCUMENT_POSITION_CONTAINED_BY;
// The actual target may be inside the cell, or just positioned to *appear* inside the cell
// such as event/task rendering.
return Boolean(containsFocus || (_target && _target !== this.cell));
}
/**
* This property is `true` if this location represents a column header.
* @property {Boolean}
* @readonly
*/
get isColumnHeader() {
return this.cell && this.rowIndex === -1;
}
/**
* This property is `true` if this location represents a cell in the grid body.
* @property {Boolean}
* @readonly
*/
get isCell() {
return this.cell && this.record;
}
}
Location.UP = 1;
Location.NEXT_CELL = 2;
Location.DOWN = 3;
Location.PREV_CELL = 4;
Location.FIRST_COLUMN = 5;
Location.LAST_COLUMN = 6;
Location.FIRST_CELL = 7;
Location.LAST_CELL = 8;
Location.PREV_PAGE = 9;
Location.NEXT_PAGE = 10;
Grid's GridNavigation.js
needs to be this:
import Base from '../../../Core/Base.js';
import Rectangle from '../../../Core/helper/util/Rectangle.js';
import Location from '../../util/Location.js';
import DomHelper from '../../../Core/helper/DomHelper.js';
/**
* @module Grid/view/mixin/GridNavigation
*/
const
defaultFocusOptions = Object.freeze({}),
disableScrolling = Object.freeze({
x : false,
y : false
}),
containedFocusable = function(e) {
// When we step outside of the target cell, throw.
// The TreeWalker silences the exception and terminates the traverse.
if (!this.focusableFinderCell.contains(e)) {
return DomHelper.NodeFilter.FILTER_REJECT;
}
if (DomHelper.isFocusable(e) && !e.disabled) {
return DomHelper.NodeFilter.FILTER_ACCEPT;
}
return DomHelper.NodeFilter.FILTER_SKIP;
};
/**
* Mixin for Grid that handles cell to cell navigation.
*
* See {@link Grid.view.Grid} for more information on grid cell keyboard navigation.
*
* @mixin
*/
export default Target => class GridNavigation extends (Target || Base) {
static get $name() {
return 'GridNavigation';
}
static get configurable() {
return {
focusable : false,
focusableSelector : '.b-grid-cell,.b-grid-header.b-depth-0',
// Documented on Grid
keyMap : {
ArrowUp : { handler : 'navigateUpByKey', weight : 10 },
ArrowRight : { handler : 'navigateRightByKey', weight : 10 },
ArrowDown : { handler : 'navigateDownByKey', weight : 10 },
ArrowLeft : { handler : 'navigateLeftByKey', weight : 10 },
/* This configuration is needed at the moment because of Shift-key checks further up the road (in
* GridSelection). This makes these keyMaps strongly dependent of each other until this is refactored.
*/
'Shift+ArrowUp' : 'navigateUpByKey',
'Shift+ArrowDown' : 'navigateDownByKey',
'Ctrl+Home' : 'navigateFirstCell',
Home : 'navigateFirstColumn',
'Ctrl+End' : 'navigateLastCell',
End : 'navigateLastColumn',
PageUp : 'navigatePrevPage',
PageDown : 'navigateNextPage',
Enter : 'activateHeader',
// Private
Escape : 'onEscape',
'Shift+Tab' : { handler : 'onShiftTab', preventDefault : false },
Tab : { handler : 'onTab', preventDefault : false },
' ' : { handler : 'onSpace', preventDefault : false }
}
};
}
onStoreRecordIdChange(event) {
super.onStoreRecordIdChange?.(event);
const
{ focusedCell } = this,
{ oldValue, value } = event;
// https://github.com/bryntum/support/issues/4935
if (focusedCell && focusedCell.id === oldValue) {
focusedCell._id = value;
}
}
onElementMouseDown(event) {
// Cache this so that focusin handling can tell whether its a mousedown focus
// in which case it must be obeyed. If not, it's taken to be a TAB in and that
// must redirect to the last focused cell.
this.lastMousedownEvent = event;
super.onElementMouseDown(event);
}
/**
* Called by the RowManager when the row which contains the focus location is derendered.
*
* This keeps focus in a consistent place.
* @protected
*/
onFocusedRowDerender() {
const
me = this,
{ focusedCell } = me;
if (focusedCell?.id != null && focusedCell.cell) {
const isActive = focusedCell.cell.contains(DomHelper.getActiveElement(me));
if (me.hideHeaders) {
if (isActive) {
me.revertFocus();
}
}
else {
const headerContext = me.normalizeCellContext({
rowIndex : -1,
columnIndex : isActive ? focusedCell.columnIndex : 0
});
// The row contained focus, focus the corresponding header
if (isActive) {
me.focusCell(headerContext);
}
else {
headerContext.cell.tabIndex = 0;
}
}
focusedCell.cell.tabIndex = -1;
}
}
navigateUpByKey(keyEvent) {
this.navigationEvent = keyEvent;
this.focusCell(Location.UP);
}
navigateDownByKey(keyEvent) {
this.navigationEvent = keyEvent;
this.focusCell(Location.DOWN);
}
navigateLeftByKey(keyEvent) {
this.navigationEvent = keyEvent;
this.focusCell(this.rtl ? Location.NEXT_CELL : Location.PREV_CELL);
}
navigateRightByKey(keyEvent) {
this.navigationEvent = keyEvent;
this.focusCell(this.rtl ? Location.PREV_CELL : Location.NEXT_CELL);
}
navigateFirstCell(keyEvent) {
this.navigationEvent = keyEvent;
this.focusCell(Location.FIRST_CELL);
}
navigateFirstColumn(keyEvent) {
this.navigationEvent = keyEvent;
this.focusCell(Location.FIRST_COLUMN);
}
navigateLastCell(keyEvent) {
this.navigationEvent = keyEvent;
this.focusCell(Location.LAST_CELL);
}
navigateLastColumn(keyEvent) {
this.navigationEvent = keyEvent;
this.focusCell(Location.LAST_COLUMN);
}
navigatePrevPage(keyEvent) {
this.navigationEvent = keyEvent;
this.focusCell(Location.PREV_PAGE);
}
navigateNextPage(keyEvent) {
this.navigationEvent = keyEvent;
this.focusCell(Location.NEXT_PAGE);
}
activateHeader(keyEvent) {
this.navigationEvent = keyEvent;
if (keyEvent.target.classList.contains('b-grid-header') && this.focusedCell.isColumnHeader) {
const { column } = this.focusedCell;
column.onKeyDown?.(keyEvent);
this.getHeaderElement(column.id).click();
}
return false;
}
onEscape(keyEvent) {
const { focusedCell } = this;
this.navigationEvent = keyEvent;
if (!keyEvent.target.closest('.b-dragging') && focusedCell?.isActionable) {
// The escape must not be processed by handlers for the cell we are about to focus.
// We need to just push focus upwards to the cell, and stop there.
keyEvent.stopImmediatePropagation();
// To prevent the focusCell from being rejected as a no-op
this._focusedCell = null;
// Focus the cell with an explicit request to not jump in
this.focusCell({
rowIndex : focusedCell.rowIndex,
column : focusedCell.column
}, {
disableActionable : true
});
}
}
onTab(keyEvent) {
const
{ target } = keyEvent,
{
focusedCell,
bodyElement
} = this,
{
isActionable,
actionTargets
} = focusedCell,
isEditable = isActionable && DomHelper.isEditable(target) && !target.readOnly;
this.navigationEvent = keyEvent;
// If we're on the last editable in a cell, TAB navigates right
if (isEditable && target === actionTargets[actionTargets.length - 1]) {
keyEvent.preventDefault();
this.navigateRightByKey(keyEvent);
}
// If we're *on* a cell, or on last subtarget, TAB moves off the grid.
// Temporarily hide the grid body, and let TAB take effect from there
else if (!isActionable || target === actionTargets[actionTargets.length - 1]) {
bodyElement.style.display = 'none';
this.requestAnimationFrame(() => bodyElement.style.display = '');
// So that Navigator#onKeyDown does not continue to preventDefault;
return false;
}
}
onShiftTab(keyEvent) {
const
me = this,
{ target } = keyEvent,
{
focusedCell,
bodyElement
} = me,
{
cell,
isActionable,
actionTargets
} = focusedCell,
isEditable = isActionable && DomHelper.isEditable(target) && !target.readOnly,
onFirstCell = focusedCell.columnIndex === 0 && focusedCell.rowIndex === (me.hideHeaders ? 0 : -1);
me.navigationEvent = keyEvent;
// If we're on the first editable in a cell that is not the first cell, SHIFT+TAB navigates left
if (!onFirstCell && isEditable && target === actionTargets[0]) {
keyEvent.preventDefault();
me.navigateLeftByKey(keyEvent);
}
// If we're *on* a cell, or on first subtarget, SHIFT+TAB moves off the grid.
else if (!isActionable || target === actionTargets[0]) {
// Focus the first header cell and then let the key's default action take its course
const f = !onFirstCell && !me.hideHeaders && me.focusCell({
rowIndex : -1,
column : 0
}, {
disableActionable : true
});
// If that was successful then reset the tabIndex
if (f) {
f.cell.tabIndex = -1;
cell.tabIndex = 0;
me._focusedCell = focusedCell;
}
// Otherwise, temporarily hide the grid body, and let TAB take effect from there
else {
bodyElement.style.display = 'none';
me.requestAnimationFrame(() => bodyElement.style.display = '');
}
// So that Navigator#onKeyDown does not continue to preventDefault;
return false;
}
}
onSpace(keyEvent) {
// SPACE scrolls, so disable that
if (!this.focusedCell.isActionable) {
keyEvent.preventDefault();
}
// Return false to tell keyMap that any other actions should be called
return false;
}
//region Cell
/**
* Triggered when a user navigates to a grid cell
* @event navigate
* @param {Grid.view.Grid} grid The grid instance
* @param {Grid.util.Location} last The previously focused location
* @param {Grid.util.Location} location The new focused location
* @param {Event} [event] The UI event which caused navigation.
*/
/**
* Grid Location which encapsulates the currently focused cell.
* Set to focus a cell or use {@link #function-focusCell}.
* @property {Grid.util.Location}
*/
get focusedCell() {
return this._focusedCell;
}
/**
* This property is `true` if an element _within_ a cell is focused.
* @property {Boolean}
* @readonly
*/
get isActionableLocation() {
return this._focusedCell?.isActionable;
}
set focusedCell(cellSelector) {
this.focusCell(cellSelector);
}
get focusedRecord() {
return this._focusedCell?.record;
}
/**
* CSS selector for currently focused cell. Format is "[data-index=index] [data-column-id=columnId]".
* @property {String}
* @readonly
*/
get cellCSSSelector() {
const cell = this._focusedCell;
return cell ? `[data-index=${cell.rowIndex}] [data-column-id=${cell.columnId}]` : '';
}
afterHide() {
super.afterHide(...arguments);
// Do not scroll back to the last focused cell/last moused over cell upon reshow
this.lastFocusedCell = this.mouseMoveEvent = null;
}
/**
* Checks whether or not a cell is focused.
* @param {Object|String|Number} cellSelector Cell selector { id: x, columnId: xx } or row id
* @returns {Boolean} true if cell or row is focused, otherwise false
*/
isFocused(cellSelector) {
return Boolean(this._focusedCell?.equals(this.normalizeCellContext(cellSelector)));
}
get focusElement() {
if (!this.isDestroying) {
let focusCell;
// If the store is not empty, focusedCell can return the closest cell
if (this.store.count && this._focusedCell) {
focusCell = this._focusedCell.target;
}
// If the store is empty, or we have had no focusedCell set, focus a column header.
else {
focusCell = this.normalizeCellContext({
rowIndex : -1,
columnIndex : this._focusedCell?.columnIndex || 0
}).target;
}
const superFocusEl = super.focusElement;
// If there's no cell, or the Container's focus element is before the cell
// use the Container's focus element.
// For example, we may have a top toolbar.
if (superFocusEl && (!focusCell || focusCell.compareDocumentPosition(superFocusEl) === Node.DOCUMENT_POSITION_PRECEDING)) {
return superFocusEl;
}
return focusCell;
}
}
onPaint({ firstPaint }) {
const me = this;
super.onPaint?.(...arguments);
// Make the grid initally tabbable into.
// The first cell has to have the initial roving tabIndex set into it.
const defaultFocus = this.normalizeCellContext({
rowIndex : me.hideHeaders ? 0 : -1,
column : me.hideHeaders ? 0 : me.columns.find(col => col.isFocusable)
});
if (defaultFocus.cell) {
me._focusedCell = defaultFocus;
const { target } = defaultFocus;
// If cell doesn't contain a focusable target, it needs tabIndex 0.
if (target === defaultFocus.cell) {
defaultFocus.cell.tabIndex = 0;
}
}
}
/**
* This function handles focus moving into, or within the grid.
* @param {Event} focusEvent
* @private
*/
onGridBodyFocusIn(focusEvent) {
const
me = this,
{
bodyElement,
lastMousedownEvent,
navigationEvent
} = me,
event = navigationEvent || focusEvent,
lastFocusedCell = me.focusedCell,
lastTarget = lastFocusedCell?.initialTarget || lastFocusedCell?.target,
{
target,
relatedTarget
} = focusEvent,
targetCell = target.closest(me.focusableSelector);
me.navigationEvent = me.lastMousedownEvent = null;
// If focus moved into a valid cell...
if (targetCell) {
const
cellSelector = new Location(target),
{ cell } = cellSelector,
lastCell = lastFocusedCell?.cell,
actionTargets = cellSelector.actionTargets = me.findFocusables(targetCell),
// Don't select on focus on a contained actionable location
doSelect = ((Boolean(navigationEvent) || me.selectOnFocus) && target === cell);
// https://github.com/bryntum/support/issues/4039
// Only try focusing cell is current target cell is getting removed
if (!me.store.getById(targetCell.parentNode.dataset.id) && cell !== targetCell) {
cell.focus({ preventScroll : true });
return;
}
if (target.matches(me.focusableSelector)) {
if (me.disableActionable) {
cellSelector._target = cell;
}
// Focus first focusable target if we are configured to.
else if (actionTargets.length) {
me.navigationEvent = event;
actionTargets[0].focus();
return;
}
}
else {
// If we have tabbed in and *NOT* mousedowned in, and hit a tabbable element which was not our
// last focused cell, go back to last focused cell.
if (lastFocusedCell?.target && relatedTarget && (!lastMousedownEvent || !bodyElement.contains(lastMousedownEvent.target)) && !bodyElement.contains(relatedTarget) && !cellSelector.equals(lastFocusedCell)) {
lastTarget.focus();
return;
}
cellSelector._target = target;
}
if (lastCell) {
lastCell.classList.remove('b-focused');
lastCell.tabIndex = -1;
}
if (cell) {
cell.classList.add('b-focused');
// Column may update DOM on cell focus for A11Y purposes.
cellSelector.column.onCellFocus(cellSelector);
// Only switch the cell to be tabbable if focus was not directed to an inner focusable.
if (cell === target) {
cell.tabIndex = 0;
}
// Moving back to a cell from a cell-contained Editor
if (cell.contains(focusEvent.relatedTarget)) {
if (lastTarget === target) {
return;
}
}
}
//Remember
me._focusedCell = cellSelector;
me.onCellNavigate?.(me, lastFocusedCell, cellSelector, event, doSelect);
me.trigger('navigate', { lastFocusedCell, focusedCell : cellSelector, event });
//TODO: should be able to cancel selectcell from listeners
}
// Focus not moved into a valid cell, refocus last cell's target
// if there was a previously focused cell.
else {
lastTarget?.focus();
}
}
findFocusables(cell) {
const
{ focusableFinder } = this,
result = [];
focusableFinder.currentNode = this.focusableFinderCell = cell;
for (let focusable = focusableFinder.nextNode(); focusable; focusable = focusableFinder.nextNode()) {
result.push(focusable);
}
return result;
}
get focusableFinder() {
const me = this;
if (!me._focusableFinder) {
me._focusableFinder = me.setupTreeWalker(me.bodyElement, DomHelper.NodeFilter.SHOW_ELEMENT, {
acceptNode : containedFocusable.bind(me)
});
}
return me._focusableFinder;
}
/**
* Sets the passed record as the current focused record for keyboard navigation and selection purposes.
* This API is used by Combo to activate items in its picker.
* @param {Core.data.Model|Number|String} activeItem The record, or record index, or record id to highlight as the active ("focused") item.
* @internal
*/
restoreActiveItem(item = this._focusedCell) {
if (this.rowManager.count) {
// They sent a row number.
if (!isNaN(item)) {
item = this.store.getAt(item);
}
// Still not a record, treat it as a record ID.
else if (!item.isModel) {
item = this.store.getById(item);
}
return this.focusCell(item);
}
}
/**
* Navigates to a cell and/or its row (depending on selectionMode)
* @param {Object} cellSelector { id: rowId, columnId: 'columnId' }
* @param {Object} options Modifier options for how to deal with focusing the cell. These
* are used as the {@link Core.helper.util.Scroller#function-scrollTo} options.
* @param {Object|Boolean} [options.scroll=true] Pass `false` to not scroll the cell into view, or a
* scroll options object to affect the scroll.
* @returns {Grid.util.Location} A Location object representing the focused location.
* @fires navigate
*/
focusCell(cellSelector, options = defaultFocusOptions) {
const
me = this,
{ _focusedCell } = me,
{
scroll,
doSelect,
disableActionable
} = options;
// If we're being asked to go to a nonexistent header row, revert focus outwards
if (cellSelector?.rowIndex === -1 && me.hideHeaders) {
me.revertFocus();
return;
}
// Get a Grid Location.
// If the cellSelector is a number, it is taken to be a "relative" location as defined
// in the Location class eg Location.UP, and we move the current focus accordingly.
cellSelector = typeof cellSelector === 'number' && _focusedCell?.isLocation ? _focusedCell.move(cellSelector) : me.normalizeCellContext(cellSelector);
// Request is a no-op, but it's still a navigate request which selection processing needs to know about
if (cellSelector.equals(_focusedCell)) {
// doSelect only set if the cell is not actionable.
// If it can be interacted with, that interaction should not affect selection.
me.onCellNavigate?.(me, _focusedCell, cellSelector, me.navigationEvent, ('doSelect' in options) ? doSelect : !cellSelector.isActionable || cellSelector.initialTarget === cellSelector.cell);
return _focusedCell;
}
const
subGrid = me.getSubGridFromColumn(cellSelector.columnId),
{ cell } = cellSelector,
testCell = cell || me.getCell({
rowIndex : me.rowManager.topIndex,
columnId : cellSelector.columnId
}),
subGridRect = Rectangle.from(subGrid.element),
bodyRect = Rectangle.from(me.bodyElement),
cellRect = Rectangle.from(testCell).moveTo(null, subGridRect.y);
// No scrolling possible if we're movoing to a column header
if (scroll === false || cellSelector.rowIndex === -1) {
options = Object.assign({}, options, disableScrolling);
}
else {
options = Object.assign({}, options, scroll);
// If the test cell is larger than the subGrid, in any dimension, disable scrolling
if (cellRect.width > subGridRect.width || cellRect.height > bodyRect.height) {
options.x = options.y = false;
}
// Else ask for the column to be scrolled into view
else {
options.column = cellSelector.columnId;
}
me.scrollRowIntoView(cellSelector.id, options);
}
// Disable auto stepping into the focused cell.
me.disableActionable = disableActionable;
// Go through select pathway upon focus
me.selectOnFocus = doSelect;
// Focus the location's target, be it a cell, or an interior element.
// The onFocusIn element in this module responds to this.
cellSelector[disableActionable ? 'cell' : 'target']?.focus();
me.disableActionable = me.selectOnFocus = false;
return cellSelector;
}
blurCell(cellSelector) {
const me = this,
cell = me.getCell(cellSelector);
if (cell) {
cell.classList.remove('b-focused');
}
}
clearFocus(fullClear) {
const me = this;
if (me._focusedCell) {
// set last to have focus return to previous cell when alt tabbing
me.lastFocusedCell = fullClear ? null : me._focusedCell;
me.blurCell(me._focusedCell);
me._focusedCell = null;
}
}
/**
* Selects the cell before or after currently focused cell.
* @private
* @param next Specify true to select the next cell, false to select the previous
* @param {Event} [event] Optionally, the UI event which caused navigation.
* @returns {Object} Used cell selector
*/
internalNextPrevCell(next = true, event) {
const
me = this,
cellSelector = me._focusedCell;
if (cellSelector) {
me.navigationEvent = event;
return me.focusCell({
id : cellSelector.id,
columnId : me.columns.getAdjacentVisibleLeafColumn(cellSelector.columnId, next, true).id
}, {
doSelect : true,
event
});
}
return null;
}
/**
* Select the cell after the currently focused one.
* @param {Event} [event] Optionally, the UI event which caused navigation.
* @returns {Object} Cell selector
*/
navigateRight(event) {
return this.internalNextPrevCell(!this.rtl, event);
}
/**
* Select the cell before the currently focused one.
* @param {Event} [event] Optionally, the UI event which caused navigation.
* @returns {Object} Cell selector
*/
navigateLeft(event) {
return this.internalNextPrevCell(Boolean(this.rtl), event);
}
//endregion
//region Row
/**
* Selects the next or previous record in relation to the current selection. Scrolls into view if outside.
* @private
* @param next Next record (true) or previous (false)
* @param {Boolean} skipSpecialRows True to not return specialRows like headers
* @param {Event} [event] Optionally, the UI event which caused navigation.
* @returns {Object|Boolean} Selection context for the focused row (& cell) or false if no selection was made
*/
internalNextPrevRow(next, skipSpecialRows = true, event, moveToHeader = true) {
const
me = this,
cell = me._focusedCell;
if (!cell) return false;
const record = me.store[`get${next ? 'Next' : 'Prev'}`](cell.id, false, skipSpecialRows);
if (record) {
return me.focusCell({
id : record.id,
columnId : cell.columnId,
scroll : {
x : false
}
}, {
doSelect : true,
event
});
}
else if (!next && moveToHeader) {
this.clearFocus();
return this.getHeaderElement(cell.columnId).focus();
}
}
/**
* Navigates to the cell below the currently focused cell
* @param {Event} [event] Optionally, the UI event which caused navigation.
* @returns {Object} Selector for focused row (& cell)
*/
navigateDown(event) {
return this.internalNextPrevRow(true, false, event);
}
/**
* Navigates to the cell above the currently focused cell
* @param {Event} [event] Optionally, the UI event which caused navigation.
* @returns {Object} Selector for focused row (& cell)
*/
navigateUp(event) {
return this.internalNextPrevRow(false, true, event);
}
//endregion
// This does not need a className on Widgets.
// Each *Class* which doesn't need 'b-' + constructor.name.toLowerCase() automatically adding
// to the Widget it's mixed in to should implement thus.
get widgetClass() {}
};