fix(a11y): hand focus back to whatever opened a dialog

Closing any dialog left focus on <body>, so a keyboard user restarted from the top
of the page. Radix restores focus to its own DialogTrigger, and nearly every dialog
here is a controlled `<Dialog open>` opened from state — and the page unmounts the
content before Radix's close sequence runs, so its `onCloseAutoFocus` never fires
at all (verified by instrumenting the handler: it never ran).

The shared DialogContent now remembers the element that had focus when it rendered
and restores it when it unmounts, if that element is still on the page. Captured
during the first render because by the time effects run focus is already inside the
dialog.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-07-26 18:11:00 +07:00
co-authored by Claude Opus 4.8
parent 70d71df066
commit cbbee09eda
2 changed files with 41 additions and 0 deletions
+25
View File
@@ -1,5 +1,7 @@
import { expect, test } from '../../fixtures/test.ts';
import { scanA11y, waiversForScan } from '../../helpers/a11y.ts';
import { expectCleanPage } from '../../helpers/errors.ts';
import { resourcesCassette } from '../../mocks/cassettes/resources.ts';
import { populatedSettingsProvidersCassette } from '../../mocks/cassettes/settings-providers.ts';
import { loginJourneyCassette } from '../../mocks/cassettes/smoke.ts';
import { ROUTE_MANIFEST } from '../../routes.ts';
@@ -69,3 +71,26 @@ for (const theme of THEMES) {
}
});
}
test.describe('dialog keyboard contract', { tag: '@cross' }, () => {
test.use({ cassette: resourcesCassette() });
// Closing a dialog must hand focus back to whatever opened it. Radix only does that for its own
// DialogTrigger, and this app opens dialogs from controlled state, so without help focus lands on
// <body> and a keyboard user starts again from the top of the page.
test('Escape returns focus to the control that opened the dialog', async ({ page, pageErrorLog }) => {
await page.goto('/resources');
const opener = page.getByRole('button', { name: 'New folder' });
await opener.focus();
await page.keyboard.press('Enter');
await expect(page.getByRole('dialog')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.getByRole('dialog')).toBeHidden();
await expect(opener).toBeFocused();
expectCleanPage(pageErrorLog);
});
});
+16
View File
@@ -23,6 +23,22 @@ function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.C
}
function DialogContent({ children, className, ...props }: React.ComponentProps<typeof DialogPrimitive.Content>) {
// Radix hands focus back to its own DialogTrigger, and almost every dialog here is a controlled
// `<Dialog open>` with no trigger — and its content is unmounted by the page before Radix's
// close sequence runs at all, so `onCloseAutoFocus` never fires. Without this, closing a dialog
// drops focus on <body> and a keyboard user restarts from the top of the page. Captured during
// the first render, because by the time effects run focus is already inside the dialog.
const [opener] = React.useState(() => document.activeElement as HTMLElement | null);
React.useEffect(
() => () => {
if (opener?.isConnected) {
opener.focus();
}
},
[opener],
);
return (
<DialogPortal>
<DialogOverlay />