diff --git a/test/browser/column/colSpan.test.ts b/test/browser/column/colSpan.test.ts
index 3809855f82..758722742f 100644
--- a/test/browser/column/colSpan.test.ts
+++ b/test/browser/column/colSpan.test.ts
@@ -1,7 +1,7 @@
import { page, userEvent } from 'vitest/browser';
import type { Column } from '../../../src';
-import { getCellsAtRowIndex, setup, validateCellPosition } from '../utils';
+import { getCellsAtRowIndex, safeTab, setup, validateCellPosition } from '../utils';
const headerCells = page.getHeaderCell();
@@ -145,18 +145,18 @@ describe('colSpan', () => {
await validateCellPosition(0, 8);
await userEvent.keyboard('{arrowright}');
await validateCellPosition(5, 8);
- await userEvent.tab({ shift: true });
- await userEvent.tab({ shift: true });
+ await safeTab(true);
+ await safeTab(true);
await validateCellPosition(14, 7);
- await userEvent.tab();
+ await safeTab();
await validateCellPosition(0, 8);
await userEvent.click(getCellsAtRowIndex(10).nth(11));
await validateCellPosition(11, 11);
- await userEvent.tab();
+ await safeTab();
await validateCellPosition(12, 11);
- await userEvent.tab();
+ await safeTab();
await validateCellPosition(0, 12);
- await userEvent.tab({ shift: true });
+ await safeTab(true);
await validateCellPosition(12, 11);
// bottom summary rows
@@ -198,7 +198,7 @@ describe('colSpan', () => {
async function navigate(count: number, shift = false) {
for (let i = 0; i < count; i++) {
- await userEvent.tab({ shift });
+ await safeTab(shift);
}
}
});
diff --git a/test/browser/column/grouping.test.ts b/test/browser/column/grouping.test.ts
index ac4696b7e1..cd8b1bd86b 100644
--- a/test/browser/column/grouping.test.ts
+++ b/test/browser/column/grouping.test.ts
@@ -1,7 +1,7 @@
import { page, userEvent } from 'vitest/browser';
import type { ColumnOrColumnGroup } from '../../../src';
-import { setup, tabIntoGrid, testCount, validateCellPosition } from '../utils';
+import { safeTab, setup, tabIntoGrid, testCount, validateCellPosition } from '../utils';
const grid = page.getGrid();
const headerRows = grid.getHeaderRow();
@@ -323,21 +323,21 @@ test('keyboard navigation', async () => {
await validateCellPosition(10, 3);
// tab navigation
- await userEvent.tab();
+ await safeTab();
await validateCellPosition(11, 3);
- await userEvent.tab({ shift: true });
- await userEvent.tab({ shift: true });
- await userEvent.tab({ shift: true });
+ await safeTab(true);
+ await safeTab(true);
+ await safeTab(true);
await validateCellPosition(8, 3);
await userEvent.keyboard('{arrowup}');
- await userEvent.tab({ shift: true });
+ await safeTab(true);
await validateCellPosition(4, 0);
- await userEvent.tab();
+ await safeTab();
await validateCellPosition(8, 0);
await userEvent.keyboard('{home}{end}');
- await userEvent.tab();
+ await safeTab();
await validateCellPosition(0, 4);
- await userEvent.tab({ shift: true });
+ await safeTab(true);
await validateCellPosition(11, 3);
});
diff --git a/test/browser/column/renderCell.test.tsx b/test/browser/column/renderCell.test.tsx
index 7abd91f0f3..d0bdaf7ff1 100644
--- a/test/browser/column/renderCell.test.tsx
+++ b/test/browser/column/renderCell.test.tsx
@@ -4,7 +4,7 @@ import { page, userEvent } from 'vitest/browser';
import { DataGrid } from '../../../src';
import type { Column } from '../../../src';
import defaultRenderHeaderCell from '../../../src/renderHeaderCell';
-import { getCellsAtRowIndex, setup } from '../utils';
+import { getCellsAtRowIndex, safeTab, setup } from '../utils';
const cells = page.getCell();
@@ -144,7 +144,7 @@ test('Focus child if it sets tabIndex', async () => {
await userEvent.click(page.getByText('Text'));
await expect.element(button1).toHaveFocus();
await expect.element(button1).toHaveAttribute('tabindex', '0');
- await userEvent.tab({ shift: true });
+ await safeTab(true);
await expect.element(button1).not.toHaveFocus();
await expect.element(button1).toHaveAttribute('tabindex', '-1');
await expect.element(cell).toHaveAttribute('tabindex', '-1');
@@ -152,7 +152,7 @@ test('Focus child if it sets tabIndex', async () => {
await expect.element(button1).toHaveFocus();
await expect.element(button1).toHaveAttribute('tabindex', '0');
await expect.element(cell).toHaveAttribute('tabindex', '-1');
- await userEvent.tab({ shift: true });
+ await safeTab(true);
await userEvent.click(button2);
await expect.element(button2).toHaveFocus();
// It is user's responsibilty to set the tabIndex on button2
diff --git a/test/browser/column/renderEditCell.test.tsx b/test/browser/column/renderEditCell.test.tsx
index 2294e72ce9..d3001ef4e8 100644
--- a/test/browser/column/renderEditCell.test.tsx
+++ b/test/browser/column/renderEditCell.test.tsx
@@ -4,7 +4,7 @@ import { page, userEvent } from 'vitest/browser';
import { DataGrid } from '../../../src';
import type { Column, DataGridProps } from '../../../src';
-import { getCellsAtRowIndex, getRowWithCell, scrollGrid, testCount } from '../utils';
+import { getCellsAtRowIndex, getRowWithCell, safeTab, scrollGrid, testCount } from '../utils';
const grid = page.getGrid();
@@ -22,7 +22,7 @@ describe('Editor', () => {
await userEvent.dblClick(getCellsAtRowIndex(0).nth(0));
await expect.element(editor).toHaveValue(1);
await userEvent.keyboard('2');
- await userEvent.tab();
+ await safeTab();
await expect.element(editor).not.toBeInTheDocument();
await expect.element(getCellsAtRowIndex(0).nth(0)).toHaveTextContent(/^12$/);
});
diff --git a/test/browser/copyPaste.test.tsx b/test/browser/copyPaste.test.tsx
index e9ace61216..a357e5b547 100644
--- a/test/browser/copyPaste.test.tsx
+++ b/test/browser/copyPaste.test.tsx
@@ -3,7 +3,7 @@ import { page, userEvent } from 'vitest/browser';
import { DataGrid } from '../../src';
import type { CellPasteArgs, Column } from '../../src';
-import { getCellsAtRowIndex } from './utils';
+import { getCellsAtRowIndex, safeTab } from './utils';
interface Row {
col: string;
@@ -116,7 +116,7 @@ test('should allow copying a readonly cell', async () => {
test('should not allow copy/paste on header or summary cells', async () => {
await setup();
- await userEvent.tab();
+ await safeTab();
await userEvent.copy();
expect(onCellCopySpy).not.toHaveBeenCalled();
await userEvent.paste();
diff --git a/test/browser/events.test.tsx b/test/browser/events.test.tsx
index 574001b9cb..831c260fdf 100644
--- a/test/browser/events.test.tsx
+++ b/test/browser/events.test.tsx
@@ -2,6 +2,7 @@ import { page, userEvent } from 'vitest/browser';
import { DataGrid } from '../../src';
import type { Column, DataGridProps } from '../../src';
+import { safeTab } from './utils';
interface Row {
col1: number;
@@ -162,7 +163,7 @@ describe('Events', () => {
expect(onSelectedCellChange).toHaveBeenCalledTimes(4);
// Selected by tab key
- await userEvent.keyboard('{Tab}');
+ await safeTab();
expect(onSelectedCellChange).toHaveBeenLastCalledWith({
column: expect.objectContaining(columns[1]),
row: rows[0],
diff --git a/test/browser/keyboardNavigation.test.tsx b/test/browser/keyboardNavigation.test.tsx
index 91cf9f3a01..6d435374aa 100644
--- a/test/browser/keyboardNavigation.test.tsx
+++ b/test/browser/keyboardNavigation.test.tsx
@@ -4,6 +4,7 @@ import { DataGrid, SelectColumn } from '../../src';
import type { Column } from '../../src';
import {
getRowWithCell,
+ safeTab,
scrollGrid,
setup,
tabIntoGrid,
@@ -40,11 +41,11 @@ test('keyboard navigation', async () => {
await validateCellPosition(0, 0);
// tab to the next cell
- await userEvent.tab();
+ await safeTab();
await validateCellPosition(1, 0);
// tab back to the previous cell
- await userEvent.tab({ shift: true });
+ await safeTab(true);
await validateCellPosition(0, 0);
// arrow navigation
@@ -97,11 +98,11 @@ test('keyboard navigation', async () => {
// tab at the end of a row selects the first cell on the next row
await userEvent.keyboard('{end}');
- await userEvent.tab();
+ await safeTab();
await validateCellPosition(0, 1);
// shift tab should select the last cell of the previous row
- await userEvent.tab({ shift: true });
+ await safeTab(true);
await validateCellPosition(6, 0);
});
@@ -122,11 +123,11 @@ test('arrow and tab navigation', async () => {
await validateCellPosition(6, 1);
// pressing tab on the rightmost cell navigates to the leftmost cell on the next row
- await userEvent.tab();
+ await safeTab();
await validateCellPosition(0, 2);
// pressing shift+tab on the leftmost cell navigates to the rightmost cell on the previous row
- await userEvent.tab({ shift: true });
+ await safeTab(true);
await validateCellPosition(6, 1);
});
@@ -144,10 +145,10 @@ test('grid enter/exit', async () => {
await validateCellPosition(0, 0);
// shift+tab tabs out of the grid if we are at the first cell
- await userEvent.tab({ shift: true });
+ await safeTab(true);
await expect.element(beforeButton).toHaveFocus();
- await userEvent.tab();
+ await safeTab();
await validateCellPosition(0, 0);
await userEvent.keyboard('{arrowdown}{arrowdown}');
@@ -156,19 +157,19 @@ test('grid enter/exit', async () => {
// tab should select the last selected cell
// click outside the grid
await userEvent.click(beforeButton);
- await userEvent.tab();
+ await safeTab();
await userEvent.keyboard('{arrowdown}');
await validateCellPosition(0, 3);
// shift+tab should select the last selected cell
await userEvent.click(afterButton);
- await userEvent.tab({ shift: true });
+ await safeTab(true);
await validateCellPosition(0, 3);
await expect.element(selectedCell.getByRole('checkbox')).toHaveFocus();
// tab tabs out of the grid if we are at the last cell
await userEvent.keyboard('{Control>}{end}{/Control}');
- await userEvent.tab();
+ await safeTab();
await expect.element(afterButton).toHaveFocus();
});
@@ -184,7 +185,7 @@ test('navigation with focusable cell renderer', async () => {
await expect.element(checkbox).toHaveFocus();
await expect.element(checkbox).toHaveAttribute('tabIndex', '0');
- await userEvent.tab();
+ await safeTab();
await validateCellPosition(1, 1);
// cell should set tabIndex to 0 if it does not have focusable cell renderer
await expect.element(selectedCell).toHaveAttribute('tabIndex', '0');
@@ -223,31 +224,31 @@ test('navigation when header and summary rows have focusable elements', async ()
// should set focus on the header filter
await expect.element(page.getByTestId('header-filter1')).toHaveFocus();
- await userEvent.tab();
+ await safeTab();
await expect.element(page.getByTestId('header-filter2')).toHaveFocus();
- await userEvent.tab();
+ await safeTab();
await validateCellPosition(0, 1);
- await userEvent.tab({ shift: true });
+ await safeTab(true);
await expect.element(page.getByTestId('header-filter2')).toHaveFocus();
- await userEvent.tab({ shift: true });
+ await safeTab(true);
await expect.element(page.getByTestId('header-filter1')).toHaveFocus();
- await userEvent.tab();
- await userEvent.tab();
+ await safeTab();
+ await safeTab();
await userEvent.keyboard('{Control>}{end}{/Control}{arrowup}{arrowup}');
await validateCellPosition(1, 2);
- await userEvent.tab();
+ await safeTab();
await expect.element(page.getByTestId('summary-col2-1')).toHaveFocus();
- await userEvent.tab();
+ await safeTab();
await expect.element(page.getByTestId('summary-col3-1')).toHaveFocus();
- await userEvent.tab({ shift: true });
- await userEvent.tab({ shift: true });
+ await safeTab(true);
+ await safeTab(true);
await validateCellPosition(1, 2);
await expect.element(selectedCell).toHaveFocus();
});
@@ -297,7 +298,7 @@ test('reset selected cell when column is removed', async () => {
const { rerender } = await page.render();
- await userEvent.tab();
+ await safeTab();
await userEvent.keyboard('{arrowdown}{arrowright}');
await validateCellPosition(1, 1);
@@ -319,7 +320,7 @@ test('reset selected cell when row is removed', async () => {
const { rerender } = await page.render();
- await userEvent.tab();
+ await safeTab();
await userEvent.keyboard('{arrowdown}{arrowdown}{arrowright}');
await validateCellPosition(1, 2);
@@ -332,7 +333,7 @@ test('should not change the left and right arrow behavior for right to left lang
await setup({ columns, rows, direction: 'rtl' }, true);
await tabIntoGrid();
await validateCellPosition(0, 0);
- await userEvent.tab();
+ await safeTab();
await validateCellPosition(1, 0);
await userEvent.keyboard('{arrowright}');
await validateCellPosition(0, 0);
diff --git a/test/browser/renderTextEditor.test.tsx b/test/browser/renderTextEditor.test.tsx
index a4b4d3e91a..40b5ab9db7 100644
--- a/test/browser/renderTextEditor.test.tsx
+++ b/test/browser/renderTextEditor.test.tsx
@@ -3,6 +3,7 @@ import { page, userEvent } from 'vitest/browser';
import { DataGrid, renderTextEditor } from '../../src';
import type { Column } from '../../src';
+import { safeTab } from './utils';
interface Row {
readonly name: string;
@@ -48,7 +49,7 @@ test('renderTextEditor', async () => {
// blurring the input closes and commits the editor
await userEvent.dblClick(cell);
await userEvent.fill(input, 'Jim Milton');
- await userEvent.tab();
+ await safeTab();
await expect.element(input).not.toBeInTheDocument();
await expect.element(cell).toHaveTextContent(/^Jim Milton$/);
});
diff --git a/test/browser/utils.tsx b/test/browser/utils.tsx
index 38f0026a93..13e712fc38 100644
--- a/test/browser/utils.tsx
+++ b/test/browser/utils.tsx
@@ -50,14 +50,16 @@ export async function validateCellPosition(columnIdx: number, rowIdx: number) {
}
export async function scrollGrid(options: ScrollToOptions) {
- page.getGrid().element().scroll(options);
- // let the browser fire the 'scroll' event
- await new Promise(requestAnimationFrame);
+ await new Promise((resolve) => {
+ const gridElement = page.getGrid().element() as HTMLElement;
+ gridElement.addEventListener('scrollend', resolve, { once: true });
+ gridElement.scroll(options);
+ });
}
export async function tabIntoGrid() {
await userEvent.click(page.getByRole('button', { name: 'Before' }));
- await userEvent.tab();
+ await safeTab();
}
export function testCount(locator: Locator, expectedCount: number) {
@@ -67,3 +69,73 @@ export function testCount(locator: Locator, expectedCount: number) {
export function testRowCount(expectedCount: number) {
return testCount(page.getRow(), expectedCount);
}
+
+/**
+ * Tabs to the next or previous focusable element.
+ * Uses `userEvent.tab({ shift })` under the hood.
+ *
+ * Ideally, when tabbing moves focus to a browser UI element,
+ * we should be able to keep tabbing until we cycle back to an element on the page,
+ * by using the following implementation:
+ * ```
+ * await userEvent.tab({ shift });
+ *
+ * while (!document.hasFocus()) {
+ * await userEvent.tab({ shift });
+ * }
+ * ```
+ *
+ * When focus has moved to a browser UI element, and we call `userEvent.tab()`,
+ * under the hood `page.keyboard.press()` is called,
+ * the browser then handles tabbing as usual in the context of a browser,
+ * but it also handles tabbing within the `page` context, as if the page had focus.
+ *
+ * This leads to double-focus bugs where the focus is set on both a browser UI element,
+ * and an element on the page, in both Chrome and Firefox.
+ * In Chrome,
+ * programmatically tabbing will eventually return focus to the page,
+ * with the correct element on the page being focused,
+ * but browser UI elements may still look like they have focus.
+ * In Firefox,
+ * programmatically tabbing will cycle through elements on the page,
+ * and focus will be returned to the page, but it is unclear when exactly,
+ * so the focused element on the page may not be the expected one.
+ *
+ * The fact that Vitest tests run in an iframe probably doesn't help either.
+ *
+ * And so the solution is to programmatically click on the page to force focus
+ * back onto the page, and then tab to the next element.
+ *
+ * If there is only one element to focus in the page,
+ * and you need to blur it, call blur() on the element.
+ *
+ * @see https://github.com/microsoft/playwright/issues/39268
+ * @param shift Whether to tab backwards.
+ */
+export async function safeTab(shift = false) {
+ await userEvent.tab({ shift });
+
+ if (!document.hasFocus()) {
+ const button = document.createElement('button');
+ button.type = 'button';
+
+ if (shift) {
+ document.body.append(button);
+ } else {
+ document.body.prepend(button);
+ }
+
+ // Firefox needs two clicks for some reason
+ do {
+ await userEvent.click(button);
+ } while (!document.hasFocus());
+
+ await userEvent.tab({ shift });
+
+ button.remove();
+
+ if (!document.hasFocus()) {
+ throw new Error('safeTab: focus should have returned to the page');
+ }
+ }
+}
diff --git a/test/setupBrowser.ts b/test/setupBrowser.ts
index 04518e1f6e..6c68d21406 100644
--- a/test/setupBrowser.ts
+++ b/test/setupBrowser.ts
@@ -3,7 +3,7 @@
import 'vitest-browser-react';
import { configure } from 'vitest-browser-react/pure';
-import { locators, type Locator, type LocatorByRoleOptions } from 'vitest/browser';
+import { locators, userEvent, type Locator, type LocatorByRoleOptions } from 'vitest/browser';
configure({
reactStrictMode: true
@@ -80,3 +80,25 @@ function defaultToExactOpts(
return opts;
}
+
+beforeEach(async () => {
+ // 1. reset cursor position to avoid hover issues
+ // 2. force focus to be on the page
+ await userEvent.click(document.body, { position: { x: 0, y: 0 } });
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+
+ // eslint-disable-next-line @eslint-react/purity
+ if (!document.hasFocus()) {
+ // Errors thrown in `afterEach` will short-circuit subsequent `afterEach` hooks,
+ // thus preventing tests from being cleaned up properly and affecting other tests.
+ // We must therefore wait for tests to "finish" before throwing the error.
+ onTestFinished(() => {
+ throw new Error(
+ 'Focus is set on a browser UI element at the end of a test. Use safeTab() to return focus to the page.'
+ );
+ });
+ }
+});
diff --git a/vite.config.ts b/vite.config.ts
index 346f5c188a..34086f656a 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -44,7 +44,7 @@ const viewport = { width: 1920, height: 1080 } as const;
// vitest modifies the instance objects, so we cannot rely on static objects
function getInstances(): BrowserInstanceOption[] {
const opts: PlaywrightProviderOptions = {
- actionTimeout: 2000,
+ actionTimeout: 1000,
contextOptions: {
viewport
}