diff --git a/e2e/testcafe-devextreme/tests/dataGrid/common/keyboardNavigation/keyboardNavigation.functional.ts b/e2e/testcafe-devextreme/tests/dataGrid/common/keyboardNavigation/keyboardNavigation.functional.ts index 36e67bb172d1..b40343483443 100644 --- a/e2e/testcafe-devextreme/tests/dataGrid/common/keyboardNavigation/keyboardNavigation.functional.ts +++ b/e2e/testcafe-devextreme/tests/dataGrid/common/keyboardNavigation/keyboardNavigation.functional.ts @@ -1340,6 +1340,90 @@ test('Tab key on the focused group row should be handled by default behavior (T8 }); }); +test('DataGrid - Focus should move back to headers on Shift+Tab when multiple grouping is applied (T1325775)', async (t) => { + const dataGrid = new DataGrid('#container'); + const headerRow = dataGrid.getHeaders().getHeaderRow(0); + const headerCell = headerRow.getHeaderCell(2); + const stateGroupRow = dataGrid.getGroupRow(0); + const cityGroupRow = dataGrid.getGroupRow(1); + const firstDataCell = dataGrid.getDataCell(2, 2); + + await t + .expect(dataGrid.isReady()) + .ok(); + + await addFocusableElementBefore('#container'); + + await t + .click(Selector('#focusable-start')) + .pressKey('tab') + .expect(headerCell.isFocused) + .ok() + + .pressKey('tab') + .expect(stateGroupRow.isFocused) + .ok() + + .pressKey('tab') + .expect(cityGroupRow.isFocused) + .ok() + + .pressKey('tab') + .expect(firstDataCell.isFocused) + .ok() + + .pressKey('shift+tab') + .expect(cityGroupRow.isFocused) + .ok() + + .pressKey('shift+tab') + .expect(stateGroupRow.isFocused) + .ok() + + .pressKey('shift+tab') + .expect(headerCell.isFocused) + .ok(); +}).before(async () => { + await createWidget('dxDataGrid', { + showBorders: true, + keyExpr: 'id', + dataSource: [ + { + id: 1, + state: 'Arkansas', + city: 'Bentonville', + company: 'Super Mart of the West', + }, + { + id: 2, + state: 'Arkansas', + city: 'Bentonville', + company: 'Electronics Depot', + }, + { + id: 3, + state: 'California', + city: 'Cupertino', + company: 'Braeburn', + }, + ], + grouping: { + autoExpandAll: true, + }, + columns: [ + { + dataField: 'state', + groupIndex: 0, + }, + { + dataField: 'city', + groupIndex: 1, + }, + 'company', + ], + }); +}); + test('Row should not be focused by "focusedRowIndex" after change "pageIndex" by pager if "autoNavigateToFocused" row is false', async (t) => { const dataGrid = new DataGrid('#container'); await t diff --git a/packages/devextreme/js/__internal/grids/grid_core/focus/m_focus.ts b/packages/devextreme/js/__internal/grids/grid_core/focus/m_focus.ts index 0535c912428e..34b6e66936b3 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/focus/m_focus.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/focus/m_focus.ts @@ -478,7 +478,7 @@ const keyboardNavigation = (Base: ModuleType) => c } if (this.isCellFocusType()) { this.setRowFocusType(); - this._focus(this._getCellElementFromTarget(eventArgs.originalEvent.target), true); + this._focus(this.getCellElementFromTarget(eventArgs.originalEvent.target), true); return true; } diff --git a/packages/devextreme/js/__internal/grids/grid_core/keyboard_navigation/m_keyboard_navigation.ts b/packages/devextreme/js/__internal/grids/grid_core/keyboard_navigation/m_keyboard_navigation.ts index d860e27ac746..a08acb2aee7b 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/keyboard_navigation/m_keyboard_navigation.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/keyboard_navigation/m_keyboard_navigation.ts @@ -448,50 +448,70 @@ export class KeyboardNavigationController extends KeyboardNavigationControllerCo // #region Key_Handlers + /** + * Executes tab key navigation logic. + * In editing mode — moves editing to the next/previous cell. + * In normal mode — moves focus to the next/previous cell. + * Falls back to browser's native tab behavior when focus should leave the grid. + */ private executeTabKey( event: KeyDownEvent, options: { - editingOptions: any; + hasEditingOptions: boolean; isLastValidCell: boolean; isOriginalHandlerRequired: boolean; }, - ) { - const isEditing = this._editingController.isEditing(); - const direction = event.shift ? 'previous' : 'next'; + ): void { + const isEditing: boolean = this._editingController.isEditing(); + const direction: NavigationDirection = event.shift ? 'previous' : 'next'; const eventTarget = event.originalEvent.target as Element; - const { editingOptions, isLastValidCell } = options; + const { hasEditingOptions, isLastValidCell } = options; let originalHandlerRequired = options.isOriginalHandlerRequired; - if (editingOptions && eventTarget && !originalHandlerRequired) { + // Try to handle tab navigation within the grid + if (hasEditingOptions && eventTarget && !originalHandlerRequired) { if (isEditing) { - if (!this._editingCellTabHandler(event, direction)) { + // In editing mode: navigate to the next editable cell. + // If the handler returns false, stop grid handling and fall back to native Tab behavior. + if (!this.editingCellTabHandler(event, direction)) { return; } - } else if (this._targetCellTabHandler(event, direction)) { + } else if (this.targetCellTabHandler(event, direction)) { + // In normal mode: if handler signals that native tab is required, + // let the browser handle focus transition out of the grid. originalHandlerRequired = true; } } + // Let the browser handle tab natively — focus leaves the grid if (originalHandlerRequired) { - const $cell = this._getFocusedCell(); - const isCommandCell = $cell.is(COMMAND_CELL_SELECTOR); - - if (isLastValidCell && !isCommandCell) { - this._toggleInertAttr(true); - } + this.handleNativeTabOut(isLastValidCell); + return; + } - this._editorFactory.loseFocus(); + // Tab was handled by grid navigation — suppress browser default + event.originalEvent.preventDefault(); + } - if (this._editingController.isEditing() && !this._isRowEditMode()) { - this._resetFocusedCell(true); - this._resetFocusedView(); - this._closeEditCell(); - } + /** + * Handles the case when Tab should leave the grid to the next/previous focusable element. + * Cleans up editing state if the grid is in cell/batch editing mode. + */ + private handleNativeTabOut(isLastValidCell: boolean): void { + const $cell = this._getFocusedCell(); + const isCommandCell = $cell.is(COMMAND_CELL_SELECTOR); - return; + if (isLastValidCell && !isCommandCell) { + this._toggleInertAttr(true); } - event.originalEvent.preventDefault(); + this._editorFactory.loseFocus(); + + if (this._editingController.isEditing() && !this._isRowEditMode()) { + this._resetFocusedCell(true); + this._resetFocusedView(); + this._closeEditCell(); + } } protected keyDownHandler(e) { @@ -550,7 +570,7 @@ export class KeyboardNavigationController extends KeyboardNavigationControllerCo break; case 'tab': - this._tabKeyHandler(e); + this.tabKeyHandler(e); isHandled = true; break; @@ -768,54 +788,62 @@ export class KeyboardNavigationController extends KeyboardNavigationControllerCo // eslint-disable-next-line @typescript-eslint/no-unused-vars protected _toggleInertAttr(value: boolean): void {} - protected _tabKeyHandler(event: KeyDownEvent) { - const direction = event.shift ? 'previous' : 'next'; + /** + * Handles Tab / Shift+Tab key press. + * Determines navigation direction, checks boundary conditions, + * and delegates to `executeTabKey` for the actual focus transition. + */ + protected tabKeyHandler(event: KeyDownEvent): void { + const direction: NavigationDirection = event.shift ? 'previous' : 'next'; const eventTarget = event.originalEvent.target as Element; - const focusedViewElement = this._focusedView && this._focusedView.element(); - const editingOptions = this.option('editing'); - const hasFocusedCellPosition = isDefined(this._focusedCellPosition) - && !isEmptyObject(this._focusedCellPosition); - const isFirstValidCell = event.shift && hasFocusedCellPosition - ? this._isFirstValidCell(this._focusedCellPosition) - : false; - const isLastValidCell = !event.shift && hasFocusedCellPosition - ? this._isLastValidCell(this._focusedCellPosition) - : false; - const isRowsViewElement = $(eventTarget).hasClass(this.addWidgetPrefix(ROWS_VIEW_CLASS)); - const isOriginalHandlerRequired = !hasFocusedCellPosition - || isFirstValidCell - || isLastValidCell; - const canHandleEditing = editingOptions - && eventTarget - && !isOriginalHandlerRequired; - const shouldResetFocusedCell = canHandleEditing && isRowsViewElement; - const shouldProcessVirtualPosition = canHandleEditing && this._isVirtualColumnRender(); - const options = { - editingOptions, - isLastValidCell, - isOriginalHandlerRequired, - }; - if (this._handleTabKeyOnMasterDetailCell(eventTarget, direction)) { + // Master-detail cells use their own tab navigation logic + if (this.handleTabKeyOnMasterDetailCell(eventTarget, direction)) { return; } - $(focusedViewElement).addClass(FOCUS_STATE_CLASS); + $(this._focusedView?.element()).addClass(FOCUS_STATE_CLASS); + + const { isLastValidCell, isOriginalHandlerRequired } = this.getTabBoundaryInfo(event); - if (shouldResetFocusedCell) { + const hasEditingOptions = !!this.option('editing'); + const canHandleNavigation: boolean = hasEditingOptions + && !!eventTarget && !isOriginalHandlerRequired; + + // Reset focused cell when the rows view container itself is focused (not a specific cell) + if (canHandleNavigation && $(eventTarget).hasClass(this.addWidgetPrefix(ROWS_VIEW_CLASS))) { this._resetFocusedCell(); } - if (shouldProcessVirtualPosition) { - this._processVirtualHorizontalPosition(direction, event) - .done(() => { - this.executeTabKey(event, options); - }); + const tabOptions = { hasEditingOptions, isLastValidCell, isOriginalHandlerRequired }; + // Virtual columns require horizontal scrolling before executing tab navigation + if (canHandleNavigation && this._isVirtualColumnRender()) { + this._processVirtualHorizontalPosition(direction, event) + .done(() => this.executeTabKey(event, tabOptions)); return; } - this.executeTabKey(event, options); + this.executeTabKey(event, tabOptions); + } + + /** + * Determines whether the focused cell is at the boundary of the grid. + * When at the boundary, Tab/Shift+Tab should let focus leave the grid. + */ + private getTabBoundaryInfo(event: KeyDownEvent): { + isLastValidCell: boolean; + isOriginalHandlerRequired: boolean; + } { + const hasFocusedCellPosition: boolean = isDefined(this._focusedCellPosition) + && !isEmptyObject(this._focusedCellPosition); + const isLastValidCell: boolean = !event.shift && hasFocusedCellPosition + && this.isLastValidCell(this._focusedCellPosition); + const isOriginalHandlerRequired: boolean = !hasFocusedCellPosition + || (event.shift && this.isFirstValidCell(this._focusedCellPosition)) + || isLastValidCell; + + return { isLastValidCell, isOriginalHandlerRequired }; } private _getMaxVerticalOffset() { @@ -936,86 +964,128 @@ export class KeyboardNavigationController extends KeyboardNavigationControllerCo return Deferred().resolve().promise(); } - private _editingCellTabHandler(eventArgs, direction) { - const eventTarget = eventArgs.originalEvent.target; - const $targetCell = this._getCellElementFromTarget(eventTarget); - const isCommandCell = $targetCell.is(COMMAND_CELL_SELECTOR); + /** + * Handles Tab key when a cell is being edited. + * Moves focus and editing to the next/previous editable cell. + * Returns true if tab was handled and further processing may be needed, + * false if tab is fully handled or should fall through to native behavior. + */ + private editingCellTabHandler(eventArgs: KeyDownEvent, direction: NavigationDirection): boolean { + const eventTarget = eventArgs.originalEvent.target as Element; + const $targetCell: dxElementWrapper = this.getCellElementFromTarget(eventTarget); + // If focus is on an intermediate interactive element inside the cell, + // let the browser cycle through elements within the cell first if (this.isOriginalTabHandlerRequired($targetCell, eventArgs)) { return false; } - if (isCommandCell) { - return !this._targetCellTabHandler(eventArgs, direction); + // Command cells (e.g. edit/delete buttons) delegate to non-editing tab handler + if ($targetCell.is(COMMAND_CELL_SELECTOR)) { + return !this.targetCellTabHandler(eventArgs, direction); } this._updateFocusedCellPosition($targetCell); - const elementType = this._getElementType(eventTarget); - const nextCellInfo = this._getNextCellByTabKey( + const elementType: NavigationElementType = this._getElementType(eventTarget); + const nextCellInfo = this.getNextCellByTabKey( eventArgs.originalEvent, direction, elementType, ); - const $nextCell = nextCellInfo.$cell; + const $nextCell: dxElementWrapper | undefined = nextCellInfo.$cell; - if (!$nextCell || this._handleTabKeyOnMasterDetailCell($nextCell, direction)) { + if (!$nextCell || this.handleTabKeyOnMasterDetailCell($nextCell, direction)) { return false; } - let isEditingAllowed = false; - const column = this._getColumnByCellElement($nextCell); + this.focusAndEditNextCell($nextCell, nextCellInfo.isHighlighted, eventArgs.shift); - if (column?.allowEditing) { - const $row = $nextCell.parent(); - const rowIndex = this._getLocalRowIndex($row); - const row = this._dataController.items()[rowIndex] as any; - const isDataRow = !row || row.rowType === 'data'; + return true; + } - isEditingAllowed = this._editingController.allowUpdating({ row }) - ? isDataRow : row?.isNewRow; - } + /** + * Focuses the next cell and starts editing if allowed. + * Closes the current editor if the next cell is not editable. + */ + private focusAndEditNextCell( + $nextCell: dxElementWrapper, + isHighlighted: boolean | undefined, + isShift: boolean, + ): void { + const isEditingAllowed: boolean = this.canEditNextCell($nextCell); if (!isEditingAllowed) { this._closeEditCell(); } - const nextCellFocused = this._focusCell($nextCell, !nextCellInfo.isHighlighted); + const nextCellFocused = this._focusCell($nextCell, !isHighlighted); if (nextCellFocused) { - const isRowMode = this._isRowEditMode(); - if (!isRowMode && isEditingAllowed) { + if (!this._isRowEditMode() && isEditingAllowed) { this._editFocusedCell(); } else { - this._focusInteractiveElement($nextCell, eventArgs.shift); + this._focusInteractiveElement($nextCell, isShift); } } + } - return true; + /** + * Checks whether editing is allowed in the given cell + * based on the column's `allowEditing` flag and the row's editing permissions. + */ + private canEditNextCell($cell: dxElementWrapper): boolean { + const column = this._getColumnByCellElement($cell); + + if (!column?.allowEditing) { + return false; + } + + const $row: dxElementWrapper = $cell.parent(); + const rowIndex: number = this._getLocalRowIndex($row); + const row = this._dataController.items()[rowIndex]; + const isRowData: boolean = !row || row.rowType === 'data'; + + return this._editingController.allowUpdating({ row }) + ? isRowData : !!(row as any)?.isNewRow; } + /** + * Checks whether native browser tab behavior should be used. + * Returns true when focus is on an interactive element inside a cell + * that is not the boundary (last for Tab, first for Shift+Tab) interactive element, + * meaning tab should cycle through elements within the cell first. + */ private isOriginalTabHandlerRequired($cell: dxElementWrapper, event: KeyDownEvent): boolean { const eventTarget = event.originalEvent.target; const elementType = this._getElementType(eventTarget); - const $lastInteractiveElement = getInteractiveElement($cell, !event.shift); + const $boundaryInteractiveElement = getInteractiveElement($cell, !event.shift); - if (elementType !== 'cell' || $lastInteractiveElement.length === 0) { + if (elementType !== 'cell' || $boundaryInteractiveElement.length === 0) { return false; } - return eventTarget !== $lastInteractiveElement.get(0); + return eventTarget !== $boundaryInteractiveElement.get(0); } - private _targetCellTabHandler(eventArgs, direction) { - const $event = eventArgs.originalEvent; - let eventTarget = $event.target; - let elementType = this._getElementType(eventTarget); - let $cell = this._getCellElementFromTarget(eventTarget); + /** + * Handles Tab key on a cell that is not being edited. + * Moves focus to the next/previous cell and focuses its interactive elements. + * Returns true if original (browser) tab behavior is required, + * false if navigation was handled within the grid. + */ + private targetCellTabHandler(eventArgs: KeyDownEvent, direction: NavigationDirection): boolean { + const $event: KeyboardEvent = eventArgs.originalEvent; + let eventTarget = $event.target as Element; + let elementType: NavigationElementType = this._getElementType(eventTarget); + const $cell: dxElementWrapper = this.getCellElementFromTarget(eventTarget); + // Non-editor cells with intermediate interactive elements use native tab if (!isEditorCell(this, $cell) && this.isOriginalTabHandlerRequired($cell, eventArgs)) { return true; } + // Initialize focused position when it hasn't been set yet if ( this._focusedCellPosition.rowIndex === undefined && $(eventTarget).hasClass(ROW_CLASS) @@ -1025,69 +1095,100 @@ export class KeyboardNavigationController extends KeyboardNavigationControllerCo if (this.isRowFocusType()) { this.setCellFocusType(); - if (elementType === 'row' && isDataRow($(eventTarget))) { - eventTarget = this.getFirstValidCellInRow($(eventTarget)); - elementType = this._getElementType(eventTarget); - } + ({ target: eventTarget, elementType } = this.getCellFocusInfo(eventTarget, elementType)); } - const nextCellInfo = this._getNextCellByTabKey( - $event, - direction, - elementType, - ); - $cell = nextCellInfo.$cell; + const nextCellInfo = this.getNextCellByTabKey($event, direction, elementType); - if (!$cell) { + if (!nextCellInfo.$cell) { return false; } - $cell = this._checkNewLineTransition($event, $cell); - if (!$cell) { + // Handle row transition — fires focusedRowChanging event + const $newFocusedCell: dxElementWrapper | undefined = this.checkNewLineTransition( + $event, + nextCellInfo.$cell, + ); + + if (!$newFocusedCell) { return false; } - this._focusCell($cell, !nextCellInfo.isHighlighted); + this._focusCell($newFocusedCell, !nextCellInfo.isHighlighted); - if (!isEditorCell(this, $cell)) { - this._focusInteractiveElement($cell, eventArgs.shift); + if (!isEditorCell(this, $newFocusedCell)) { + this._focusInteractiveElement($newFocusedCell, eventArgs.shift); } return false; } - private _getNextCellByTabKey($event, direction, elementType) { - let $cell = this._getNextCell(direction, elementType); + /** + * Switches from row focus mode to cell focus mode when needed. + * If the current target is a data row, resolves the first valid cell as the new target. + */ + private getCellFocusInfo( + target: Element, + elementType: NavigationElementType, + ): { target: Element; elementType: NavigationElementType } { + if (elementType === 'row' && isDataRow($(target))) { + const cellTarget = this.getFirstValidCellInRow($(target)); + + return { + target: cellTarget, + elementType: this._getElementType(cellTarget), + }; + } + return { target, elementType }; + } + + /** + * Finds the next cell for tab navigation and fires the `onFocusedCellChanging` event. + * Returns `{ $cell, isHighlighted }` or an empty object if navigation was canceled. + */ + private getNextCellByTabKey( + $event: KeyboardEvent, + direction: NavigationDirection, + elementType: NavigationElementType, + ): { $cell?: dxElementWrapper; isHighlighted?: boolean } { + const $cell = this._getNextCell(direction, elementType); const args = $cell && this._fireFocusedCellChanging($event, $cell, true); if (!args || args.cancel) { return {}; } - if (args.$newCellElement) { - $cell = args.$newCellElement; - } - return { - $cell, + $cell: args.$newCellElement ?? $cell, isHighlighted: args.isHighlighted, }; } - private _checkNewLineTransition($event, $cell) { - const rowIndex = this.getVisibleRowIndex(); - const $row = $cell.parent(); + /** + * Handles the row transition during tab navigation. + * When the next cell belongs to a different row, fires the `onFocusedRowChanging` event. + * Returns the target cell element, or undefined if the transition was canceled. + */ + private checkNewLineTransition( + $event: KeyboardEvent, + $cell: dxElementWrapper, + ): dxElementWrapper | undefined { + const rowIndex: number = this.getVisibleRowIndex(); + const $row: dxElementWrapper = $cell.parent(); if (rowIndex !== this._getRowIndex($row)) { const cellPosition = this._getCellPosition($cell); const args = this._fireFocusedRowChanging($event, $row); + if (args.cancel) { return; } + if (args.rowIndexChanged && cellPosition) { this.setFocusedColumnIndex(cellPosition.columnIndex); - $cell = this._getFocusedCell(); + + return this._getFocusedCell(); } } @@ -1137,7 +1238,7 @@ export class KeyboardNavigationController extends KeyboardNavigationControllerCo if (isEditing || (!allowEditingOnEnterKey && direction)) { this._handleEnterKeyEditingCell(eventArgs.originalEvent).done(() => { if (direction === 'next' || direction === 'previous') { - this._targetCellTabHandler(eventArgs, direction); + this.targetCellTabHandler(eventArgs, direction); } else if (direction === 'upArrow' || direction === 'downArrow') { this._navigateNextCell(eventArgs.originalEvent, direction); } @@ -1166,7 +1267,7 @@ export class KeyboardNavigationController extends KeyboardNavigationControllerCo private _handleEnterKeyEditingCell(event) { const d = Deferred(); const { target } = event; - const $cell = this._getCellElementFromTarget(target); + const $cell = this.getCellElementFromTarget(target); const isRowEditMode = this._isRowEditMode(); this._updateFocusedCellPosition($cell); @@ -1192,7 +1293,7 @@ export class KeyboardNavigationController extends KeyboardNavigationControllerCo * @extended */ protected _escapeKeyHandler(eventArgs, isEditing): boolean { - const $cell = this._getCellElementFromTarget( + const $cell = this.getCellElementFromTarget( eventArgs.originalEvent.target, ); if (isEditing) { @@ -1781,7 +1882,7 @@ export class KeyboardNavigationController extends KeyboardNavigationControllerCo && isGroupRow($row) && this.setFocusedRowIndex(this._getRowIndex($row)); } else { - this._updateFocusedCellPosition(this._getCellElementFromTarget(target)); + this._updateFocusedCellPosition(this.getCellElementFromTarget(target)); } } @@ -1886,7 +1987,7 @@ export class KeyboardNavigationController extends KeyboardNavigationControllerCo if ( columnIndex > 0 && elementType !== 'row' - && this._hasValidCellBeforePosition({ columnIndex, rowIndex }) + && this.hasValidCellBeforePosition({ columnIndex, rowIndex }) ) { columnIndex--; } else if (rowIndex > 0 && code === 'previous') { @@ -1953,17 +2054,19 @@ export class KeyboardNavigationController extends KeyboardNavigationControllerCo return rowIndex === lastVisibleIndex; } - protected _isFirstValidCell(cellPosition) { - let isFirstValidCell = false; + protected isFirstValidCell(cellPosition) { + if (cellPosition.rowIndex !== 0) { + return false; + } - if (cellPosition.rowIndex === 0 && cellPosition.columnIndex >= 0) { - isFirstValidCell = isFirstValidCell || !this._hasValidCellBeforePosition(cellPosition); + if (this.isFullRowFocusType(cellPosition.rowIndex) && cellPosition.columnIndex > 0) { + return true; } - return isFirstValidCell; + return cellPosition.columnIndex >= 0 && !this.hasValidCellBeforePosition(cellPosition); } - private _hasValidCellBeforePosition(cellPosition) { + private hasValidCellBeforePosition(cellPosition) { let { columnIndex } = cellPosition; let hasValidCells = false; @@ -1994,23 +2097,14 @@ export class KeyboardNavigationController extends KeyboardNavigationControllerCo return hasValidCells; } - protected _isLastValidCell(cellPosition) { - const nextColumnIndex = cellPosition.columnIndex >= 0 ? cellPosition.columnIndex + 1 : 0; + protected isLastValidCell(cellPosition) { const { rowIndex } = cellPosition; - const checkingPosition = { - columnIndex: nextColumnIndex, - rowIndex, - }; - const visibleRows = this._dataController.getVisibleRows(); - const row = visibleRows && visibleRows[rowIndex]; - const isLastRow = this._isLastRow(rowIndex); - if (!isLastRow) { + if (!this._isLastRow(rowIndex)) { return false; } - const isFullRowFocus = row?.rowType === 'group' || row?.rowType === 'groupFooter'; - if (isFullRowFocus && cellPosition.columnIndex > 0) { + if (this.isFullRowFocusType(rowIndex) && cellPosition.columnIndex > 0) { return true; } @@ -2018,11 +2112,21 @@ export class KeyboardNavigationController extends KeyboardNavigationControllerCo return true; } + const nextColumnIndex = cellPosition.columnIndex >= 0 ? cellPosition.columnIndex + 1 : 0; + const checkingPosition = { columnIndex: nextColumnIndex, rowIndex }; + if (this._isCellByPositionValid(checkingPosition)) { return false; } - return this._isLastValidCell(checkingPosition); + return this.isLastValidCell(checkingPosition); + } + + private isFullRowFocusType(rowIndex: number): boolean { + const visibleRows = this._dataController.getVisibleRows(); + const row = visibleRows && visibleRows[rowIndex]; + + return row?.rowType === 'group' || row?.rowType === 'groupFooter'; } // #endregion Cell_Position @@ -2165,8 +2269,8 @@ export class KeyboardNavigationController extends KeyboardNavigationControllerCo const isRowFocus = this.isRowFocusType(); const includeCommandCells = isRowFocus || keyCode === 'next' || keyCode === 'previous'; const isBoundaryCell = keyCode === 'previous' - ? this._isFirstValidCell(position) - : this._isLastValidCell(position); + ? this.isFirstValidCell(position) + : this.isLastValidCell(position); if (!this._isCellInRow(position, includeCommandCells) || isBoundaryCell) { return $cell; @@ -2679,7 +2783,14 @@ export class KeyboardNavigationController extends KeyboardNavigationControllerCo } } - private _handleTabKeyOnMasterDetailCell(target, direction) { + /** + * Handles tab navigation when focus is on or entering a master-detail cell. + * Returns true if the master-detail handling was applied. + */ + private handleTabKeyOnMasterDetailCell( + target: Element | dxElementWrapper, + direction: NavigationDirection, + ): boolean { if (this.getMasterDetailCell(target)) { this._updateFocusedCellPosition($(target), direction); @@ -2719,16 +2830,15 @@ export class KeyboardNavigationController extends KeyboardNavigationControllerCo return $element.length && $element[0].tagName === 'TD'; } - public _getCellElementFromTarget(target) { + public getCellElementFromTarget(target: Element): dxElementWrapper { const elementType = this._getElementType(target); const $targetElement = $(target); - let $cell; + if (elementType === 'cell') { - $cell = $targetElement.closest(`.${ROW_CLASS} > td`); - } else { - $cell = $targetElement.children().not(`.${COMMAND_EXPAND_CLASS}`).first(); + return $targetElement.closest(`.${ROW_CLASS} > td`); } - return $cell; + + return $targetElement.children().not(`.${COMMAND_EXPAND_CLASS}`).first(); } private _getRowsViewElement() { diff --git a/packages/devextreme/js/__internal/grids/grid_core/keyboard_navigation/scrollable_a11y.ts b/packages/devextreme/js/__internal/grids/grid_core/keyboard_navigation/scrollable_a11y.ts index bb43aa7d964b..0a7087cb9ede 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/keyboard_navigation/scrollable_a11y.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/keyboard_navigation/scrollable_a11y.ts @@ -67,19 +67,19 @@ export const keyboardNavigationScrollableA11yExtender = (Base: ModuleType { + const editingCellTabHandler = this.keyboardNavigationController.editingCellTabHandler; + this.keyboardNavigationController.editingCellTabHandler = (eventArgs, direction) => { const $target = $(eventArgs.originalEvent.target); - const result = _editingCellTabHandler.bind(this.keyboardNavigationController)(eventArgs, direction); + const result = editingCellTabHandler.bind(this.keyboardNavigationController)(eventArgs, direction); if($target.hasClass('dx-link')) { assert.equal(result, eventArgs.shift ? $target.index() === 0 : $target.index() === 1, 'need default behavior'); @@ -163,13 +163,13 @@ QUnit.module('Keyboard navigation accessibility', { this.triggerKeyDown('tab', false, false, $(this.getCellElement(1, 2)).find('.dx-link').first().get(0)); // assert - assert.equal(counter, 1, '_editingCellTabHandler counter'); + assert.equal(counter, 1, 'editingCellTabHandler counter'); // act this.triggerKeyDown('tab', false, false, $(this.getCellElement(1, 2)).find('.dx-link').last().get(0)); // assert - assert.equal(counter, 2, '_editingCellTabHandler counter'); + assert.equal(counter, 2, 'editingCellTabHandler counter'); assert.ok($('#qunit-fixture').find(':focus').is('input'), 'focused element'); assert.equal($('#qunit-fixture').find(':focus').closest('td').index(), 3, 'focused element index'); @@ -184,13 +184,13 @@ QUnit.module('Keyboard navigation accessibility', { this.triggerKeyDown('tab', false, true, $(this.getCellElement(1, 2)).find('.dx-link').last().get(0)); // assert - assert.equal(counter, 3, '_editingCellTabHandler counter'); + assert.equal(counter, 3, 'editingCellTabHandler counter'); // act this.triggerKeyDown('tab', false, true, $(this.getCellElement(1, 2)).find('.dx-link').first().get(0)); // assert - assert.equal(counter, 4, '_editingCellTabHandler counter'); + assert.equal(counter, 4, 'editingCellTabHandler counter'); }); // T741590 diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/keyboardNavigation.keyboardKeys.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/keyboardNavigation.keyboardKeys.tests.js index 18ea6a590091..f8b54bd9d2ca 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/keyboardNavigation.keyboardKeys.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/keyboardNavigation.keyboardKeys.tests.js @@ -1679,7 +1679,7 @@ QUnit.module('Keyboard keys', { this.gridView.render($('#container')); this.editingController.editCell(0, 1); this.keyboardNavigationController._focusedCellPosition = { columnIndex: 0, rowIndex: 1 }; - this.keyboardNavigationController._tabKeyHandler({ + this.keyboardNavigationController.tabKeyHandler({ originalEvent: { target: $('#container').find('.txt').first(), preventDefault: commonUtils.noop