diff --git a/documentation/ag-grid-docs/src/content/api-documentation/grid-options/properties.json b/documentation/ag-grid-docs/src/content/api-documentation/grid-options/properties.json index 04b5eabb67b..b0c2fa79988 100644 --- a/documentation/ag-grid-docs/src/content/api-documentation/grid-options/properties.json +++ b/documentation/ag-grid-docs/src/content/api-documentation/grid-options/properties.json @@ -990,6 +990,12 @@ "name": "Custom Navigation", "url": "./keyboard-navigation/#custom-navigation" } + }, + "tabToNextGridContainer": { + "more": { + "name": "Custom Navigation", + "url": "./keyboard-navigation/#custom-navigation" + } } }, "pagination": { diff --git a/documentation/ag-grid-docs/src/content/docs/keyboard-navigation/_examples/custom-tabbing-into-grid/index.html b/documentation/ag-grid-docs/src/content/docs/keyboard-navigation/_examples/custom-tabbing-into-grid/index.html index bacd1806692..69f61908427 100644 --- a/documentation/ag-grid-docs/src/content/docs/keyboard-navigation/_examples/custom-tabbing-into-grid/index.html +++ b/documentation/ag-grid-docs/src/content/docs/keyboard-navigation/_examples/custom-tabbing-into-grid/index.html @@ -3,7 +3,7 @@
@@ -11,7 +11,7 @@
diff --git a/documentation/ag-grid-docs/src/content/docs/keyboard-navigation/_examples/custom-tabbing-into-grid/provided/reactFunctionalTs/index.tsx b/documentation/ag-grid-docs/src/content/docs/keyboard-navigation/_examples/custom-tabbing-into-grid/provided/reactFunctionalTs/index.tsx deleted file mode 100644 index 02587575558..00000000000 --- a/documentation/ag-grid-docs/src/content/docs/keyboard-navigation/_examples/custom-tabbing-into-grid/provided/reactFunctionalTs/index.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import React, { StrictMode, useMemo, useState } from 'react'; -import { createRoot } from 'react-dom/client'; - -import type { - CellFocusedParams, - ColDef, - Column, - ColumnGroup, - FocusGridInnerElementParams, - GridApi, - HeaderFocusedParams, -} from 'ag-grid-community'; -import { - ClientSideRowModelModule, - NumberEditorModule, - NumberFilterModule, - TextEditorModule, - TextFilterModule, - ValidationModule, -} from 'ag-grid-community'; -import { AgGridProvider, AgGridReact } from 'ag-grid-react'; - -import './styles.css'; - -const modules = [ - NumberEditorModule, - TextEditorModule, - TextFilterModule, - NumberFilterModule, - ClientSideRowModelModule, - ...(process.env.NODE_ENV !== 'production' ? [ValidationModule] : []), -]; - -const GridExample = () => { - const [lastFocused, setLastFocused] = useState< - { column: string | Column | ColumnGroup | null; rowIndex?: number | null } | undefined - >(); - - const columnDefs = useMemo( - () => [ - { - headerName: '#', - colId: 'rowNum', - valueGetter: 'node.id', - }, - { - field: 'athlete', - minWidth: 170, - }, - { field: 'age' }, - { field: 'country' }, - { field: 'year' }, - { field: 'date' }, - { field: 'sport' }, - { field: 'gold' }, - { field: 'silver' }, - { field: 'bronze' }, - { field: 'total' }, - ], - [] - ); - - const { data, loading } = useFetchJson('https://www.ag-grid.com/example-assets/olympic-winners.json'); - - const onCellFocused = (params: CellFocusedParams) => { - setLastFocused({ column: params.column, rowIndex: params.rowIndex }); - }; - - const onHeaderFocused = (params: HeaderFocusedParams) => { - setLastFocused({ column: params.column, rowIndex: null }); - }; - - const focusGridInnerElement = (params: FocusGridInnerElementParams) => { - if (!lastFocused || !lastFocused.column) { - return false; - } - - if (lastFocused.rowIndex != null) { - params.api.setFocusedCell(lastFocused.rowIndex, lastFocused.column as Column | string); - } else { - params.api.setFocusedHeader(lastFocused.column); - } - - return true; - }; - - const defaultColDef = useMemo( - () => ({ - editable: true, - flex: 1, - minWidth: 100, - filter: true, - }), - [] - ); - - return ( - -
-
-
-
- - -
-
-
- -
-
- - -
-
-
-
- ); -}; - -const root = createRoot(document.getElementById('root')!); -root.render( - - - -); diff --git a/documentation/ag-grid-docs/src/content/docs/keyboard-navigation/_examples/tab-to-next-grid-container/example.spec.ts b/documentation/ag-grid-docs/src/content/docs/keyboard-navigation/_examples/tab-to-next-grid-container/example.spec.ts new file mode 100644 index 00000000000..ed79767b830 --- /dev/null +++ b/documentation/ag-grid-docs/src/content/docs/keyboard-navigation/_examples/tab-to-next-grid-container/example.spec.ts @@ -0,0 +1,11 @@ +import { clickAllButtons, ensureGridReady, test, waitForGridContent } from '@utils/grid/test-utils'; + +test.agExample(import.meta, () => { + test.eachFramework('Example', async ({ page }) => { + // PLACEHOLDER - MINIMAL TEST TO ENSURE GRID LOADS WITHOUT ERRORS + await ensureGridReady(page); + await waitForGridContent(page); + await clickAllButtons(page); + // END PLACEHOLDER + }); +}); diff --git a/documentation/ag-grid-docs/src/content/docs/keyboard-navigation/_examples/tab-to-next-grid-container/index.html b/documentation/ag-grid-docs/src/content/docs/keyboard-navigation/_examples/tab-to-next-grid-container/index.html new file mode 100644 index 00000000000..69f61908427 --- /dev/null +++ b/documentation/ag-grid-docs/src/content/docs/keyboard-navigation/_examples/tab-to-next-grid-container/index.html @@ -0,0 +1,17 @@ +
+
+
+ +
+
+
+
+ +
+
diff --git a/documentation/ag-grid-docs/src/content/docs/keyboard-navigation/_examples/tab-to-next-grid-container/main.ts b/documentation/ag-grid-docs/src/content/docs/keyboard-navigation/_examples/tab-to-next-grid-container/main.ts new file mode 100644 index 00000000000..d03f3cb451a --- /dev/null +++ b/documentation/ag-grid-docs/src/content/docs/keyboard-navigation/_examples/tab-to-next-grid-container/main.ts @@ -0,0 +1,98 @@ +import type { + CellPosition, + ColDef, + GridApi, + GridOptions, + TabToNextGridContainer, + TabToNextGridContainerParams, +} from 'ag-grid-community'; +import { + ClientSideRowModelModule, + ModuleRegistry, + PaginationModule, + TextFilterModule, + ValidationModule, + createGrid, +} from 'ag-grid-community'; + +ModuleRegistry.registerModules([ + PaginationModule, + TextFilterModule, + ClientSideRowModelModule, + ...(process.env.NODE_ENV !== 'production' ? [ValidationModule] : []), +]); + +const columnDefs: ColDef[] = [ + { headerName: '#', colId: 'rowNum', valueGetter: 'node.id', maxWidth: 90 }, + { field: 'athlete', minWidth: 170 }, + { field: 'age' }, + { field: 'country' }, + { field: 'year' }, + { field: 'date' }, + { field: 'sport' }, + { field: 'gold' }, + { field: 'silver' }, + { field: 'bronze' }, + { field: 'total' }, +]; + +let gridApi: GridApi; +let lastFocusedCell: CellPosition | null = null; + +const tabToNextGridContainer: TabToNextGridContainer = ( + params: TabToNextGridContainerParams +) => { + const { backwards, fromContainer, toContainer, defaultTarget } = params; + + // route tabbing out of the last grid cell into pagination controls first. + if (!backwards && fromContainer === 'gridBody' && toContainer === 'external') { + return 'pagination'; + } + + // restore last focused cell when shift-tabbing from pagination back into the grid. + if (backwards && fromContainer === 'pagination' && toContainer === 'gridBody') { + const target = lastFocusedCell ?? defaultTarget; + return target == null ? undefined : target; + } + + // from pagination forwards, allow browser default focus flow to leave the grid. + if (!backwards && fromContainer === 'pagination' && toContainer === 'external') { + return false; + } + + // For everything else, keep grid defaults. + return undefined; +}; + +const gridOptions: GridOptions = { + columnDefs, + defaultColDef: { + flex: 1, + minWidth: 100, + filter: true, + }, + pagination: true, + tabToNextGridContainer, + onCellFocused: (params) => { + const { rowIndex, rowPinned, column } = params; + if (rowIndex == null || !column || typeof column === 'string') { + return; + } + + lastFocusedCell = { + rowIndex, + rowPinned: rowPinned ?? null, + column, + }; + }, +}; + +// setup the grid after the page has finished loading +document.addEventListener('DOMContentLoaded', function () { + const gridDiv = document.querySelector('#myGrid')!; + gridApi = createGrid(gridDiv, gridOptions); + + fetch('https://www.ag-grid.com/example-assets/olympic-winners.json') + .then((response) => response.json()) + .then((data: IOlympicData[]) => gridApi!.setGridOption('rowData', data)); +}); diff --git a/documentation/ag-grid-docs/src/content/docs/keyboard-navigation/_examples/custom-tabbing-into-grid/provided/reactFunctionalTs/styles.css b/documentation/ag-grid-docs/src/content/docs/keyboard-navigation/_examples/tab-to-next-grid-container/styles.css similarity index 100% rename from documentation/ag-grid-docs/src/content/docs/keyboard-navigation/_examples/custom-tabbing-into-grid/provided/reactFunctionalTs/styles.css rename to documentation/ag-grid-docs/src/content/docs/keyboard-navigation/_examples/tab-to-next-grid-container/styles.css diff --git a/documentation/ag-grid-docs/src/content/docs/keyboard-navigation/index.mdoc b/documentation/ag-grid-docs/src/content/docs/keyboard-navigation/index.mdoc index b624a76a419..338ddd5754b 100644 --- a/documentation/ag-grid-docs/src/content/docs/keyboard-navigation/index.mdoc +++ b/documentation/ag-grid-docs/src/content/docs/keyboard-navigation/index.mdoc @@ -93,13 +93,14 @@ The example below has grouped headers, headers and floating filters to demonstra ## Custom Navigation -Most people will be happy with the default navigation the grid does when you use the arrow keys and the {% kbd "⇥ Tab" /%} key. Some people will want to override this (e.g. you may want the {% kbd "⇥ Tab" /%} key to navigate to the cell below, not the cell to the right). To facilitate this, the grid offers four methods: `navigateToNextCell`, `tabToNextCell`, `navigateToNextHeader` and `tabToNextHeader`. +Most people will be happy with the default navigation the grid does when you use the arrow keys and the {% kbd "⇥ Tab" /%} key. Some people will want to override this (e.g. you may want the {% kbd "⇥ Tab" /%} key to navigate to the cell below, not the cell to the right). To facilitate this, the grid offers five methods: `navigateToNextCell`, `tabToNextCell`, `navigateToNextHeader`, `tabToNextHeader` and `tabToNextGridContainer`. -{% apiDocumentation source="grid-options/properties.json" section="nav" names=["focusGridInnerElement", "navigateToNextCell", "tabToNextCell", "navigateToNextHeader", "tabToNextHeader"] /%} +{% apiDocumentation source="grid-options/properties.json" section="nav" names=["focusGridInnerElement", "navigateToNextCell", "tabToNextCell", "navigateToNextHeader", "tabToNextHeader", "tabToNextGridContainer"] /%} {% note %} The `navigateToNextCell` and `tabToNextCell` are only called while navigating across grid cells, while -`navigateToNextHeader` and `tabToNextHeader` are only called while navigating across grid headers. +`navigateToNextHeader` and `tabToNextHeader` are only called while navigating across grid headers. The +`tabToNextGridContainer` callback is called when tab navigation moves between core grid containers. If you need to navigate from one container to another, pass `rowIndex: -1` in `CellPosition` or `headerRowIndex: -1` in `HeaderPosition`. {% /note %} @@ -166,6 +167,19 @@ In the following example there are two input box provided to test tabbing into t {% gridExampleRunner title="Tabbing into the Grid" name="tabbing-into-grid" /%} +### Custom Tabbing Between Grid Containers + +Use `tabToNextGridContainer` to override focus behaviour when tabbing between grid containers such as the grid body, pagination toolbar and external elements. + +In the following example: + +- Tabbing out of the last grid cell is redirected to the pagination toolbar by returning `'pagination'`. +- Shift tabbing from the pagination toolbar back into the grid restores the last focused cell by returning a `CellPosition`. +- Tabbing forward from pagination returns `false`, allowing browser default behaviour to move focus outside the grid. +- All other transitions return `undefined`, which keeps the grid's default behaviour. + +{% gridExampleRunner title="Custom Tab to Next Grid Container" name="tab-to-next-grid-container" /%} + ### Custom Tabbing into the Grid The `focusGridInnerElement` callback can be used to change the element focused by the grid when receiving focus from outside . Notice the following: diff --git a/packages/ag-grid-angular/projects/ag-grid-angular/src/lib/ag-grid-angular.component.ts b/packages/ag-grid-angular/projects/ag-grid-angular/src/lib/ag-grid-angular.component.ts index 112e7f0e520..64b10d5dbec 100644 --- a/packages/ag-grid-angular/projects/ag-grid-angular/src/lib/ag-grid-angular.component.ts +++ b/packages/ag-grid-angular/projects/ag-grid-angular/src/lib/ag-grid-angular.component.ts @@ -216,6 +216,7 @@ import type { StatusBar, StoreRefreshedEvent, TabToNextCell, + TabToNextGridContainer, TabToNextHeader, Theme, ToolPanelSizeChangedEvent, @@ -1938,6 +1939,12 @@ export class AgGridAngular = ColDef | undefined = undefined; + /** Allows overriding the default behaviour when tabbing between core grid containers. + * Return a container name, a cell position, or a header position to focus that target, + * `true` to stay on the current focus, `false` to let the browser handle tab behaviour, + * or `undefined` to use the grid's default behaviour. + */ + @Input() public tabToNextGridContainer: TabToNextGridContainer | undefined = undefined; /** A callback for localising text within the grid. * @initial * @agModule `LocaleModule` diff --git a/packages/ag-grid-community/src/entities/gridOptions.ts b/packages/ag-grid-community/src/entities/gridOptions.ts index 3f3f6ff2566..73d505ef795 100644 --- a/packages/ag-grid-community/src/entities/gridOptions.ts +++ b/packages/ag-grid-community/src/entities/gridOptions.ts @@ -167,6 +167,7 @@ import type { ProcessUnpinnedColumns, SendToClipboard, TabToNextCell, + TabToNextGridContainer, TabToNextHeader, } from '../interfaces/iCallbackParams'; import type { @@ -2194,6 +2195,13 @@ export interface GridOptions { * or `false` to let the browser handle the tab behaviour. */ tabToNextCell?: TabToNextCell; + /** + * Allows overriding the default behaviour when tabbing between core grid containers. + * Return a container name, a cell position, or a header position to focus that target, + * `true` to stay on the current focus, `false` to let the browser handle tab behaviour, + * or `undefined` to use the grid's default behaviour. + */ + tabToNextGridContainer?: TabToNextGridContainer; // *** Localisation *** // /** diff --git a/packages/ag-grid-community/src/focus-overrides.test.ts b/packages/ag-grid-community/src/focus-overrides.test.ts new file mode 100644 index 00000000000..62f9e379b6d --- /dev/null +++ b/packages/ag-grid-community/src/focus-overrides.test.ts @@ -0,0 +1,1515 @@ +import { FocusService } from './focusService'; +import { GridCtrl } from './gridComp/gridCtrl'; +import type { GridOptionsService } from './gridOptionsService'; +import { GridHeaderCtrl } from './headerRendering/gridHeaderCtrl'; +import type { CellPosition } from './interfaces/iCellPosition'; +import type { Column } from './interfaces/iColumn'; +import type { FocusableContainer, FocusableContainerName } from './interfaces/iFocusableContainer'; +import type { HeaderPosition } from './interfaces/iHeaderPosition'; +import { NavigationService } from './navigation/navigationService'; +import { mock } from './test-utils/mock'; +import { _focusNextGridCoreContainer } from './utils/gridFocus'; + +function createColumn(colId: string): Column { + return { + getId: () => colId, + getColId: () => colId, + getPinned: () => null, + } as unknown as Column; +} + +function createHeaderPosition(columnId: string, headerRowIndex: number): HeaderPosition { + return { + headerRowIndex, + column: createColumn(columnId), + }; +} + +function markVisible(element: T): T { + Object.defineProperty(element, 'checkVisibility', { + value: () => true, + configurable: true, + }); + return element; +} + +function createFocusableButton(): HTMLButtonElement { + const button = markVisible(document.createElement('button')); + button.type = 'button'; + button.tabIndex = 0; + return button; +} + +function createContainer(name: FocusableContainerName): { gui: HTMLElement; container: FocusableContainer } { + const gui = markVisible(document.createElement('div')); + const container: FocusableContainer = { + getGui: () => gui, + getFocusableContainerName: () => name, + }; + + return { gui, container }; +} + +describe('Focus override callbacks', () => { + describe('FocusService', () => { + let focusSvc: FocusService; + let focusSvcAny: any; + let gos: jest.Mocked; + let getOption: jest.Mock; + let getCallback: jest.Mock; + let focusProvidedHeaderPosition: jest.SpyInstance; + let rootDiv: HTMLElement; + + const currentHeader = createHeaderPosition('athlete', 0); + const defaultNextHeader = createHeaderPosition('country', 0); + const userHeader = createHeaderPosition('sport', 0); + + beforeEach(() => { + focusSvc = new FocusService(); + focusSvcAny = focusSvc as any; + + gos = mock('get', 'getCallback'); + getOption = gos.get as unknown as jest.Mock; + getCallback = gos.getCallback as unknown as jest.Mock; + + getOption.mockImplementation((key) => { + if (key === 'suppressHeaderFocus') { + return false; + } + if (key === 'headerHeight') { + return 25; + } + return undefined; + }); + + rootDiv = markVisible(document.createElement('div')); + document.body.appendChild(rootDiv); + + focusSvcAny.gos = gos; + focusSvcAny.beans = { + gos, + eRootDiv: rootDiv, + visibleCols: { + headerGroupRowCount: 0, + }, + ctrlsSvc: { + getHeaderRowContainerCtrl: () => ({ getRowCount: () => 2 }), + }, + }; + focusSvcAny.focusedHeader = currentHeader; + + focusProvidedHeaderPosition = jest + .spyOn(focusSvcAny, 'focusProvidedHeaderPosition') + .mockImplementation(() => true); + }); + + afterEach(() => { + rootDiv.remove(); + jest.restoreAllMocks(); + }); + + const mountContainers = (...containers: FocusableContainer[]): void => { + for (const container of containers) { + rootDiv.appendChild(container.getGui()); + } + }; + + test('tabToNextHeader: false cancels header movement', () => { + const tabToNextHeader = jest.fn(() => false); + getCallback.mockImplementation((key) => (key === 'tabToNextHeader' ? tabToNextHeader : undefined)); + + const result = focusSvc.focusHeaderPosition({ + headerPosition: defaultNextHeader, + direction: 'After', + fromTab: true, + allowUserOverride: true, + }); + + expect(result).toBe(false); + expect(tabToNextHeader).toHaveBeenCalledWith({ + backwards: false, + previousHeaderPosition: currentHeader, + nextHeaderPosition: defaultNextHeader, + headerRowCount: 2, + }); + expect(focusProvidedHeaderPosition).not.toHaveBeenCalled(); + }); + + test('tabToNextHeader: true keeps current header', () => { + const tabToNextHeader = jest.fn(() => true); + getCallback.mockImplementation((key) => (key === 'tabToNextHeader' ? tabToNextHeader : undefined)); + + const result = focusSvc.focusHeaderPosition({ + headerPosition: defaultNextHeader, + direction: 'After', + fromTab: true, + allowUserOverride: true, + }); + + expect(result).toBe(true); + expect(focusProvidedHeaderPosition).toHaveBeenCalledWith( + expect.objectContaining({ + headerPosition: currentHeader, + }) + ); + }); + + test('navigateToNextHeader: returned header position is used', () => { + const navigateToNextHeader = jest.fn(() => userHeader); + getCallback.mockImplementation((key) => + key === 'navigateToNextHeader' ? navigateToNextHeader : undefined + ); + const event = new KeyboardEvent('keydown', { key: 'ArrowRight' }); + + const result = focusSvc.focusHeaderPosition({ + headerPosition: defaultNextHeader, + direction: 'After', + allowUserOverride: true, + event, + }); + + expect(result).toBe(true); + expect(navigateToNextHeader).toHaveBeenCalledWith({ + key: 'ArrowRight', + previousHeaderPosition: currentHeader, + nextHeaderPosition: defaultNextHeader, + headerRowCount: 2, + event, + }); + expect(focusProvidedHeaderPosition).toHaveBeenCalledWith( + expect.objectContaining({ + headerPosition: userHeader, + }) + ); + }); + + test('tabToNextHeader: returned header position is used', () => { + const tabToNextHeader = jest.fn(() => userHeader); + getCallback.mockImplementation((key) => (key === 'tabToNextHeader' ? tabToNextHeader : undefined)); + + const result = focusSvc.focusHeaderPosition({ + headerPosition: defaultNextHeader, + direction: 'After', + fromTab: true, + allowUserOverride: true, + }); + + expect(result).toBe(true); + expect(tabToNextHeader).toHaveBeenCalledWith({ + backwards: false, + previousHeaderPosition: currentHeader, + nextHeaderPosition: defaultNextHeader, + headerRowCount: 2, + }); + expect(focusProvidedHeaderPosition).toHaveBeenCalledWith( + expect.objectContaining({ + headerPosition: userHeader, + }) + ); + }); + + test('tabToNextHeader: callback is ignored when allowUserOverride is false', () => { + const tabToNextHeader = jest.fn(() => userHeader); + getCallback.mockImplementation((key) => (key === 'tabToNextHeader' ? tabToNextHeader : undefined)); + + const result = focusSvc.focusHeaderPosition({ + headerPosition: defaultNextHeader, + direction: 'After', + fromTab: true, + allowUserOverride: false, + }); + + expect(result).toBe(true); + expect(tabToNextHeader).not.toHaveBeenCalled(); + expect(focusProvidedHeaderPosition).toHaveBeenCalledWith( + expect.objectContaining({ + headerPosition: defaultNextHeader, + }) + ); + }); + + test('navigateToNextHeader: callback is ignored when allowUserOverride is false', () => { + const navigateToNextHeader = jest.fn(() => userHeader); + getCallback.mockImplementation((key) => + key === 'navigateToNextHeader' ? navigateToNextHeader : undefined + ); + const event = new KeyboardEvent('keydown', { key: 'ArrowRight' }); + + const result = focusSvc.focusHeaderPosition({ + headerPosition: defaultNextHeader, + direction: 'After', + allowUserOverride: false, + event, + }); + + expect(result).toBe(true); + expect(navigateToNextHeader).not.toHaveBeenCalled(); + expect(focusProvidedHeaderPosition).toHaveBeenCalledWith( + expect.objectContaining({ + headerPosition: defaultNextHeader, + }) + ); + }); + + test('navigateToNextHeader: callback is ignored when event is missing', () => { + const navigateToNextHeader = jest.fn(() => userHeader); + getCallback.mockImplementation((key) => + key === 'navigateToNextHeader' ? navigateToNextHeader : undefined + ); + + const result = focusSvc.focusHeaderPosition({ + headerPosition: defaultNextHeader, + direction: 'After', + allowUserOverride: true, + }); + + expect(result).toBe(true); + expect(navigateToNextHeader).not.toHaveBeenCalled(); + expect(focusProvidedHeaderPosition).toHaveBeenCalledWith( + expect.objectContaining({ + headerPosition: defaultNextHeader, + }) + ); + }); + + test('navigateToNextHeader: null result keeps focus on current header (handled)', () => { + const navigateToNextHeader = jest.fn(() => null); + getCallback.mockImplementation((key) => + key === 'navigateToNextHeader' ? navigateToNextHeader : undefined + ); + const event = new KeyboardEvent('keydown', { key: 'ArrowRight' }); + + const result = focusSvc.focusHeaderPosition({ + headerPosition: defaultNextHeader, + direction: 'After', + allowUserOverride: true, + event, + }); + + expect(result).toBe(true); + expect(navigateToNextHeader).toHaveBeenCalledWith({ + key: 'ArrowRight', + previousHeaderPosition: currentHeader, + nextHeaderPosition: defaultNextHeader, + headerRowCount: 2, + event, + }); + if (focusProvidedHeaderPosition.mock.calls.length > 0) { + expect(focusProvidedHeaderPosition).toHaveBeenCalledWith( + expect.objectContaining({ + headerPosition: currentHeader, + }) + ); + } + }); + + test('tabToNextGridContainer default target: backwards into gridBody returns a real cell target', () => { + const column = createColumn('athlete'); + const columnAny = column as any; + columnAny.isSuppressNavigable = jest.fn(() => false); + + const { gui, container } = createContainer('gridBody'); + const focusable = createFocusableButton(); + gui.appendChild(focusable); + + focusSvcAny.visibleCols = { allCols: [column] }; + focusSvcAny.beans.rowModel = { + getRowCount: () => 1, + getRow: jest.fn(() => ({ id: 'row-0' })), + }; + focusSvcAny.beans.pageBounds = { + getFirstRow: () => 0, + getLastRow: () => 0, + }; + focusSvcAny.beans.pinnedRowModel = { + getPinnedTopRowCount: () => 0, + getPinnedBottomRowCount: () => 0, + }; + focusSvcAny.rowRenderer = { getRowByPosition: jest.fn() }; + + const target = focusSvc.getDefaultTabToNextGridContainerTarget({ + backwards: true, + focusableContainers: [container], + nextIndex: 0, + }); + + expect(target).toEqual({ + rowIndex: 0, + rowPinned: null, + column, + }); + }); + + test('tabToNextGridContainer default target: skips non-focusable intermediate containers', () => { + const { container: statusBar } = createContainer('statusBar'); + const { gui: paginationGui, container: pagination } = createContainer('pagination'); + paginationGui.appendChild(createFocusableButton()); + mountContainers(statusBar, pagination); + + const target = focusSvc.getDefaultTabToNextGridContainerTarget({ + backwards: false, + focusableContainers: [statusBar, pagination], + nextIndex: 0, + }); + + expect(target).toBe('pagination'); + }); + + test('tabToNextGridContainer default target: returns null when no focusable targets are available', () => { + const { container: statusBar } = createContainer('statusBar'); + const { container: pagination } = createContainer('pagination'); + mountContainers(statusBar, pagination); + + const target = focusSvc.getDefaultTabToNextGridContainerTarget({ + backwards: false, + focusableContainers: [statusBar, pagination], + nextIndex: 0, + }); + + expect(target).toBeNull(); + }); + + test('tabToNextGridContainer default target: forward into gridBody should align with header-first default', () => { + const column = createColumn('athlete'); + const columnAny = column as any; + columnAny.isSuppressNavigable = jest.fn(() => false); + focusSvcAny.visibleCols = { allCols: [column] }; + focusSvcAny.beans.rowModel = { + getRowCount: () => 1, + getRow: jest.fn(() => ({ id: 'row-0' })), + }; + focusSvcAny.beans.pageBounds = { + getFirstRow: () => 0, + getLastRow: () => 0, + }; + focusSvcAny.beans.pinnedRowModel = { + getPinnedTopRowCount: () => 0, + getPinnedBottomRowCount: () => 0, + }; + focusSvcAny.rowRenderer = { getRowByPosition: jest.fn() }; + + const target = focusSvc.getDefaultTabToNextGridContainerTarget({ + backwards: false, + focusableContainers: [createContainer('gridBody').container], + nextIndex: 0, + }); + + expect(target).toEqual( + expect.objectContaining({ + headerRowIndex: expect.any(Number), + column, + }) + ); + }); + + test('tabToNextGridContainer default target: when gridBody target is unavailable, continue to next container', () => { + const column = createColumn('athlete'); + const columnAny = column as any; + columnAny.isSuppressNavigable = jest.fn(() => true); + getOption.mockImplementation((key) => (key === 'suppressHeaderFocus' ? true : undefined)); + + const { container: gridBody } = createContainer('gridBody'); + const { gui: paginationGui, container: pagination } = createContainer('pagination'); + paginationGui.appendChild(createFocusableButton()); + mountContainers(gridBody, pagination); + + focusSvcAny.visibleCols = { allCols: [column] }; + focusSvcAny.beans.rowModel = { + getRowCount: () => 1, + getRow: jest.fn(() => ({ id: 'row-0' })), + }; + focusSvcAny.beans.pageBounds = { + getFirstRow: () => 0, + getLastRow: () => 0, + }; + focusSvcAny.beans.pinnedRowModel = { + getPinnedTopRowCount: () => 0, + getPinnedBottomRowCount: () => 0, + }; + focusSvcAny.rowRenderer = { getRowByPosition: jest.fn() }; + + const target = focusSvc.getDefaultTabToNextGridContainerTarget({ + backwards: false, + focusableContainers: [gridBody, pagination], + nextIndex: 0, + }); + + expect(target).toBe('pagination'); + }); + }); + + describe('NavigationService', () => { + let navigationSvc: NavigationService; + let navigationSvcAny: any; + let gos: jest.Mocked; + let getOption: jest.Mock; + let getCallback: jest.Mock; + let colA: Column; + let colB: Column; + + beforeEach(() => { + navigationSvc = new NavigationService(); + navigationSvcAny = navigationSvc as any; + + gos = mock('get', 'getCallback'); + getOption = gos.get as unknown as jest.Mock; + getCallback = gos.getCallback as unknown as jest.Mock; + + getOption.mockImplementation((key) => { + if (key === 'enableRtl') { + return false; + } + if (key === 'editType') { + return undefined; + } + return undefined; + }); + + colA = createColumn('a'); + colB = createColumn('b'); + + navigationSvcAny.gos = gos; + navigationSvcAny.beans = { + gos, + cellNavigation: { + getNextTabbedCell: jest.fn(), + getNextCellToFocus: jest.fn(), + }, + focusSvc: { + focusHeaderPosition: jest.fn(), + }, + rowRenderer: { + getRowByPosition: jest.fn(), + }, + ctrlsSvc: { + getHeaderRowContainerCtrl: () => ({ getRowCount: () => 2 }), + }, + }; + + jest.spyOn(navigationSvcAny, 'getLastCellOfColSpan').mockImplementation( + (position: CellPosition) => position + ); + jest.spyOn(navigationSvcAny, 'isValidNavigateCell').mockReturnValue(true); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('tabToNextCell: false cancels tab navigation', () => { + const tabToNextCell = jest.fn(() => false); + getCallback.mockImplementation((key) => (key === 'tabToNextCell' ? tabToNextCell : undefined)); + + const previousPosition: CellPosition = { rowIndex: 0, rowPinned: null, column: colA }; + const nextPosition: CellPosition = { rowIndex: 0, rowPinned: null, column: colB }; + navigationSvcAny.beans.cellNavigation.getNextTabbedCell.mockReturnValue(nextPosition); + + const result = navigationSvc.findNextCellToFocusOn(previousPosition, { + backwards: false, + startEditing: false, + }); + + expect(result).toBe(false); + expect(tabToNextCell).toHaveBeenCalledWith({ + backwards: false, + editing: false, + previousCellPosition: previousPosition, + nextCellPosition: nextPosition, + }); + }); + + test('tabToNextCell: custom rowIndex -1 result moves focus to header', () => { + const tabToNextCell = jest.fn(() => ({ rowIndex: -1, rowPinned: null, column: colA })); + getCallback.mockImplementation((key) => (key === 'tabToNextCell' ? tabToNextCell : undefined)); + navigationSvcAny.beans.cellNavigation.getNextTabbedCell.mockReturnValue(null); + + const previousPosition: CellPosition = { rowIndex: 0, rowPinned: null, column: colA }; + const result = navigationSvc.findNextCellToFocusOn(previousPosition, { + backwards: false, + startEditing: false, + }); + + expect(result).toBeNull(); + expect(navigationSvcAny.beans.focusSvc.focusHeaderPosition).toHaveBeenCalledWith({ + headerPosition: { headerRowIndex: 1, column: colA }, + fromCell: true, + }); + }); + + test('navigateToNextCell: null result from callback stops navigation', () => { + const navigateToNextCell = jest.fn(() => null); + getCallback.mockImplementation((key) => (key === 'navigateToNextCell' ? navigateToNextCell : undefined)); + + const focusPositionSpy = jest.spyOn(navigationSvcAny, 'focusPosition').mockImplementation(() => undefined); + jest.spyOn(navigationSvcAny, 'getNormalisedPosition').mockImplementation(() => null); + + const current: CellPosition = { rowIndex: 0, rowPinned: null, column: colA }; + const next: CellPosition = { rowIndex: 1, rowPinned: null, column: colB }; + navigationSvcAny.beans.cellNavigation.getNextCellToFocus.mockReturnValue(next); + + const event = new KeyboardEvent('keydown', { key: 'ArrowRight' }); + navigationSvc.navigateToNextCell(event, 'ArrowRight', current, true); + + expect(navigateToNextCell).toHaveBeenCalledWith({ + key: 'ArrowRight', + previousCellPosition: current, + nextCellPosition: next, + event, + }); + expect(focusPositionSpy).not.toHaveBeenCalled(); + }); + + test('navigateToNextCell: callback override position is focused', () => { + const userResult: CellPosition = { rowIndex: 2, rowPinned: null, column: colB }; + const navigateToNextCell = jest.fn(() => userResult); + getCallback.mockImplementation((key) => (key === 'navigateToNextCell' ? navigateToNextCell : undefined)); + + const normalised = { ...userResult }; + jest.spyOn(navigationSvcAny, 'getNormalisedPosition').mockReturnValue(normalised); + const focusPositionSpy = jest.spyOn(navigationSvcAny, 'focusPosition').mockImplementation(() => undefined); + + const current: CellPosition = { rowIndex: 0, rowPinned: null, column: colA }; + const defaultNext: CellPosition = { rowIndex: 0, rowPinned: null, column: colB }; + navigationSvcAny.beans.cellNavigation.getNextCellToFocus.mockReturnValue(defaultNext); + + navigationSvc.navigateToNextCell(null, 'ArrowRight', current, true); + + expect(navigateToNextCell).toHaveBeenCalled(); + expect(focusPositionSpy).toHaveBeenCalledWith(normalised); + }); + + test('tabToNextCell: callback receives editing=true when tabbing from edit mode', () => { + const tabToNextCell = jest.fn(() => false); + getCallback.mockImplementation((key) => (key === 'tabToNextCell' ? tabToNextCell : undefined)); + + const previousPosition: CellPosition = { rowIndex: 0, rowPinned: null, column: colA }; + const nextPosition: CellPosition = { rowIndex: 0, rowPinned: null, column: colB }; + navigationSvcAny.beans.cellNavigation.getNextTabbedCell.mockReturnValue(nextPosition); + + const result = navigationSvc.findNextCellToFocusOn(previousPosition, { + backwards: false, + startEditing: true, + }); + + expect(result).toBe(false); + expect(tabToNextCell).toHaveBeenCalledWith({ + backwards: false, + editing: true, + previousCellPosition: previousPosition, + nextCellPosition: nextPosition, + }); + }); + + test('tabToNextCell: callback receives null nextCellPosition at grid edge', () => { + const tabToNextCell = jest.fn(() => false); + getCallback.mockImplementation((key) => (key === 'tabToNextCell' ? tabToNextCell : undefined)); + + const previousPosition: CellPosition = { rowIndex: 0, rowPinned: null, column: colA }; + navigationSvcAny.beans.cellNavigation.getNextTabbedCell.mockReturnValue(null); + + const result = navigationSvc.findNextCellToFocusOn(previousPosition, { + backwards: false, + startEditing: false, + }); + + expect(result).toBe(false); + expect(tabToNextCell).toHaveBeenCalledWith({ + backwards: false, + editing: false, + previousCellPosition: previousPosition, + nextCellPosition: null, + }); + }); + + test('navigateToNextCell: callback is ignored when allowUserOverride is false', () => { + const userResult: CellPosition = { rowIndex: 2, rowPinned: null, column: colB }; + const navigateToNextCell = jest.fn(() => userResult); + getCallback.mockImplementation((key) => (key === 'navigateToNextCell' ? navigateToNextCell : undefined)); + + const current: CellPosition = { rowIndex: 0, rowPinned: null, column: colA }; + const defaultNext: CellPosition = { rowIndex: 0, rowPinned: null, column: colB }; + navigationSvcAny.beans.cellNavigation.getNextCellToFocus.mockReturnValue(defaultNext); + + const normalised = { ...defaultNext }; + jest.spyOn(navigationSvcAny, 'getNormalisedPosition').mockReturnValue(normalised); + const focusPositionSpy = jest.spyOn(navigationSvcAny, 'focusPosition').mockImplementation(() => undefined); + + navigationSvc.navigateToNextCell(null, 'ArrowRight', current, false); + + expect(navigateToNextCell).not.toHaveBeenCalled(); + expect(focusPositionSpy).toHaveBeenCalledWith(normalised); + }); + + test('navigateToNextCell: callback rowIndex -1 routes focus to header using callback column', () => { + const navigateToNextCell = jest.fn(() => ({ rowIndex: -1, rowPinned: null, column: colB })); + getCallback.mockImplementation((key) => (key === 'navigateToNextCell' ? navigateToNextCell : undefined)); + + const current: CellPosition = { rowIndex: 0, rowPinned: null, column: colA }; + const defaultNext: CellPosition = { rowIndex: 1, rowPinned: null, column: colB }; + navigationSvcAny.beans.cellNavigation.getNextCellToFocus.mockReturnValue(defaultNext); + + const event = new KeyboardEvent('keydown', { key: 'ArrowUp' }); + navigationSvc.navigateToNextCell(event, 'ArrowUp', current, true); + + expect(navigateToNextCell).toHaveBeenCalledWith({ + key: 'ArrowUp', + previousCellPosition: current, + nextCellPosition: defaultNext, + event, + }); + expect(navigationSvcAny.beans.focusSvc.focusHeaderPosition).toHaveBeenCalledWith({ + headerPosition: { headerRowIndex: 1, column: colB }, + event, + fromCell: true, + }); + }); + + test('navigateToNextCell: callback receives null nextCellPosition at edge', () => { + const navigateToNextCell = jest.fn(() => null); + getCallback.mockImplementation((key) => (key === 'navigateToNextCell' ? navigateToNextCell : undefined)); + + const current: CellPosition = { rowIndex: 0, rowPinned: null, column: colA }; + navigationSvcAny.beans.cellNavigation.getNextCellToFocus.mockReturnValue(null); + + const focusPositionSpy = jest.spyOn(navigationSvcAny, 'focusPosition').mockImplementation(() => undefined); + navigationSvc.navigateToNextCell(null, 'ArrowRight', current, true); + + expect(navigateToNextCell).toHaveBeenCalledWith({ + key: 'ArrowRight', + previousCellPosition: current, + nextCellPosition: null, + event: null, + }); + expect(focusPositionSpy).not.toHaveBeenCalled(); + }); + }); + + describe('GridCtrl', () => { + let gridCtrl: GridCtrl; + let gridCtrlAny: any; + let gos: jest.Mocked; + let getOption: jest.Mock; + let getCallback: jest.Mock; + let gridBodyContainer: FocusableContainer; + let paginationContainer: FocusableContainer; + let rootDiv: HTMLElement; + + const appendFocusableButton = (container: FocusableContainer): HTMLButtonElement => { + const button = createFocusableButton(); + container.getGui().appendChild(button); + return button; + }; + + beforeEach(() => { + gridCtrl = new GridCtrl(); + gridCtrlAny = gridCtrl as any; + + gos = mock('get', 'getCallback'); + getOption = gos.get as unknown as jest.Mock; + getCallback = gos.getCallback as unknown as jest.Mock; + + getOption.mockImplementation((key) => { + if (key === 'headerHeight') { + return 25; + } + if (key === 'suppressHeaderFocus') { + return false; + } + return undefined; + }); + + const gridBody = createContainer('gridBody'); + const pagination = createContainer('pagination'); + gridBodyContainer = gridBody.container; + paginationContainer = pagination.container; + + rootDiv = markVisible(document.createElement('div')); + rootDiv.appendChild(gridBody.gui); + rootDiv.appendChild(pagination.gui); + document.body.appendChild(rootDiv); + + const gridBodyFocusable = appendFocusableButton(gridBodyContainer); + gridBodyFocusable.focus(); + + gridCtrlAny.gos = gos; + gridCtrlAny.view = { + getFocusableContainers: () => [gridBodyContainer, paginationContainer], + forceFocusOutOfContainer: jest.fn(), + }; + gridCtrlAny.beans = { + gos, + eRootDiv: rootDiv, + navigation: { + ensureCellVisible: jest.fn(), + }, + focusSvc: { + getDefaultTabToNextGridContainerTarget: jest.fn(() => 'pagination'), + setFocusedCell: jest.fn(), + isCellFocused: jest.fn(() => true), + focusHeaderPosition: jest.fn(), + focusFirstHeader: jest.fn(() => true), + focusGridView: jest.fn(() => true), + }, + visibleCols: { + allCols: [], + }, + }; + }); + + afterEach(() => { + rootDiv.remove(); + jest.restoreAllMocks(); + }); + + test('focusGridInnerElement: user callback returning true short-circuits default flow', () => { + const focusGridInnerElement = jest.fn(() => true); + getCallback.mockImplementation((key) => + key === 'focusGridInnerElement' ? focusGridInnerElement : undefined + ); + + const result = gridCtrl.focusInnerElement(true); + + expect(result).toBe(true); + expect(focusGridInnerElement).toHaveBeenCalledWith({ fromBottom: true }); + }); + + test('tabToNextGridContainer: callback receives default routing metadata', () => { + const tabToNextGridContainer = jest.fn(() => false); + getCallback.mockImplementation((key) => + key === 'tabToNextGridContainer' ? tabToNextGridContainer : undefined + ); + + const result = gridCtrl.focusNextInnerContainer(false); + + expect(result).toBe(false); + expect(tabToNextGridContainer).toHaveBeenCalledWith({ + backwards: false, + fromContainer: 'gridBody', + toContainer: 'pagination', + defaultTarget: 'pagination', + }); + }); + + test('tabToNextGridContainer: callback metadata resolves to external when there is no default target', () => { + const paginationButton = appendFocusableButton(paginationContainer); + paginationButton.focus(); + + const focusSvc = gridCtrlAny.beans.focusSvc; + focusSvc.getDefaultTabToNextGridContainerTarget.mockImplementation( + ({ + focusableContainers, + nextIndex, + }: { + focusableContainers: FocusableContainer[]; + nextIndex: number; + }) => { + if (nextIndex < 0 || nextIndex >= focusableContainers.length) { + return null; + } + return focusableContainers[nextIndex].getFocusableContainerName(); + } + ); + + const tabToNextGridContainer = jest.fn(() => false); + getCallback.mockImplementation((key) => + key === 'tabToNextGridContainer' ? tabToNextGridContainer : undefined + ); + + const result = gridCtrl.focusNextInnerContainer(false); + + expect(result).toBe(false); + expect(tabToNextGridContainer).toHaveBeenCalledWith({ + backwards: false, + fromContainer: 'pagination', + toContainer: 'external', + defaultTarget: null, + }); + }); + + test('tabToNextGridContainer: callback metadata keeps gridBody destination when default target cannot be represented', () => { + const externalButton = createFocusableButton(); + rootDiv.appendChild(externalButton); + externalButton.focus(); + + const focusSvc = gridCtrlAny.beans.focusSvc; + focusSvc.getDefaultTabToNextGridContainerTarget.mockReturnValue(null); + + const tabToNextGridContainer = jest.fn(() => false); + getCallback.mockImplementation((key) => + key === 'tabToNextGridContainer' ? tabToNextGridContainer : undefined + ); + + const result = gridCtrl.focusNextInnerContainer(false); + + expect(result).toBe(false); + expect(tabToNextGridContainer).toHaveBeenCalledWith({ + backwards: false, + fromContainer: 'external', + toContainer: 'gridBody', + defaultTarget: null, + }); + }); + + test('tabToNextGridContainer: callback metadata uses resolved target when immediate next container is not focusable', () => { + const { gui: statusBarGui, container: statusBar } = createContainer('statusBar'); + rootDiv.insertBefore(statusBarGui, paginationContainer.getGui()); + + appendFocusableButton(paginationContainer); + gridCtrlAny.view.getFocusableContainers = () => [gridBodyContainer, statusBar, paginationContainer]; + + const focusSvc = gridCtrlAny.beans.focusSvc; + focusSvc.getDefaultTabToNextGridContainerTarget.mockImplementation(() => 'pagination'); + + const tabToNextGridContainer = jest.fn(() => false); + getCallback.mockImplementation((key) => + key === 'tabToNextGridContainer' ? tabToNextGridContainer : undefined + ); + + const result = gridCtrl.focusNextInnerContainer(false); + + expect(result).toBe(false); + expect(tabToNextGridContainer).toHaveBeenCalledWith({ + backwards: false, + fromContainer: 'gridBody', + toContainer: 'pagination', + defaultTarget: 'pagination', + }); + }); + + test('tabToNextGridContainer: callback metadata maps cell/header default targets to gridBody', () => { + const targetCell: CellPosition = { + rowIndex: 1, + rowPinned: null, + column: createColumn('country'), + }; + const focusSvc = gridCtrlAny.beans.focusSvc; + focusSvc.getDefaultTabToNextGridContainerTarget.mockReturnValue(targetCell); + + const tabToNextGridContainer = jest.fn(() => false); + getCallback.mockImplementation((key) => + key === 'tabToNextGridContainer' ? tabToNextGridContainer : undefined + ); + + const result = gridCtrl.focusNextInnerContainer(false); + + expect(result).toBe(false); + expect(tabToNextGridContainer).toHaveBeenCalledWith({ + backwards: false, + fromContainer: 'gridBody', + toContainer: 'gridBody', + defaultTarget: targetCell, + }); + }); + + test('tabToNextGridContainer: when callback is missing, default target lookup is not computed', () => { + const paginationButton = appendFocusableButton(paginationContainer); + getCallback.mockImplementation(() => undefined); + + const result = gridCtrl.focusNextInnerContainer(false); + const focusSvc = gridCtrlAny.beans.focusSvc; + + expect(result).toBe(true); + expect(document.activeElement).toBe(paginationButton); + expect(focusSvc.getDefaultTabToNextGridContainerTarget).not.toHaveBeenCalled(); + }); + + test('tabToNextGridContainer: callback undefined keeps grid default flow', () => { + const paginationButton = appendFocusableButton(paginationContainer); + const tabToNextGridContainer = jest.fn(() => undefined); + getCallback.mockImplementation((key) => + key === 'tabToNextGridContainer' ? tabToNextGridContainer : undefined + ); + + const result = gridCtrl.focusNextInnerContainer(false); + + expect(result).toBe(true); + expect(document.activeElement).toBe(paginationButton); + expect(tabToNextGridContainer).toHaveBeenCalledTimes(1); + }); + + test('tabToNextGridContainer: default flow with no next container returns undefined', () => { + const paginationButton = appendFocusableButton(paginationContainer); + paginationButton.focus(); + getCallback.mockImplementation(() => undefined); + + const result = gridCtrl.focusNextInnerContainer(false); + + expect(result).toBeUndefined(); + }); + + test('tabToNextGridContainer: default flow skips intermediate containers that cannot take focus', () => { + const { gui: statusBarGui, container: statusBar } = createContainer('statusBar'); + rootDiv.insertBefore(statusBarGui, paginationContainer.getGui()); + gridCtrlAny.view.getFocusableContainers = () => [gridBodyContainer, statusBar, paginationContainer]; + const paginationButton = appendFocusableButton(paginationContainer); + getCallback.mockImplementation(() => undefined); + + const result = gridCtrl.focusNextInnerContainer(false); + + expect(result).toBe(true); + expect(document.activeElement).toBe(paginationButton); + }); + + test('tabToNextGridContainer: callback true preserves current focus', () => { + const tabToNextGridContainer = jest.fn(() => true); + getCallback.mockImplementation((key) => + key === 'tabToNextGridContainer' ? tabToNextGridContainer : undefined + ); + const before = document.activeElement; + + const result = gridCtrl.focusNextInnerContainer(false); + + expect(result).toBe(true); + expect(document.activeElement).toBe(before); + expect(gridCtrlAny.beans.navigation.ensureCellVisible).not.toHaveBeenCalled(); + }); + + test('tabToNextGridContainer: callback cell position is applied through focus service', () => { + const targetCell: CellPosition = { + rowIndex: 2, + rowPinned: null, + column: createColumn('country'), + }; + const tabToNextGridContainer = jest.fn(() => targetCell); + getCallback.mockImplementation((key) => + key === 'tabToNextGridContainer' ? tabToNextGridContainer : undefined + ); + + const result = gridCtrl.focusNextInnerContainer(false); + const { navigation, focusSvc } = gridCtrlAny.beans; + + expect(result).toBe(true); + expect(navigation.ensureCellVisible).toHaveBeenCalledWith(targetCell); + expect(focusSvc.setFocusedCell).toHaveBeenCalledWith({ ...targetCell, forceBrowserFocus: true }); + expect(focusSvc.isCellFocused).toHaveBeenCalledWith(targetCell); + }); + + test('tabToNextGridContainer: callback cell position returns undefined when focus is not achieved', () => { + const targetCell: CellPosition = { + rowIndex: 2, + rowPinned: null, + column: createColumn('country'), + }; + const tabToNextGridContainer = jest.fn(() => targetCell); + getCallback.mockImplementation((key) => + key === 'tabToNextGridContainer' ? tabToNextGridContainer : undefined + ); + const focusSvc = gridCtrlAny.beans.focusSvc; + focusSvc.isCellFocused.mockReturnValue(false); + + const result = gridCtrl.focusNextInnerContainer(false); + + expect(result).toBeUndefined(); + expect(gridCtrlAny.beans.navigation.ensureCellVisible).toHaveBeenCalledWith(targetCell); + expect(focusSvc.setFocusedCell).toHaveBeenCalledWith({ ...targetCell, forceBrowserFocus: true }); + }); + + test('tabToNextGridContainer: callback header position routes through focus service', () => { + const targetHeader: HeaderPosition = { + headerRowIndex: 0, + column: createColumn('country'), + }; + const tabToNextGridContainer = jest.fn(() => targetHeader); + getCallback.mockImplementation((key) => + key === 'tabToNextGridContainer' ? tabToNextGridContainer : undefined + ); + const focusSvc = gridCtrlAny.beans.focusSvc; + focusSvc.focusHeaderPosition.mockReturnValue(true); + + const result = gridCtrl.focusNextInnerContainer(false); + + expect(result).toBe(true); + expect(focusSvc.focusHeaderPosition).toHaveBeenCalledWith({ headerPosition: targetHeader }); + }); + + test('tabToNextGridContainer: callback header position returns undefined when focus fails', () => { + const targetHeader: HeaderPosition = { + headerRowIndex: 0, + column: createColumn('country'), + }; + const tabToNextGridContainer = jest.fn(() => targetHeader); + getCallback.mockImplementation((key) => + key === 'tabToNextGridContainer' ? tabToNextGridContainer : undefined + ); + const focusSvc = gridCtrlAny.beans.focusSvc; + focusSvc.focusHeaderPosition.mockReturnValue(false); + + const result = gridCtrl.focusNextInnerContainer(false); + + expect(result).toBeUndefined(); + expect(focusSvc.focusHeaderPosition).toHaveBeenCalledWith({ headerPosition: targetHeader }); + }); + + test('tabToNextGridContainer: callback container name routes focus to that container', () => { + const { gui: statusBarGui, container: statusBar } = createContainer('statusBar'); + const statusBarButton = appendFocusableButton(statusBar); + rootDiv.appendChild(statusBarGui); + gridCtrlAny.view.getFocusableContainers = () => [gridBodyContainer, statusBar, paginationContainer]; + + const tabToNextGridContainer = jest.fn(() => 'statusBar'); + getCallback.mockImplementation((key) => + key === 'tabToNextGridContainer' ? tabToNextGridContainer : undefined + ); + + const result = gridCtrl.focusNextInnerContainer(false); + + expect(result).toBe(true); + expect(document.activeElement).toBe(statusBarButton); + }); + + for (const containerName of [ + 'pagination', + 'statusBar', + 'sideBar', + 'rowGroupToolbar', + 'pivotToolbar', + 'dialog', + ] as const) { + test(`tabToNextGridContainer: callback accepts ${containerName} container target`, () => { + const { gui: targetGui, container: targetContainer } = createContainer(containerName); + const targetButton = appendFocusableButton(targetContainer); + rootDiv.appendChild(targetGui); + gridCtrlAny.view.getFocusableContainers = () => [ + gridBodyContainer, + targetContainer, + paginationContainer, + ]; + + const tabToNextGridContainer = jest.fn(() => containerName); + getCallback.mockImplementation((key) => + key === 'tabToNextGridContainer' ? tabToNextGridContainer : undefined + ); + + const result = gridCtrl.focusNextInnerContainer(false); + + expect(result).toBe(true); + expect(document.activeElement).toBe(targetButton); + }); + } + + test('tabToNextGridContainer: callback container name returns undefined when container is not focusable', () => { + const { gui: statusBarGui, container: statusBar } = createContainer('statusBar'); + rootDiv.appendChild(statusBarGui); + gridCtrlAny.view.getFocusableContainers = () => [gridBodyContainer, statusBar, paginationContainer]; + + const tabToNextGridContainer = jest.fn(() => 'statusBar'); + getCallback.mockImplementation((key) => + key === 'tabToNextGridContainer' ? tabToNextGridContainer : undefined + ); + + const result = gridCtrl.focusNextInnerContainer(false); + + expect(result).toBeUndefined(); + }); + + test('tabToNextGridContainer: callback container name warns and returns undefined when target container is absent', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const tabToNextGridContainer = jest.fn(() => 'statusBar'); + getCallback.mockImplementation((key) => + key === 'tabToNextGridContainer' ? tabToNextGridContainer : undefined + ); + + const firstResult = gridCtrl.focusNextInnerContainer(false); + const secondResult = gridCtrl.focusNextInnerContainer(false); + + expect(firstResult).toBeUndefined(); + expect(secondResult).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledTimes(2); + expect(warnSpy).toHaveBeenNthCalledWith( + 1, + 'AG Grid: tabToNextGridContainer - statusBar container not found' + ); + expect(warnSpy).toHaveBeenNthCalledWith( + 2, + 'AG Grid: tabToNextGridContainer - statusBar container not found' + ); + }); + + test('tabToNextGridContainer: callback gridBody target follows header-first forward default', () => { + const tabToNextGridContainer = jest.fn(() => 'gridBody'); + getCallback.mockImplementation((key) => + key === 'tabToNextGridContainer' ? tabToNextGridContainer : undefined + ); + const focusSvc = gridCtrlAny.beans.focusSvc; + focusSvc.focusFirstHeader.mockReturnValue(true); + + const result = gridCtrl.focusNextInnerContainer(false); + + expect(result).toBe(true); + expect(focusSvc.focusFirstHeader).toHaveBeenCalledTimes(1); + expect(focusSvc.focusGridView).not.toHaveBeenCalled(); + }); + + test('tab from rowGroupToolbar into grid uses first header and not grid body viewport', () => { + const { gui: rowGroupGui, container: rowGroupContainer } = createContainer('rowGroupToolbar'); + const rowGroupButton = appendFocusableButton(rowGroupContainer); + const gridBodyViewport = appendFocusableButton(gridBodyContainer); + + rootDiv.insertBefore(rowGroupGui, gridBodyContainer.getGui()); + gridCtrlAny.view.getFocusableContainers = () => [rowGroupContainer, gridBodyContainer, paginationContainer]; + + rowGroupButton.focus(); + getCallback.mockImplementation(() => undefined); + + const focusSvc = gridCtrlAny.beans.focusSvc; + focusSvc.focusFirstHeader.mockReturnValue(true); + + const result = gridCtrl.focusNextInnerContainer(false); + + expect(result).toBe(true); + expect(focusSvc.focusFirstHeader).toHaveBeenCalledTimes(1); + expect(focusSvc.focusGridView).not.toHaveBeenCalled(); + expect(document.activeElement).not.toBe(gridBodyViewport); + }); + + test('tabToNextGridContainer: callback gridBody target routes backwards to grid view', () => { + const lastColumn = createColumn('sport'); + gridCtrlAny.beans.visibleCols.allCols = [lastColumn]; + + const tabToNextGridContainer = jest.fn(() => 'gridBody'); + getCallback.mockImplementation((key) => + key === 'tabToNextGridContainer' ? tabToNextGridContainer : undefined + ); + const focusSvc = gridCtrlAny.beans.focusSvc; + focusSvc.focusGridView.mockReturnValue(true); + + const result = gridCtrl.focusNextInnerContainer(true); + + expect(result).toBe(true); + expect(focusSvc.focusGridView).toHaveBeenCalledWith({ column: lastColumn, backwards: true }); + }); + + test('focusGridInnerElement: fromBottom prefers grid body default before earlier containers', () => { + const { gui: rowGroupGui, container: rowGroupContainer } = createContainer('rowGroupToolbar'); + const rowGroupButton = appendFocusableButton(rowGroupContainer); + const { gui: statusBarGui, container: statusBar } = createContainer('statusBar'); + + rootDiv.insertBefore(rowGroupGui, gridBodyContainer.getGui()); + rootDiv.insertBefore(statusBarGui, paginationContainer.getGui()); + gridCtrlAny.view.getFocusableContainers = () => [rowGroupContainer, gridBodyContainer, statusBar]; + + const lastColumn = createColumn('sport'); + gridCtrlAny.beans.visibleCols.allCols = [lastColumn]; + const focusSvc = gridCtrlAny.beans.focusSvc; + focusSvc.focusGridView.mockReturnValue(true); + getCallback.mockImplementation(() => undefined); + + const result = gridCtrl.focusInnerElement(true); + + expect(result).toBe(true); + expect(focusSvc.focusGridView).toHaveBeenCalledWith({ column: lastColumn, backwards: true }); + expect(document.activeElement).not.toBe(rowGroupButton); + }); + + test('tabToNextGridContainer: shift-tab from unmanaged element should target the last grid container', () => { + const tabGuard = document.createElement('div'); + tabGuard.tabIndex = 0; + rootDiv.appendChild(tabGuard); + tabGuard.focus(); + + const focusSvc = gridCtrlAny.beans.focusSvc; + focusSvc.getDefaultTabToNextGridContainerTarget.mockImplementation( + ({ + focusableContainers, + nextIndex, + }: { + focusableContainers: FocusableContainer[]; + nextIndex: number; + }) => { + if (nextIndex < 0 || nextIndex >= focusableContainers.length) { + return null; + } + + return focusableContainers[nextIndex].getFocusableContainerName(); + } + ); + + const tabToNextGridContainer = jest.fn(() => false); + getCallback.mockImplementation((key) => + key === 'tabToNextGridContainer' ? tabToNextGridContainer : undefined + ); + + const result = gridCtrl.focusNextInnerContainer(true); + + expect(result).toBe(false); + expect(tabToNextGridContainer).toHaveBeenCalledWith({ + backwards: true, + fromContainer: 'external', + toContainer: 'pagination', + defaultTarget: 'pagination', + }); + }); + + test('tabToNextGridContainer: tab from unmanaged element enters first grid container by default', () => { + const externalButton = createFocusableButton(); + rootDiv.appendChild(externalButton); + externalButton.focus(); + getCallback.mockImplementation(() => undefined); + const focusSvc = gridCtrlAny.beans.focusSvc; + focusSvc.focusFirstHeader.mockReturnValue(true); + + const result = gridCtrl.focusNextInnerContainer(false); + + expect(result).toBe(true); + expect(focusSvc.focusFirstHeader).toHaveBeenCalledTimes(1); + expect(focusSvc.focusGridView).not.toHaveBeenCalled(); + }); + + test('tabToNextGridContainer: shift-tab from unmanaged element enters last grid container by default', () => { + const paginationButton = appendFocusableButton(paginationContainer); + const externalButton = createFocusableButton(); + rootDiv.appendChild(externalButton); + externalButton.focus(); + getCallback.mockImplementation(() => undefined); + + const result = gridCtrl.focusNextInnerContainer(true); + + expect(result).toBe(true); + expect(document.activeElement).toBe(paginationButton); + }); + + test('tabToNextGridContainer: shift-tab from unmanaged element into gridBody-only uses grid body default target', () => { + const gridBodyViewport = appendFocusableButton(gridBodyContainer); + gridCtrlAny.view.getFocusableContainers = () => [gridBodyContainer]; + + const externalButton = createFocusableButton(); + rootDiv.appendChild(externalButton); + externalButton.focus(); + getCallback.mockImplementation(() => undefined); + + const lastColumn = createColumn('sport'); + gridCtrlAny.beans.visibleCols.allCols = [lastColumn]; + const focusSvc = gridCtrlAny.beans.focusSvc; + focusSvc.focusGridView.mockReturnValue(true); + + const result = gridCtrl.focusNextInnerContainer(true); + + expect(result).toBe(true); + expect(focusSvc.focusGridView).toHaveBeenCalledWith({ column: lastColumn, backwards: true }); + expect(document.activeElement).not.toBe(gridBodyViewport); + }); + }); + + describe('GridHeaderCtrl', () => { + let headerCtrl: GridHeaderCtrl; + let headerCtrlAny: any; + let gos: jest.Mocked; + let getOption: jest.Mock; + let gridCtrl: { + focusNextInnerContainer: jest.Mock; + forceFocusOutOfContainer: jest.Mock; + isDetailGrid: jest.Mock; + isFocusInsideGridBody: jest.Mock; + }; + let headerNavigation: { + navigateHorizontally: jest.Mock; + }; + let focusSvc: { + focusOverlay: jest.Mock; + }; + + const createTabEvent = (shiftKey = false): KeyboardEvent => + new KeyboardEvent('keydown', { key: 'Tab', shiftKey, cancelable: true }); + + beforeEach(() => { + headerCtrl = new GridHeaderCtrl(); + headerCtrlAny = headerCtrl as any; + + gos = mock('get'); + getOption = gos.get as unknown as jest.Mock; + getOption.mockImplementation((key) => (key === 'enableRtl' ? false : undefined)); + + gridCtrl = { + focusNextInnerContainer: jest.fn((_backwards: boolean) => undefined), + forceFocusOutOfContainer: jest.fn(), + isDetailGrid: jest.fn(() => false), + isFocusInsideGridBody: jest.fn(() => true), + }; + + headerNavigation = { + navigateHorizontally: jest.fn((_direction: string, _fromTab: boolean, _event: KeyboardEvent) => false), + }; + focusSvc = { + focusOverlay: jest.fn(() => false), + }; + + headerCtrlAny.gos = gos; + headerCtrlAny.beans = { + gos, + headerNavigation, + focusSvc, + ctrlsSvc: { + get: jest.fn(() => gridCtrl), + }, + }; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('tab from header: successful core-container move prevents default once', () => { + gridCtrl.focusNextInnerContainer.mockReturnValue(true); + const event = createTabEvent(false); + + headerCtrlAny.onTabKeyDown(event); + + expect(event.defaultPrevented).toBe(true); + expect(gridCtrl.focusNextInnerContainer).toHaveBeenCalledTimes(1); + expect(gridCtrl.focusNextInnerContainer).toHaveBeenCalledWith(false); + }); + + test('tab from header: callback false preserves browser default flow', () => { + gridCtrl.focusNextInnerContainer.mockReturnValue(false); + const event = createTabEvent(false); + + headerCtrlAny.onTabKeyDown(event); + + expect(event.defaultPrevented).toBe(false); + expect(gridCtrl.focusNextInnerContainer).toHaveBeenCalledTimes(1); + expect(gridCtrl.focusNextInnerContainer).toHaveBeenCalledWith(false); + expect(gridCtrl.forceFocusOutOfContainer).not.toHaveBeenCalled(); + }); + + test('shift-tab from header: callback false preserves browser default and does not re-enter fallback path', () => { + gridCtrl.focusNextInnerContainer.mockReturnValue(false); + const event = createTabEvent(true); + + headerCtrlAny.onTabKeyDown(event); + + expect(event.defaultPrevented).toBe(false); + expect(gridCtrl.focusNextInnerContainer).toHaveBeenCalledTimes(1); + expect(gridCtrl.focusNextInnerContainer).toHaveBeenCalledWith(true); + expect(gridCtrl.forceFocusOutOfContainer).not.toHaveBeenCalled(); + }); + + test('shift-tab from header: unresolved movement forces focus out', () => { + gridCtrl.focusNextInnerContainer.mockReturnValue(undefined); + const event = createTabEvent(true); + + headerCtrlAny.onTabKeyDown(event); + + expect(event.defaultPrevented).toBe(true); + expect(gridCtrl.focusNextInnerContainer).toHaveBeenCalledTimes(1); + expect(gridCtrl.focusNextInnerContainer).toHaveBeenCalledWith(true); + expect(gridCtrl.forceFocusOutOfContainer).toHaveBeenCalledWith(true); + }); + + test('shift-tab from header: successful first move does not trigger fallback call', () => { + gridCtrl.focusNextInnerContainer.mockReturnValue(true); + const event = createTabEvent(true); + + headerCtrlAny.onTabKeyDown(event); + + expect(event.defaultPrevented).toBe(true); + expect(gridCtrl.focusNextInnerContainer).toHaveBeenCalledTimes(1); + expect(gridCtrl.focusNextInnerContainer).toHaveBeenCalledWith(true); + }); + }); + + describe('Grid focus container flow', () => { + test('tabToNextGridContainer: false from grid body should not force focus out', () => { + const gridCtrl = { + focusNextInnerContainer: jest.fn(() => false), + forceFocusOutOfContainer: jest.fn(), + isDetailGrid: jest.fn(() => false), + isFocusInsideGridBody: jest.fn(() => true), + }; + + const beans = { + ctrlsSvc: { + get: jest.fn(() => gridCtrl), + }, + } as any; + + const result = _focusNextGridCoreContainer(beans, false); + + expect(result).toBe(false); + expect(gridCtrl.focusNextInnerContainer).toHaveBeenCalledWith(false); + expect(gridCtrl.forceFocusOutOfContainer).not.toHaveBeenCalled(); + }); + + test('focus flow: unresolved forward movement in grid body forces focus out', () => { + const gridCtrl = { + focusNextInnerContainer: jest.fn(() => undefined), + forceFocusOutOfContainer: jest.fn(), + isDetailGrid: jest.fn(() => false), + isFocusInsideGridBody: jest.fn(() => true), + }; + + const beans = { + ctrlsSvc: { + get: jest.fn(() => gridCtrl), + }, + } as any; + + const result = _focusNextGridCoreContainer(beans, false); + + expect(result).toBe(false); + expect(gridCtrl.focusNextInnerContainer).toHaveBeenCalledWith(false); + expect(gridCtrl.forceFocusOutOfContainer).toHaveBeenCalledWith(false); + }); + + test('focus flow: unresolved forward movement outside grid body does not force focus out', () => { + const gridCtrl = { + focusNextInnerContainer: jest.fn(() => undefined), + forceFocusOutOfContainer: jest.fn(), + isDetailGrid: jest.fn(() => false), + isFocusInsideGridBody: jest.fn(() => false), + }; + + const beans = { + ctrlsSvc: { + get: jest.fn(() => gridCtrl), + }, + } as any; + + const result = _focusNextGridCoreContainer(beans, false); + + expect(result).toBe(false); + expect(gridCtrl.focusNextInnerContainer).toHaveBeenCalledWith(false); + expect(gridCtrl.forceFocusOutOfContainer).not.toHaveBeenCalled(); + }); + + test('force-out path still attempts next inner container to allow overrides', () => { + const gridCtrl = { + focusNextInnerContainer: jest.fn(() => undefined), + forceFocusOutOfContainer: jest.fn(), + isDetailGrid: jest.fn(() => false), + isFocusInsideGridBody: jest.fn(() => true), + }; + + const beans = { + ctrlsSvc: { + get: jest.fn(() => gridCtrl), + }, + } as any; + + _focusNextGridCoreContainer(beans, false, true); + + expect(gridCtrl.focusNextInnerContainer).toHaveBeenCalledWith(false); + expect(gridCtrl.forceFocusOutOfContainer).toHaveBeenCalledWith(false); + }); + + test('tabToNextGridContainer: false should always preserve browser-default flow, including forceOut path', () => { + const gridCtrl = { + focusNextInnerContainer: jest.fn(() => false), + forceFocusOutOfContainer: jest.fn(), + isDetailGrid: jest.fn(() => false), + isFocusInsideGridBody: jest.fn(() => true), + }; + + const beans = { + ctrlsSvc: { + get: jest.fn(() => gridCtrl), + }, + } as any; + + const result = _focusNextGridCoreContainer(beans, false, true); + + expect(result).toBe(false); + expect(gridCtrl.focusNextInnerContainer).toHaveBeenCalledWith(false); + expect(gridCtrl.forceFocusOutOfContainer).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/ag-grid-community/src/focusService.ts b/packages/ag-grid-community/src/focusService.ts index b362b76af2f..a155d8265a5 100644 --- a/packages/ag-grid-community/src/focusService.ts +++ b/packages/ag-grid-community/src/focusService.ts @@ -17,9 +17,14 @@ import { _getDomData } from './gridOptionsUtils'; import { DOM_DATA_KEY_HEADER_CTRL } from './headerRendering/cells/abstractCell/abstractHeaderCellCtrl'; import type { HeaderCellCtrl } from './headerRendering/cells/column/headerCellCtrl'; import { getFocusHeaderRowCount, isHeaderPositionEqual } from './headerRendering/headerUtils'; -import type { NavigateToNextHeaderParams, TabToNextHeaderParams } from './interfaces/iCallbackParams'; +import type { + NavigateToNextHeaderParams, + TabToNextGridContainerTarget, + TabToNextHeaderParams, +} from './interfaces/iCallbackParams'; import type { CellPosition } from './interfaces/iCellPosition'; import type { WithoutGridCommon } from './interfaces/iCommon'; +import type { FocusableContainer } from './interfaces/iFocusableContainer'; import type { HeaderPosition } from './interfaces/iHeaderPosition'; import type { RowPinnedType } from './interfaces/iRowNode'; import { getHeaderIndexToFocus } from './navigation/headerNavigationService'; @@ -27,7 +32,12 @@ import type { NavigationService } from './navigation/navigationService'; import type { OverlayService } from './rendering/overlays/overlayService'; import { DOM_DATA_KEY_CELL_CTRL, DOM_DATA_KEY_ROW_CTRL } from './rendering/renderUtils'; import type { RowRenderer } from './rendering/rowRenderer'; -import { _focusNextGridCoreContainer, _isCellFocusSuppressed, _isHeaderFocusSuppressed } from './utils/gridFocus'; +import { + _focusNextGridCoreContainer, + _getDefaultTabTargetForContainer, + _isCellFocusSuppressed, + _isHeaderFocusSuppressed, +} from './utils/gridFocus'; type FocusDirection = 'Before' | 'After' | null; @@ -358,7 +368,8 @@ export class FocusService extends BeanStub implements NamedBean { headerRowCount, event, }; - headerPosition = userFunc(params); + const userResult = userFunc(params); + headerPosition = userResult === null ? currentPosition : userResult; } } } @@ -527,6 +538,117 @@ export class FocusService extends BeanStub implements NamedBean { return !!overlayGui && _focusInto(overlayGui, backwards); } + public getDefaultTabToNextGridContainerTarget(params: { + backwards: boolean; + focusableContainers: FocusableContainer[]; + nextIndex: number; + }): TabToNextGridContainerTarget | null { + const { backwards, focusableContainers } = params; + const step = backwards ? -1 : 1; + let gridBodyTarget: TabToNextGridContainerTarget | null | undefined; + const getGridBodyTabTarget = (): TabToNextGridContainerTarget | null => { + if (gridBodyTarget === undefined) { + gridBodyTarget = this.getGridBodyTabTarget(backwards); + } + + return gridBodyTarget; + }; + + // walk container order in tab direction and return the first default target candidate. + for (let index = params.nextIndex; index >= 0 && index < focusableContainers.length; index += step) { + const target = _getDefaultTabTargetForContainer(focusableContainers[index], getGridBodyTabTarget); + if (target) { + return target; + } + } + + return null; + } + + private getGridBodyTabTarget(backwards: boolean): CellPosition | HeaderPosition | null { + if (backwards) { + return this.getGridViewTabTarget({ column: _last(this.visibleCols.allCols), backwards: true }); + } + + const firstColumn = this.visibleCols.allCols[0]; + + // forward tab into grid body mirrors focusGridBodyDefault: + // headers first when enabled, otherwise first focusable grid cell. + if (this.gos.get('headerHeight') === 0 || _isHeaderFocusSuppressed(this.beans)) { + return this.getGridViewTabTarget({ column: firstColumn }); + } + + if (!firstColumn) { + return null; + } + + return getHeaderIndexToFocus(this.beans, firstColumn, 0); + } + + private getGridViewTabTarget(params: { + column?: AgColumn; + backwards?: boolean; + }): CellPosition | HeaderPosition | null { + const { backwards = false } = params; + const column: AgColumn | undefined = params.column ?? (this.focusedHeader?.column as AgColumn | undefined); + + // compute the grid-view focus target without moving browser focus or scrolling. + if (!column) { + return null; + } + + if (this.overlays?.exclusive) { + return null; + } + + if (_isCellFocusSuppressed(this.beans)) { + return backwards && !_isHeaderFocusSuppressed(this.beans) + ? { + headerRowIndex: getFocusHeaderRowCount(this.beans) - 1, + column, + } + : null; + } + + const nextRow = backwards ? _getLastRow(this.beans) : _getFirstRow(this.beans); + if (nextRow?.rowIndex == null) { + if (this.overlays?.isVisible()) { + return null; + } + + if (backwards && !_isHeaderFocusSuppressed(this.beans)) { + const lastColumn = _last(this.visibleCols.allCols); + if (lastColumn) { + return { + headerRowIndex: getFocusHeaderRowCount(this.beans) - 1, + column: lastColumn, + }; + } + } + + return null; + } + + const rowNode = _getRowNode(this.beans, nextRow); + + if (!rowNode || column.isSuppressNavigable(rowNode)) { + return null; + } + + if (backwards) { + const rowCtrl = this.rowRenderer.getRowByPosition(nextRow); + if (rowCtrl?.isFullWidth()) { + return null; + } + } + + return { + rowIndex: nextRow.rowIndex, + rowPinned: nextRow.rowPinned, + column, + }; + } + public focusGridView(params: { column?: AgColumn; backwards?: boolean; @@ -637,9 +759,8 @@ export class FocusService extends BeanStub implements NamedBean { headerRowIndex: getFocusHeaderRowCount(this.beans) - 1, }, }); - } else { - return this.focusGridView({ column }); } + return this.focusGridView({ column }); } public clearAdvancedFilterColumn(): void { diff --git a/packages/ag-grid-community/src/gridBodyComp/gridBodyComp.ts b/packages/ag-grid-community/src/gridBodyComp/gridBodyComp.ts index 6014edcbef4..c29245c9734 100644 --- a/packages/ag-grid-community/src/gridBodyComp/gridBodyComp.ts +++ b/packages/ag-grid-community/src/gridBodyComp/gridBodyComp.ts @@ -3,6 +3,7 @@ import { _setAriaColCount, _setAriaMultiSelectable, _setAriaRole, _setAriaRowCou import { _observeResize } from '../agStack/utils/dom'; import { _isCellSelectionEnabled, _isMultiRowSelection } from '../gridOptionsUtils'; import { GridHeaderSelector } from '../headerRendering/gridHeaderComp'; +import type { FocusableContainer } from '../interfaces/iFocusableContainer'; import { LayoutCssClasses } from '../styling/layoutFeature'; import type { ElementParams } from '../utils/element'; import type { ComponentSelector } from '../widgets/component'; @@ -104,7 +105,7 @@ function getGridBodyTemplate(includeOverlay?: boolean): { return { paramsMap, elementParams }; } -export class GridBodyComp extends Component { +export class GridBodyComp extends Component implements FocusableContainer { private readonly eGridRoot: HTMLElement = RefPlaceholder; private readonly eBodyViewport: HTMLElement = RefPlaceholder; private readonly eStickyTop: HTMLElement = RefPlaceholder; @@ -209,6 +210,10 @@ export class GridBodyComp extends Component { bodyViewportClassList.toggle('ag-row-animation' as RowAnimationCssClasses, animateRows); bodyViewportClassList.toggle('ag-row-no-animation' as RowAnimationCssClasses, !animateRows); } + + public getFocusableContainerName(): 'gridBody' { + return 'gridBody'; + } } export const GridBodySelector: ComponentSelector = { selector: 'AG-GRID-BODY', diff --git a/packages/ag-grid-community/src/gridComp/gridComp.ts b/packages/ag-grid-community/src/gridComp/gridComp.ts index 54b2e96563f..1633b792e9b 100644 --- a/packages/ag-grid-community/src/gridComp/gridComp.ts +++ b/packages/ag-grid-community/src/gridComp/gridComp.ts @@ -13,10 +13,16 @@ import { TabGuardComp } from '../widgets/tabGuardComp'; import type { IGridComp, OptionalGridComponents } from './gridCtrl'; import { GridCtrl } from './gridCtrl'; +interface HeaderDropZonesComp extends Component { + getFocusableContainers?(): FocusableContainer[]; +} + export class GridComp extends TabGuardComp { private readonly gridBody: GridBodyComp = RefPlaceholder; - private readonly sideBar: ISideBar & Component = RefPlaceholder; - private readonly pagination: TabGuardComp = RefPlaceholder; + private readonly gridHeaderDropZones: HeaderDropZonesComp = RefPlaceholder; + private readonly sideBar: ISideBar & Component & FocusableContainer = RefPlaceholder; + private readonly statusBar: Component & FocusableContainer = RefPlaceholder; + private readonly pagination: TabGuardComp & FocusableContainer = RefPlaceholder; private readonly rootWrapperBody: HTMLElement = RefPlaceholder; private readonly eGridDiv: HTMLElement; @@ -85,7 +91,7 @@ export class GridComp extends TabGuardComp { private createTemplate(params: OptionalGridComponents): ElementParams { const dropZones: ElementParams | null = params.gridHeaderDropZonesSelector - ? { tag: 'ag-grid-header-drop-zones' } + ? { tag: 'ag-grid-header-drop-zones', ref: 'gridHeaderDropZones' } : null; const sideBar: ElementParams | null = params.sideBarSelector ? { @@ -93,7 +99,9 @@ export class GridComp extends TabGuardComp { ref: 'sideBar', } : null; - const statusBar: ElementParams | null = params.statusBarSelector ? { tag: 'ag-status-bar' } : null; + const statusBar: ElementParams | null = params.statusBarSelector + ? { tag: 'ag-status-bar', ref: 'statusBar' } + : null; const watermark: ElementParams | null = params.watermarkSelector ? { tag: 'ag-watermark' } : null; const pagination: ElementParams | null = params.paginationSelector ? { tag: 'ag-pagination', ref: 'pagination' } @@ -132,9 +140,12 @@ export class GridComp extends TabGuardComp { } protected getFocusableContainers(): FocusableContainer[] { - const focusableContainers: FocusableContainer[] = [this.gridBody]; + const focusableContainers: FocusableContainer[] = [ + ...(this.gridHeaderDropZones?.getFocusableContainers?.() ?? []), + this.gridBody, + ]; - for (const comp of [this.sideBar, this.pagination]) { + for (const comp of [this.sideBar, this.statusBar, this.pagination]) { if (comp) { focusableContainers.push(comp); } diff --git a/packages/ag-grid-community/src/gridComp/gridCtrl.ts b/packages/ag-grid-community/src/gridComp/gridCtrl.ts index ae2d41f0a11..84b6ade11d4 100644 --- a/packages/ag-grid-community/src/gridComp/gridCtrl.ts +++ b/packages/ag-grid-community/src/gridComp/gridCtrl.ts @@ -4,10 +4,13 @@ import { _getActiveDomElement } from '../agStack/utils/document'; import { _observeResize } from '../agStack/utils/dom'; import { _findTabbableParent, _focusInto } from '../agStack/utils/focus'; import { BeanStub } from '../context/beanStub'; +import { isHeaderPosition } from '../headerRendering/headerUtils'; +import type { GridContainerName, TabToNextGridContainerTarget } from '../interfaces/iCallbackParams'; import type { FocusableContainer } from '../interfaces/iFocusableContainer'; import type { LayoutView } from '../styling/layoutFeature'; import { LayoutFeature } from '../styling/layoutFeature'; -import { _isCellFocusSuppressed, _isHeaderFocusSuppressed } from '../utils/gridFocus'; +import { _isCellFocusSuppressed, _isHeaderFocusSuppressed, _runWithContainerFocusAllowed } from '../utils/gridFocus'; +import { _consoleWarn } from '../utils/log'; import type { Component, ComponentSelector } from '../widgets/component'; export interface IGridComp extends LayoutView { @@ -27,6 +30,22 @@ export interface OptionalGridComponents { watermarkSelector?: ComponentSelector; } +const focusContainer = (comp: FocusableContainer, up?: boolean): boolean => { + return _runWithContainerFocusAllowed(comp, () => _focusInto(comp.getGui(), up, false, true)); +}; + +const getGridContainerName = (container?: FocusableContainer): GridContainerName => { + return container?.getFocusableContainerName() ?? 'external'; +}; + +const getDefaultTabToNextGridContainerTargetName = (target: TabToNextGridContainerTarget | null): GridContainerName => { + if (target == null) { + return 'external'; + } + + return typeof target === 'string' ? target : 'gridBody'; +}; + export class GridCtrl extends BeanStub { private view: IGridComp; private eGridHostDiv: HTMLElement; @@ -63,6 +82,7 @@ export class GridCtrl extends BeanStub { public getOptionalSelectors(): OptionalGridComponents { const beans = this.beans; + return { paginationSelector: beans.pagination?.getPaginationSelector(), gridHeaderDropZonesSelector: beans.registry?.getSelector('AG-GRID-HEADER-DROP-ZONES'), @@ -94,8 +114,7 @@ export class GridCtrl extends BeanStub { if (direction === false) { view.setCursor(null); } else { - const cursor = direction === Direction.Horizontal ? 'ew-resize' : 'ns-resize'; - view.setCursor(cursor); + view.setCursor(direction === Direction.Horizontal ? 'ew-resize' : 'ns-resize'); } } @@ -103,51 +122,109 @@ export class GridCtrl extends BeanStub { this.view.setUserSelect(on ? 'none' : null); } - public focusNextInnerContainer(backwards: boolean): boolean { + public focusNextInnerContainer(backwards: boolean): boolean | undefined { const focusableContainers = this.getFocusableContainers(); const { indexWithFocus, nextIndex } = this.getNextFocusableIndex(focusableContainers, backwards); + const resolvedNextIndex = indexWithFocus === -1 ? (backwards ? focusableContainers.length - 1 : 0) : nextIndex; + const { + gos, + beans: { focusSvc, navigation }, + } = this; + const userCallbackFunction = gos.getCallback('tabToNextGridContainer'); + + if (userCallbackFunction) { + const defaultTarget = focusSvc.getDefaultTabToNextGridContainerTarget({ + backwards, + focusableContainers, + nextIndex: resolvedNextIndex, + }); + + const nextContainerName = getGridContainerName(focusableContainers[resolvedNextIndex]); + const toContainer = + defaultTarget == null && nextContainerName === 'gridBody' + ? 'gridBody' + : getDefaultTabToNextGridContainerTargetName(defaultTarget); + + const userResult = userCallbackFunction({ + backwards, + fromContainer: getGridContainerName(focusableContainers[indexWithFocus]), + toContainer, + defaultTarget, + }); + + if (userResult !== undefined) { + if (typeof userResult === 'boolean') { + return userResult; + } - if (nextIndex < 0 || nextIndex >= focusableContainers.length) { - return false; - } + if (typeof userResult === 'string') { + if (userResult === 'gridBody') { + return this.focusGridBodyDefault(backwards) || undefined; + } - if (nextIndex === 0) { - if (indexWithFocus > 0) { - const { visibleCols, focusSvc } = this.beans; - const allColumns = visibleCols.allCols; - const lastColumn = _last(allColumns); - if (focusSvc.focusGridView({ column: lastColumn, backwards: true })) { - return true; + const targetContainer = focusableContainers.find( + (container) => container.getFocusableContainerName() === userResult + ); + if (!targetContainer) { + _consoleWarn(`tabToNextGridContainer - ${userResult} container not found`); + return undefined; + } + + return focusContainer(targetContainer, backwards) ? true : undefined; + } + + if (isHeaderPosition(userResult)) { + return focusSvc.focusHeaderPosition({ headerPosition: userResult }) || undefined; } + + navigation?.ensureCellVisible(userResult); + focusSvc.setFocusedCell({ ...userResult, forceBrowserFocus: true }); + return focusSvc.isCellFocused(userResult) || undefined; } - return false; } - return this.focusContainer(focusableContainers[nextIndex], backwards); + return ( + this.focusNextInnerContainerDefault({ + backwards, + focusableContainers, + indexWithFocus, + nextIndex: resolvedNextIndex, + }) || undefined + ); } public focusInnerElement(fromBottom?: boolean): boolean { - const userCallbackFunction = this.gos.getCallback('focusGridInnerElement'); + const { + gos, + beans, + beans: { focusSvc, visibleCols }, + } = this; + const userCallbackFunction = gos.getCallback('focusGridInnerElement'); if (userCallbackFunction?.({ fromBottom: !!fromBottom })) { return true; } const focusableContainers = this.getFocusableContainers(); - const { focusSvc, visibleCols } = this.beans; - const allColumns = visibleCols.allCols; if (fromBottom) { - if (focusableContainers.length > 1) { - return this.focusContainer(_last(focusableContainers), fromBottom); - } - - const lastColumn = _last(allColumns); - if (focusSvc.focusGridView({ column: lastColumn, backwards: fromBottom })) { + if ( + this.focusNextInnerContainerDefault({ + backwards: true, + focusableContainers, + indexWithFocus: focusableContainers.length, + nextIndex: focusableContainers.length - 1, + }) + ) { return true; } + + // preserve previous bottom-entry fallback for async row model timing. + return focusSvc.focusGridView({ column: _last(visibleCols.allCols), backwards: true }); } - if (this.gos.get('headerHeight') === 0 || _isHeaderFocusSuppressed(this.beans)) { + const allColumns = visibleCols.allCols; + + if (gos.get('headerHeight') === 0 || _isHeaderFocusSuppressed(beans)) { if (focusSvc.focusGridView({ column: allColumns[0], backwards: fromBottom })) { return true; } @@ -167,6 +244,12 @@ export class GridCtrl extends BeanStub { this.view.forceFocusOutOfContainer(up); } + public isFocusInsideGridBody(): boolean { + const focusableContainers = this.getFocusableContainers(); + const { indexWithFocus } = this.getNextFocusableIndex(focusableContainers); + return focusableContainers[indexWithFocus]?.getFocusableContainerName() === 'gridBody'; + } + public addFocusableContainer(container: FocusableContainer): void { this.additionalFocusableContainers.add(container); } @@ -177,17 +260,18 @@ export class GridCtrl extends BeanStub { public allowFocusForNextCoreContainer(up?: boolean): void { const coreContainers = this.view.getFocusableContainers(); - const { nextIndex } = this.getNextFocusableIndex(coreContainers, up); - - const comp = coreContainers[nextIndex]; - - // if we got to this point, it means the user wants the browser's default focus behavior - // we can no longer allow the browser's default behavior because scrollable divs are - // considered focusable which causes the focus to become lost within the grid. So, here - // we attempt to throw focus into the next container within the grid, or push focus out. - if (comp) { - this.focusContainer(comp); - } else { + const { indexWithFocus, nextIndex } = this.getNextFocusableIndex(coreContainers, up); + + // browser default tabbing can focus unmanaged scrollable elements and lose focus context. + // move focus to the next reachable core container first; if none can take focus, push focus out. + if ( + !this.focusNextInnerContainerDefault({ + backwards: !!up, + focusableContainers: coreContainers, + indexWithFocus, + nextIndex, + }) + ) { this.forceFocusOutOfContainer(up); } } @@ -208,18 +292,62 @@ export class GridCtrl extends BeanStub { } { const activeEl = _getActiveDomElement(this.beans); const indexWithFocus = focusableContainers.findIndex((container) => container.getGui().contains(activeEl)); - const nextIndex = indexWithFocus + (backwards ? -1 : 1); - return { - indexWithFocus, - nextIndex, - }; + + return { indexWithFocus, nextIndex: indexWithFocus + (backwards ? -1 : 1) }; + } + + private focusGridBodyDefault(backwards: boolean): boolean { + const { + gos, + beans, + beans: { + focusSvc, + visibleCols: { allCols }, + }, + } = this; + if (backwards) { + return focusSvc.focusGridView({ column: _last(allCols), backwards: true }); + } + + if (gos.get('headerHeight') === 0 || _isHeaderFocusSuppressed(beans)) { + return focusSvc.focusGridView({ column: allCols[0] }); + } + + return focusSvc.focusFirstHeader(); } - private focusContainer(comp: FocusableContainer, up?: boolean): boolean { - comp.setAllowFocus?.(true); - const result = _focusInto(comp.getGui(), up, false, true); - comp.setAllowFocus?.(false); - return result; + private focusNextInnerContainerDefault(params: { + backwards: boolean; + focusableContainers: FocusableContainer[]; + indexWithFocus: number; + nextIndex: number; + }): boolean { + const { backwards, focusableContainers, indexWithFocus } = params; + const step = backwards ? -1 : 1; + + // walk container order in tab direction and focus the first target that can accept focus. + for (let index = params.nextIndex; index >= 0 && index < focusableContainers.length; index += step) { + const container = focusableContainers[index]; + const containerName = container.getFocusableContainerName(); + + // grid body transitions should restore a real grid target, not focus structural wrappers. + if (containerName === 'gridBody') { + const enteringGridBody = + indexWithFocus === -1 || (backwards ? indexWithFocus > index : indexWithFocus < index); + if (enteringGridBody) { + if (this.focusGridBodyDefault(backwards)) { + return true; + } + continue; + } + } + + if (focusContainer(container, backwards)) { + return true; + } + } + + return false; } private getFocusableContainers(): FocusableContainer[] { diff --git a/packages/ag-grid-community/src/headerRendering/gridHeaderCtrl.ts b/packages/ag-grid-community/src/headerRendering/gridHeaderCtrl.ts index cac6961aad2..8de74dea202 100644 --- a/packages/ag-grid-community/src/headerRendering/gridHeaderCtrl.ts +++ b/packages/ag-grid-community/src/headerRendering/gridHeaderCtrl.ts @@ -5,7 +5,6 @@ import { _exists } from '../agStack/utils/generic'; import { BeanStub } from '../context/beanStub'; import type { BeanCollection } from '../context/context'; import type { HeaderNavigationDirection } from '../navigation/headerNavigationService'; -import { _focusNextGridCoreContainer } from '../utils/gridFocus'; import { ManagedFocusFeature } from '../widgets/managedFocusFeature'; import { getColumnHeaderRowHeight, getFloatingFiltersHeight, getGroupRowsHeight } from './headerUtils'; @@ -130,11 +129,22 @@ export class GridHeaderCtrl extends BeanStub { const { beans } = this; const { headerNavigation, focusSvc } = beans; - if ( - headerNavigation!.navigateHorizontally(direction, true, e) || - (!backwards && focusSvc.focusOverlay(false)) || - _focusNextGridCoreContainer(beans, backwards, true) - ) { + let focused = + headerNavigation!.navigateHorizontally(direction, true, e) || (!backwards && focusSvc.focusOverlay(false)); + + if (!focused) { + const gridCtrl = beans.ctrlsSvc.get('gridCtrl'); + const focusResult = gridCtrl.focusNextInnerContainer(backwards); + + if (focusResult === true) { + focused = true; + } else if (focusResult === undefined) { + gridCtrl.forceFocusOutOfContainer(backwards); + focused = true; + } + } + + if (focused) { // preventDefault so that the tab key doesn't cause focus to get lost e.preventDefault(); } diff --git a/packages/ag-grid-community/src/headerRendering/headerUtils.ts b/packages/ag-grid-community/src/headerRendering/headerUtils.ts index dfbc8ae30b5..8da05e4a6fa 100644 --- a/packages/ag-grid-community/src/headerRendering/headerUtils.ts +++ b/packages/ag-grid-community/src/headerRendering/headerUtils.ts @@ -92,3 +92,7 @@ function getPivotGroupHeaderHeight(beans: BeanCollection): number { export function isHeaderPositionEqual(headerPosA: HeaderPosition, headerPosB: HeaderPosition): boolean { return headerPosA.headerRowIndex === headerPosB.headerRowIndex && headerPosA.column === headerPosB.column; } + +export function isHeaderPosition(position: unknown): position is HeaderPosition { + return (position as HeaderPosition)?.headerRowIndex != null; +} diff --git a/packages/ag-grid-community/src/interfaces/iCallbackParams.ts b/packages/ag-grid-community/src/interfaces/iCallbackParams.ts index 933f3e2a985..8491b864cba 100644 --- a/packages/ag-grid-community/src/interfaces/iCallbackParams.ts +++ b/packages/ag-grid-community/src/interfaces/iCallbackParams.ts @@ -3,6 +3,7 @@ import type { CellPosition } from './iCellPosition'; import type { ChartToolbarMenuItemOptions, DefaultChartMenuItem } from './iChartOptions'; import type { Column, ProvidedColumnGroup } from './iColumn'; import type { AgGridCommon } from './iCommon'; +import type { FocusableContainerName } from './iFocusableContainer'; import type { HeaderPosition } from './iHeaderPosition'; import type { IRowNode, RowPinnedType } from './iRowNode'; import type { DefaultMenuItem } from './menuItem'; @@ -141,6 +142,22 @@ export interface TabToNextCellParams extends AgGrid nextCellPosition: CellPosition | null; } +export type GridContainerName = FocusableContainerName | 'external'; +export type TabToNextGridContainerTarget = CellPosition | HeaderPosition | FocusableContainerName; +export type TabToNextGridContainer = ( + params: TabToNextGridContainerParams +) => TabToNextGridContainerTarget | boolean | undefined; +export interface TabToNextGridContainerParams extends AgGridCommon { + /** True if the Shift key is also down. */ + backwards: boolean; + /** The container that currently has focus. */ + fromContainer: GridContainerName; + /** The container the grid would normally focus next. */ + toContainer: GridContainerName; + /** The target the grid would normally focus when moving to `toContainer`, or `null` if it can't be represented. */ + defaultTarget: TabToNextGridContainerTarget | null; +} + export type NavigateToNextCell = ( params: NavigateToNextCellParams ) => CellPosition | null; diff --git a/packages/ag-grid-community/src/interfaces/iFocusableContainer.ts b/packages/ag-grid-community/src/interfaces/iFocusableContainer.ts index 035ee5fdbbf..50f1efc5fc7 100644 --- a/packages/ag-grid-community/src/interfaces/iFocusableContainer.ts +++ b/packages/ag-grid-community/src/interfaces/iFocusableContainer.ts @@ -1,4 +1,14 @@ +export type FocusableContainerName = + | 'dialog' + | 'gridBody' + | 'pagination' + | 'pivotToolbar' + | 'rowGroupToolbar' + | 'sideBar' + | 'statusBar'; + export interface FocusableContainer { getGui(): HTMLElement; + getFocusableContainerName(): FocusableContainerName; setAllowFocus?(allowFocus: boolean): void; } diff --git a/packages/ag-grid-community/src/main.ts b/packages/ag-grid-community/src/main.ts index 5e8a4b45c13..7c40b1e33f2 100644 --- a/packages/ag-grid-community/src/main.ts +++ b/packages/ag-grid-community/src/main.ts @@ -812,6 +812,7 @@ export { GetRowIdParams, GetServerSideGroupLevelParams, GetServerSideGroupLevelParamsParams, + GridContainerName, IMenuActionParams, InitialGroupOrderComparator, InitialGroupOrderComparatorParams, @@ -847,6 +848,9 @@ export { ServerSideStoreParams, TabToNextCell, TabToNextCellParams, + TabToNextGridContainer, + TabToNextGridContainerParams, + TabToNextGridContainerTarget, TabToNextHeader, TabToNextHeaderParams, } from './interfaces/iCallbackParams'; diff --git a/packages/ag-grid-community/src/navigation/navigationService.ts b/packages/ag-grid-community/src/navigation/navigationService.ts index 9ca2376093f..eec341db060 100644 --- a/packages/ag-grid-community/src/navigation/navigationService.ts +++ b/packages/ag-grid-community/src/navigation/navigationService.ts @@ -746,7 +746,10 @@ export class NavigationService extends BeanStub implements NamedBean { const headerLen = getFocusHeaderRowCount(beans); focusSvc.focusHeaderPosition({ - headerPosition: { headerRowIndex: headerLen + nextCell.rowIndex, column: currentCell.column }, + headerPosition: { + headerRowIndex: headerLen + nextCell.rowIndex, + column: nextCell.column ?? currentCell.column, + }, event: event || undefined, fromCell: true, }); diff --git a/packages/ag-grid-community/src/pagination/paginationComp.ts b/packages/ag-grid-community/src/pagination/paginationComp.ts index a5b2fd9d59b..b1e6fd010bf 100644 --- a/packages/ag-grid-community/src/pagination/paginationComp.ts +++ b/packages/ag-grid-community/src/pagination/paginationComp.ts @@ -97,6 +97,10 @@ class PaginationComp extends TabGuardComp implements FocusableContainer { this.allowFocusInnerElement = allowFocus; } + public getFocusableContainerName(): 'pagination' { + return 'pagination'; + } + private onPaginationChanged(): void { const isPaging = this.gos.get('pagination'); const paginationPanelEnabled = isPaging && !this.gos.get('suppressPaginationPanel'); diff --git a/packages/ag-grid-community/src/propertyKeys.ts b/packages/ag-grid-community/src/propertyKeys.ts index 9f80e4acaf1..11094d179fb 100644 --- a/packages/ag-grid-community/src/propertyKeys.ts +++ b/packages/ag-grid-community/src/propertyKeys.ts @@ -373,6 +373,7 @@ export const _FUNCTION_GRID_OPTIONS: (CallbackKeys | FunctionKeys)[] = [ 'tabToNextHeader', 'navigateToNextCell', 'tabToNextCell', + 'tabToNextGridContainer', 'processCellFromClipboard', 'getDocument', 'postProcessPopup', diff --git a/packages/ag-grid-community/src/utils/gridFocus.ts b/packages/ag-grid-community/src/utils/gridFocus.ts index 0a4bd66fb53..6f76c57623c 100644 --- a/packages/ag-grid-community/src/utils/gridFocus.ts +++ b/packages/ag-grid-community/src/utils/gridFocus.ts @@ -1,15 +1,18 @@ import { KeyCode } from '../agStack/constants/keyCode'; import { _isNothingFocused } from '../agStack/utils/document'; -import { _findNextFocusableElement, _isKeyboardMode } from '../agStack/utils/focus'; +import { _findFocusableElements, _findNextFocusableElement, _isKeyboardMode } from '../agStack/utils/focus'; import type { BeanCollection } from '../context/context'; import { _areCellsEqual } from '../entities/positionUtils'; +import type { TabToNextGridContainerTarget } from '../interfaces/iCallbackParams'; import type { CellPosition } from '../interfaces/iCellPosition'; +import type { FocusableContainer } from '../interfaces/iFocusableContainer'; import type { Component } from '../widgets/component'; +import { _isStopPropagationForAgGrid } from './gridEvent'; export function _addFocusableContainerListener(beans: BeanCollection, comp: Component, eGui: HTMLElement): void { comp.addManagedElementListeners(eGui, { keydown: (e: KeyboardEvent) => { - if (!e.defaultPrevented && e.key === KeyCode.TAB) { + if (!e.defaultPrevented && !_isStopPropagationForAgGrid(e) && e.key === KeyCode.TAB) { const backwards = e.shiftKey; if (!_findNextFocusableElement(beans, eGui, false, backwards)) { if (_focusNextGridCoreContainer(beans, backwards)) { @@ -39,11 +42,18 @@ export function _focusNextGridCoreContainer( forceOut: boolean = false ): boolean { const gridCtrl = beans.ctrlsSvc.get('gridCtrl'); - if (!forceOut && gridCtrl.focusNextInnerContainer(backwards)) { + const focusResult = gridCtrl.focusNextInnerContainer(backwards); + + if (focusResult === true) { return true; } - if (forceOut || (!backwards && !gridCtrl.isDetailGrid())) { + // false from tabToNextGridContainer means browser-default tab flow. + if (focusResult === false) { + return focusResult; + } + + if (forceOut || (!backwards && !gridCtrl.isDetailGrid() && gridCtrl.isFocusInsideGridBody())) { gridCtrl.forceFocusOutOfContainer(backwards); } @@ -68,3 +78,31 @@ export function _attemptToRestoreCellFocus(beans: BeanCollection, focusedCell: C } } } + +export function _getDefaultTabTargetForContainer( + container: FocusableContainer, + getGridBodyTabTarget: () => TabToNextGridContainerTarget | null +): TabToNextGridContainerTarget | null { + const containerName = container.getFocusableContainerName(); + + // when moving into the grid body, default focus should land on a real grid target. + if (containerName === 'gridBody') { + return getGridBodyTabTarget(); + } + + return _runWithContainerFocusAllowed( + container, + () => _findFocusableElements(container.getGui(), '.ag-tab-guard').length > 0 + ) + ? containerName + : null; +} + +export function _runWithContainerFocusAllowed(container: FocusableContainer, callback: () => T): T { + container.setAllowFocus?.(true); + try { + return callback(); + } finally { + container.setAllowFocus?.(false); + } +} diff --git a/packages/ag-grid-community/src/utils/log.ts b/packages/ag-grid-community/src/utils/log.ts index 494ad256dfb..8f8bb950147 100644 --- a/packages/ag-grid-community/src/utils/log.ts +++ b/packages/ag-grid-community/src/utils/log.ts @@ -20,7 +20,7 @@ export function _consoleError(msg: string, ...args: any[]) { console.error('AG Grid: ' + msg, ...args); } -function _consoleWarn(msg: string, ...args: any[]) { +export function _consoleWarn(msg: string, ...args: any[]) { // eslint-disable-next-line no-console console.warn('AG Grid: ' + msg, ...args); } diff --git a/packages/ag-grid-enterprise/src/rowGrouping/columnDropZones/agGridHeaderDropZones.ts b/packages/ag-grid-enterprise/src/rowGrouping/columnDropZones/agGridHeaderDropZones.ts index cc64477d9b7..23b8b04fb5e 100644 --- a/packages/ag-grid-enterprise/src/rowGrouping/columnDropZones/agGridHeaderDropZones.ts +++ b/packages/ag-grid-enterprise/src/rowGrouping/columnDropZones/agGridHeaderDropZones.ts @@ -1,12 +1,12 @@ -import type { ComponentSelector } from 'ag-grid-community'; +import type { ComponentSelector, FocusableContainer } from 'ag-grid-community'; import { Component, _createElement } from 'ag-grid-community'; import { PivotDropZonePanel } from './pivotDropZonePanel'; import { RowGroupDropZonePanel } from './rowGroupDropZonePanel'; class AgGridHeaderDropZones extends Component { - private rowGroupComp: Component; - private pivotComp: Component; + private rowGroupComp: Component & FocusableContainer; + private pivotComp: Component & FocusableContainer; constructor() { super(); @@ -93,6 +93,10 @@ class AgGridHeaderDropZones extends Component { pivotComp.setDisplayed(false); } } + + public getFocusableContainers(): FocusableContainer[] { + return [this.rowGroupComp, this.pivotComp].filter((comp) => !!comp); + } } export const AgGridHeaderDropZonesSelector: ComponentSelector = { diff --git a/packages/ag-grid-enterprise/src/rowGrouping/columnDropZones/pivotDropZonePanel.ts b/packages/ag-grid-enterprise/src/rowGrouping/columnDropZones/pivotDropZonePanel.ts index 310363d7c8a..535319ac59f 100644 --- a/packages/ag-grid-enterprise/src/rowGrouping/columnDropZones/pivotDropZonePanel.ts +++ b/packages/ag-grid-enterprise/src/rowGrouping/columnDropZones/pivotDropZonePanel.ts @@ -1,9 +1,9 @@ -import type { AgColumn, DragAndDropIcon, GridDraggingEvent } from 'ag-grid-community'; -import { _createIconNoSpan } from 'ag-grid-community'; +import type { AgColumn, DragAndDropIcon, FocusableContainer, GridDraggingEvent } from 'ag-grid-community'; +import { _addFocusableContainerListener, _createIconNoSpan } from 'ag-grid-community'; import { BaseDropZonePanel } from './baseDropZonePanel'; -export class PivotDropZonePanel extends BaseDropZonePanel { +export class PivotDropZonePanel extends BaseDropZonePanel implements FocusableContainer { constructor(horizontal: boolean) { super(horizontal, 'pivot'); } @@ -19,6 +19,11 @@ export class PivotDropZonePanel extends BaseDropZonePanel { title: title, }); + // only the top (horizontal) drop zone participates in core grid container tabbing. + if (this.horizontal) { + _addFocusableContainerListener(this.beans, this, this.getGui()); + } + this.addManagedEventListeners({ newColumnsLoaded: this.refresh.bind(this), columnPivotChanged: this.refresh.bind(this), @@ -87,4 +92,8 @@ export class PivotDropZonePanel extends BaseDropZonePanel { protected getExistingItems(): AgColumn[] { return this.beans.pivotColsSvc?.columns ?? []; } + + public getFocusableContainerName(): 'pivotToolbar' { + return 'pivotToolbar'; + } } diff --git a/packages/ag-grid-enterprise/src/rowGrouping/columnDropZones/rowGroupDropZonePanel.ts b/packages/ag-grid-enterprise/src/rowGrouping/columnDropZones/rowGroupDropZonePanel.ts index c1d30f0b5fa..0932432770f 100644 --- a/packages/ag-grid-enterprise/src/rowGrouping/columnDropZones/rowGroupDropZonePanel.ts +++ b/packages/ag-grid-enterprise/src/rowGrouping/columnDropZones/rowGroupDropZonePanel.ts @@ -1,9 +1,9 @@ -import type { AgColumn, DragAndDropIcon, GridDraggingEvent } from 'ag-grid-community'; -import { _createIconNoSpan } from 'ag-grid-community'; +import type { AgColumn, DragAndDropIcon, FocusableContainer, GridDraggingEvent } from 'ag-grid-community'; +import { _addFocusableContainerListener, _createIconNoSpan } from 'ag-grid-community'; import { BaseDropZonePanel } from './baseDropZonePanel'; -export class RowGroupDropZonePanel extends BaseDropZonePanel { +export class RowGroupDropZonePanel extends BaseDropZonePanel implements FocusableContainer { constructor(horizontal: boolean) { super(horizontal, 'rowGroup'); } @@ -19,6 +19,11 @@ export class RowGroupDropZonePanel extends BaseDropZonePanel { title, }); + // only the top (horizontal) drop zone participates in core grid container tabbing. + if (this.horizontal) { + _addFocusableContainerListener(this.beans, this, this.getGui()); + } + this.addManagedEventListeners({ columnRowGroupChanged: this.refreshGui.bind(this) }); } @@ -49,4 +54,8 @@ export class RowGroupDropZonePanel extends BaseDropZonePanel { protected getExistingItems(): AgColumn[] { return this.beans.rowGroupColsSvc?.columns ?? []; } + + public getFocusableContainerName(): 'rowGroupToolbar' { + return 'rowGroupToolbar'; + } } diff --git a/packages/ag-grid-enterprise/src/sideBar/agSideBar.focus-overrides.test.ts b/packages/ag-grid-enterprise/src/sideBar/agSideBar.focus-overrides.test.ts new file mode 100644 index 00000000000..9411bb48b2c --- /dev/null +++ b/packages/ag-grid-enterprise/src/sideBar/agSideBar.focus-overrides.test.ts @@ -0,0 +1,74 @@ +import { _addFocusableContainerListener } from 'ag-grid-community'; + +import { AgSideBarSelector } from './agSideBar'; + +function createFocusableButton(): HTMLButtonElement { + const button = document.createElement('button'); + button.type = 'button'; + button.tabIndex = 0; + return button; +} + +describe('AgSideBar focus overrides', () => { + test.each([false, undefined])( + 'tab with no open panel evaluates next grid container once when focus result is %s', + (focusResult) => { + const sideBarGui = document.createElement('div'); + const sideBarButton = createFocusableButton(); + sideBarGui.appendChild(sideBarButton); + + const rootDiv = document.createElement('div'); + try { + rootDiv.appendChild(sideBarGui); + document.body.appendChild(rootDiv); + sideBarButton.focus(); + + const gridCtrl = { + focusNextInnerContainer: jest.fn((_backwards: boolean) => focusResult), + forceFocusOutOfContainer: jest.fn(), + isDetailGrid: jest.fn(() => false), + isFocusInsideGridBody: jest.fn(() => true), + }; + + const beans = { + eRootDiv: rootDiv, + ctrlsSvc: { + get: jest.fn(() => gridCtrl), + }, + }; + + const sideBarContext = { + beans, + sideBarButtons: { + getGui: () => sideBarGui, + }, + getGui: () => sideBarGui, + addManagedElementListeners: ( + element: HTMLElement, + listeners: { keydown?: (e: KeyboardEvent) => void } + ) => { + if (listeners.keydown) { + element.addEventListener('keydown', listeners.keydown as EventListener); + } + }, + }; + + const onTabKeyDown = (AgSideBarSelector.component as any).prototype.onTabKeyDown; + + // replicate enterprise sidebar wiring: managed focus listener + focusable container listener + sideBarContext.addManagedElementListeners(sideBarGui, { + keydown: (e: KeyboardEvent) => onTabKeyDown.call(sideBarContext, e), + }); + _addFocusableContainerListener(beans as any, sideBarContext as any, sideBarGui); + + sideBarButton.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }) + ); + + expect(gridCtrl.focusNextInnerContainer).toHaveBeenCalledTimes(1); + } finally { + rootDiv.remove(); + } + } + ); +}); diff --git a/packages/ag-grid-enterprise/src/sideBar/agSideBar.ts b/packages/ag-grid-enterprise/src/sideBar/agSideBar.ts index d930f9efa68..9543530667b 100644 --- a/packages/ag-grid-enterprise/src/sideBar/agSideBar.ts +++ b/packages/ag-grid-enterprise/src/sideBar/agSideBar.ts @@ -1,6 +1,7 @@ import type { ComponentSelector, ElementParams, + FocusableContainer, ISideBar, IToolPanel, IToolPanelParams, @@ -22,6 +23,7 @@ import { _isVisible, _removeFromParent, _setAriaControlsAndLabel, + _stopPropagationForAgGrid, _warn, } from 'ag-grid-community'; @@ -43,7 +45,7 @@ const AgSideBarElement: ElementParams = { }, ], }; -class AgSideBar extends Component implements ISideBar { +class AgSideBar extends Component implements ISideBar, FocusableContainer { private readonly sideBarButtons: AgSideBarButtons = RefPlaceholder; private toolPanelWrappers: ToolPanelWrapper[] = []; private sideBar: SideBarDef | undefined; @@ -79,6 +81,10 @@ class AgSideBar extends Component implements ISideBar { this.addManagedPropertyListener('enableAdvancedFilter', this.onAdvancedFilterChanged.bind(this)); } + public getFocusableContainerName(): 'sideBar' { + return 'sideBar'; + } + protected onTabKeyDown(e: KeyboardEvent) { if (e.defaultPrevented) { return; @@ -93,11 +99,13 @@ class AgSideBar extends Component implements ISideBar { const backwards = e.shiftKey; if (!openPanel) { - if (_focusNextGridCoreContainer(beans, backwards)) { + if (_focusNextGridCoreContainer(beans, backwards, true)) { e.preventDefault(); return true; } - return _focusNextGridCoreContainer(beans, backwards, true); + // avoid a second core-container evaluation from the generic focusable-container listener. + _stopPropagationForAgGrid(e); + return false; } if (sideBarGui.contains(activeElement)) { diff --git a/packages/ag-grid-enterprise/src/statusBar/agStatusBar.ts b/packages/ag-grid-enterprise/src/statusBar/agStatusBar.ts index e4c4a936d53..fa5b65b10b0 100644 --- a/packages/ag-grid-enterprise/src/statusBar/agStatusBar.ts +++ b/packages/ag-grid-enterprise/src/statusBar/agStatusBar.ts @@ -3,6 +3,7 @@ import type { ComponentSelector, ComponentType, ElementParams, + FocusableContainer, IStatusPanelComp, IStatusPanelParams, RowModelType, @@ -15,6 +16,7 @@ import { AgPromise, Component, RefPlaceholder, + _addFocusableContainerListener, _addGridCommonParams, _clearElement, _removeFromParent, @@ -69,7 +71,7 @@ const AgStatusBarElement: ElementParams = { }, ], }; -class AgStatusBar extends Component { +class AgStatusBar extends Component implements FocusableContainer { private userCompFactory: UserComponentFactory; private statusBarSvc: StatusBarService; private updateQueued: boolean = false; @@ -94,6 +96,11 @@ class AgStatusBar extends Component { public postConstruct(): void { this.processStatusPanels(new Map()); this.addManagedPropertyListeners(['statusBar'], this.handleStatusBarChanged.bind(this)); + _addFocusableContainerListener(this.beans, this, this.getGui()); + } + + public getFocusableContainerName(): 'statusBar' { + return 'statusBar'; } private getValidPanels(): StatusPanelDef[] | undefined { diff --git a/packages/ag-grid-enterprise/src/widgets/dialog.ts b/packages/ag-grid-enterprise/src/widgets/dialog.ts index b07841c1907..3bcffec1ea2 100644 --- a/packages/ag-grid-enterprise/src/widgets/dialog.ts +++ b/packages/ag-grid-enterprise/src/widgets/dialog.ts @@ -44,4 +44,8 @@ export class Dialog constructor(config: DialogOptions) { super(config, DIALOG_CALLBACKS); } + + public getFocusableContainerName(): 'dialog' { + return 'dialog'; + } } diff --git a/packages/ag-grid-react/src/reactUi/gridComp.tsx b/packages/ag-grid-react/src/reactUi/gridComp.tsx index bc1ad8fffff..b40930088df 100644 --- a/packages/ag-grid-react/src/reactUi/gridComp.tsx +++ b/packages/ag-grid-react/src/reactUi/gridComp.tsx @@ -21,6 +21,9 @@ interface GridCompProps { context: Context; } +type FocusableContainerComp = Component & FocusableContainer; +type HeaderDropZonesComp = Component & { getFocusableContainers?: () => FocusableContainerComp[] }; + const GridComp = ({ context }: GridCompProps) => { const [rtlClass, setRtlClass] = useState(''); const [layoutClass, setLayoutClass] = useState(''); @@ -37,7 +40,7 @@ const GridComp = ({ context }: GridCompProps) => { const focusInnerElementRef = useRef<(fromBottom?: boolean) => void>(() => undefined); const paginationCompRef = useRef(); - const focusableContainersRef = useRef([]); + const focusableContainersRef = useRef([]); const onTabKeyDown = useCallback(() => undefined, []); @@ -67,16 +70,31 @@ const GridComp = ({ context }: GridCompProps) => { }, updateLayoutClasses: setLayoutClass, getFocusableContainers: () => { - const comps: FocusableContainer[] = []; + const beforeGridBody: FocusableContainer[] = []; + const afterGridBody: FocusableContainer[] = []; const gridBodyCompEl = eRootWrapperRef.current?.querySelector('.ag-root'); - if (gridBodyCompEl) { - comps.push({ getGui: () => gridBodyCompEl as HTMLElement }); - } for (const comp of focusableContainersRef.current) { - if (comp.isDisplayed()) { - comps.push(comp); + if (!comp.isDisplayed()) { + continue; + } + + const name = comp.getFocusableContainerName(); + if (name === 'rowGroupToolbar' || name === 'pivotToolbar') { + beforeGridBody.push(comp); + continue; } + + afterGridBody.push(comp); + } + + const comps: FocusableContainer[] = [...beforeGridBody]; + if (gridBodyCompEl) { + comps.push({ + getGui: () => gridBodyCompEl as HTMLElement, + getFocusableContainerName: () => 'gridBody', + }); } + comps.push(...afterGridBody); return comps; }, setCursor, @@ -97,6 +115,8 @@ const GridComp = ({ context }: GridCompProps) => { } const beansToDestroy: any[] = []; + focusableContainersRef.current = []; + paginationCompRef.current = undefined; // these components are optional, so we check if they are registered before creating them const { @@ -109,11 +129,14 @@ const GridComp = ({ context }: GridCompProps) => { const additionalEls: HTMLElement[] = []; if (gridHeaderDropZonesSelector) { - const headerDropZonesComp = context.createBean(new gridHeaderDropZonesSelector.component()); + const headerDropZonesComp = context.createBean( + new gridHeaderDropZonesSelector.component() + ) as HeaderDropZonesComp; const eGui = headerDropZonesComp.getGui(); eRootWrapper.insertAdjacentElement('afterbegin', eGui); additionalEls.push(eGui); beansToDestroy.push(headerDropZonesComp); + focusableContainersRef.current.push(...(headerDropZonesComp.getFocusableContainers?.() ?? [])); } if (sideBarSelector) { @@ -126,7 +149,7 @@ const GridComp = ({ context }: GridCompProps) => { } beansToDestroy.push(sideBarComp); - focusableContainersRef.current.push(sideBarComp); + focusableContainersRef.current.push(sideBarComp as FocusableContainerComp); } const addComponentToDom = (component: ComponentSelector['component']) => { @@ -139,13 +162,14 @@ const GridComp = ({ context }: GridCompProps) => { }; if (statusBarSelector) { - addComponentToDom(statusBarSelector.component); + const statusBarComp = addComponentToDom(statusBarSelector.component); + focusableContainersRef.current.push(statusBarComp as FocusableContainerComp); } if (paginationSelector) { const paginationComp = addComponentToDom(paginationSelector.component); paginationCompRef.current = paginationComp as JsTabGuardComp; - focusableContainersRef.current.push(paginationComp); + focusableContainersRef.current.push(paginationComp as FocusableContainerComp); } if (watermarkSelector) { @@ -154,6 +178,8 @@ const GridComp = ({ context }: GridCompProps) => { return () => { context.destroyBeans(beansToDestroy); + focusableContainersRef.current = []; + paginationCompRef.current = undefined; for (const el of additionalEls) { el.remove(); } diff --git a/packages/ag-grid-vue3/src/components/utils.ts b/packages/ag-grid-vue3/src/components/utils.ts index 5997b76f2f8..eec769216e0 100644 --- a/packages/ag-grid-vue3/src/components/utils.ts +++ b/packages/ag-grid-vue3/src/components/utils.ts @@ -101,6 +101,7 @@ import type { SortDirection, StatusBar, TabToNextCell, + TabToNextGridContainer, TabToNextHeader, Theme, TreeDataDisplayType, @@ -1759,6 +1760,12 @@ export interface Props { * or `false` to let the browser handle the tab behaviour. */ tabToNextCell?: TabToNextCell, + /** Allows overriding the default behaviour when tabbing between core grid containers. + * Return a container name, a cell position, or a header position to focus that target, + * `true` to stay on the current focus, `false` to let the browser handle tab behaviour, + * or `undefined` to use the grid's default behaviour. + */ + tabToNextGridContainer?: TabToNextGridContainer, /** A callback for localising text within the grid. * @initial * @agModule `LocaleModule` @@ -2308,6 +2315,7 @@ export function getProps() { tabToNextHeader: undefined, navigateToNextCell: undefined, tabToNextCell: undefined, + tabToNextGridContainer: undefined, getLocaleText: undefined, getDocument: undefined, paginationNumberFormatter: undefined, diff --git a/testing/behavioural/src/services/focus-overrides.test.ts b/testing/behavioural/src/services/focus-overrides.test.ts new file mode 100644 index 00000000000..93c32e86cee --- /dev/null +++ b/testing/behavioural/src/services/focus-overrides.test.ts @@ -0,0 +1,285 @@ +import '@testing-library/jest-dom'; +import { userEvent } from '@testing-library/user-event'; + +import type { + ColDef, + FocusGridInnerElementParams, + GridApi, + GridOptions, + NavigateToNextCellParams, + NavigateToNextHeaderParams, + TabToNextCellParams, + TabToNextGridContainerParams, + TabToNextHeaderParams, +} from 'ag-grid-community'; +import { getGridElement } from 'ag-grid-community'; + +import { TestGridsManager, asyncSetTimeout } from '../test-utils'; +import { expect } from '../test-utils/matchers'; + +interface RowData { + athlete: string; + country: string; + sport: string; +} + +const rowData: RowData[] = [ + { athlete: 'A', country: 'UK', sport: 'S1' }, + { athlete: 'B', country: 'IE', sport: 'S2' }, + { athlete: 'C', country: 'PT', sport: 'S3' }, +]; + +const columnDefs: ColDef[] = [{ field: 'athlete' }, { field: 'country' }, { field: 'sport' }]; + +async function waitForCondition( + description: string, + condition: () => boolean, + timeoutMs = 300, + pollMs = 5 +): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (condition()) { + return; + } + await asyncSetTimeout(pollMs); + } + throw new Error(`Timed out waiting for ${description}`); +} + +function dispatchKeyDown(key: string, opts?: KeyboardEventInit): void { + const activeElement = document.activeElement as HTMLElement | null; + if (!activeElement) { + throw new Error('Expected active element before dispatching keyboard event'); + } + activeElement.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...opts })); +} + +function isFocusedCell(api: GridApi, rowIndex: number, colId: string): boolean { + const focusedCell = api.getFocusedCell(); + return focusedCell?.rowIndex === rowIndex && focusedCell?.column.getColId() === colId; +} + +function getFocusedHeaderColId(): string | null { + const activeElement = document.activeElement as HTMLElement | null; + return activeElement?.closest('.ag-header-cell')?.getAttribute('col-id') ?? null; +} + +describe('Focus Overrides', () => { + const gridsManager = new TestGridsManager(); + + afterEach(() => { + gridsManager.reset(); + }); + + test('focusGridInnerElement override is used when shift-tabbing into grid from below', async () => { + const host = document.createElement('div'); + const aboveInput = document.createElement('input'); + const gridDiv = document.createElement('div'); + const belowInput = document.createElement('input'); + host.append(aboveInput, gridDiv, belowInput); + document.body.appendChild(host); + + let api: GridApi; + const focusGridInnerElement = vi.fn((params: FocusGridInnerElementParams) => { + if (params.fromBottom) { + api.setFocusedCell(2, 'sport'); + return true; + } + return false; + }); + + const gridOptions: GridOptions = { + columnDefs, + rowData, + tabIndex: 0, + focusGridInnerElement, + }; + + try { + api = await gridsManager.createGridAndWait(gridDiv, gridOptions); + const gridElement = getGridElement(api) as HTMLElement; + + const user = userEvent.setup(); + belowInput.focus(); + expect(belowInput).toHaveFocus(); + + await user.tab({ shift: true }); + const bottomGuard = gridElement.querySelector('.ag-tab-guard-bottom'); + bottomGuard?.focus(); + + await waitForCondition( + 'focusGridInnerElement callback invocation', + () => focusGridInnerElement.mock.calls.length > 0 + ); + await waitForCondition('focus moved to callback-selected cell', () => isFocusedCell(api, 2, 'sport')); + + expect(focusGridInnerElement).toHaveBeenCalledWith(expect.objectContaining({ fromBottom: true })); + } finally { + host.remove(); + } + }); + + test('tabToNextGridContainer callback is invoked on backwards tab flow', async () => { + const tabToNextGridContainer = vi.fn((_params: TabToNextGridContainerParams) => undefined); + + const api = await gridsManager.createGridAndWait('myGrid', { + columnDefs, + rowData, + pagination: true, + paginationPageSize: 1, + paginationPageSizeSelector: false, + tabToNextGridContainer, + }); + + const gridElement = getGridElement(api) as HTMLElement; + const pagingButtons = Array.from(gridElement.querySelectorAll('.ag-paging-button')); + const firstButton = pagingButtons[0]; + expect(firstButton).toBeTruthy(); + + firstButton.focus(); + expect(firstButton).toHaveFocus(); + dispatchKeyDown('Tab', { shiftKey: true }); + + await waitForCondition('tabToNextGridContainer callback invocation', () => + tabToNextGridContainer.mock.calls.some(([params]) => params?.backwards === true) + ); + + expect(tabToNextGridContainer).toHaveBeenCalledWith( + expect.objectContaining({ + backwards: true, + }) + ); + }); + + test('tabToNextCell override reroutes tabbing target', async () => { + const tabToNextCell = vi.fn((params: TabToNextCellParams) => { + if ( + params.previousCellPosition.rowIndex === 0 && + params.previousCellPosition.column.getColId() === 'athlete' + ) { + return { + rowIndex: 2, + rowPinned: null, + column: params.nextCellPosition?.column ?? params.previousCellPosition.column, + }; + } + + return params.nextCellPosition ?? false; + }); + + const api = await gridsManager.createGridAndWait('myGrid', { + columnDefs, + rowData, + tabToNextCell, + }); + + api.setFocusedCell(0, 'athlete'); + await asyncSetTimeout(0); + + dispatchKeyDown('Tab'); + + await waitForCondition('tabToNextCell callback invocation', () => tabToNextCell.mock.calls.length > 0); + await waitForCondition('focus moved to callback-selected cell', () => isFocusedCell(api, 2, 'country')); + + expect(tabToNextCell).toHaveBeenCalledWith( + expect.objectContaining({ + backwards: false, + }) + ); + }); + + test('navigateToNextCell override reroutes arrow navigation target', async () => { + const navigateToNextCell = vi.fn((params: NavigateToNextCellParams) => { + if (params.key === 'ArrowRight') { + return { + rowIndex: 2, + rowPinned: null, + column: params.nextCellPosition?.column ?? params.previousCellPosition.column, + }; + } + + return params.nextCellPosition; + }); + + const api = await gridsManager.createGridAndWait('myGrid', { + columnDefs, + rowData, + navigateToNextCell, + }); + + api.setFocusedCell(0, 'athlete'); + await asyncSetTimeout(0); + + dispatchKeyDown('ArrowRight'); + + await waitForCondition( + 'navigateToNextCell callback invocation', + () => navigateToNextCell.mock.calls.length > 0 + ); + await waitForCondition('focus moved to callback-selected cell', () => isFocusedCell(api, 2, 'country')); + + expect(navigateToNextCell).toHaveBeenCalledWith( + expect.objectContaining({ + key: 'ArrowRight', + }) + ); + }); + + test('tabToNextHeader override reroutes header tab target', async () => { + const tabToNextHeader = vi.fn((params: TabToNextHeaderParams) => { + const column = params.api.getColumn('sport'); + if (!column) { + return false; + } + return { headerRowIndex: 0, column }; + }); + + const api = await gridsManager.createGridAndWait('myGrid', { + columnDefs, + rowData, + tabToNextHeader, + }); + + api.setFocusedHeader('athlete'); + await asyncSetTimeout(0); + + dispatchKeyDown('Tab'); + + await waitForCondition('tabToNextHeader callback invocation', () => tabToNextHeader.mock.calls.length > 0); + await waitForCondition( + 'header focus moved to callback-selected header', + () => getFocusedHeaderColId() === 'sport' + ); + }); + + test('navigateToNextHeader override reroutes arrow navigation target', async () => { + const navigateToNextHeader = vi.fn((params: NavigateToNextHeaderParams) => { + const column = params.api.getColumn('sport'); + if (!column) { + return null; + } + return { headerRowIndex: 0, column }; + }); + + const api = await gridsManager.createGridAndWait('myGrid', { + columnDefs, + rowData, + navigateToNextHeader, + }); + + api.setFocusedHeader('athlete'); + await asyncSetTimeout(0); + + dispatchKeyDown('ArrowRight'); + + await waitForCondition( + 'navigateToNextHeader callback invocation', + () => navigateToNextHeader.mock.calls.length > 0 + ); + await waitForCondition( + 'header focus moved to callback-selected header', + () => getFocusedHeaderColId() === 'sport' + ); + }); +});