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 }