mirror of
https://github.com/vxcontrol/pentagi.git
synced 2026-08-25 20:46:31 +00:00
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:
co-authored by
Claude Opus 4.8
parent
70d71df066
commit
cbbee09eda
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user