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'
+ );
+ });
+});