fix radix dialog focus restore behaviour

This commit is contained in:
Gregor Vostrak
2026-09-04 14:55:09 +02:00
parent 169d522da0
commit 82ea9af8b5
5 changed files with 149 additions and 2 deletions

View File

@@ -0,0 +1,87 @@
import { describe, expect, it, vi } from 'vitest';
import { useDialogFocusRestore } from './useDialogFocusRestore';
function closeEvent() {
return new CustomEvent('focusScope.autoFocusOnUnmount', { cancelable: true });
}
describe('useDialogFocusRestore', () => {
it('restores focus to the element focused when the dialog opened', () => {
vi.useFakeTimers();
const button = document.createElement('button');
document.body.appendChild(button);
button.focus();
const { onOpenAutoFocus, onCloseAutoFocus } = useDialogFocusRestore();
onOpenAutoFocus();
button.blur();
const event = closeEvent();
onCloseAutoFocus(event);
expect(event.defaultPrevented).toBe(true);
vi.runAllTimers();
expect(document.activeElement).toBe(button);
button.remove();
vi.useRealTimers();
});
it('focuses nothing when the dialog was opened with nothing focused', () => {
vi.useFakeTimers();
const stale = document.createElement('button');
document.body.appendChild(stale);
const { onOpenAutoFocus, onCloseAutoFocus } = useDialogFocusRestore();
// First open from the button, then close
stale.focus();
onOpenAutoFocus();
onCloseAutoFocus(closeEvent());
vi.runAllTimers();
stale.blur();
// Second open from the body must not refocus the stale button
onOpenAutoFocus();
const event = closeEvent();
onCloseAutoFocus(event);
expect(event.defaultPrevented).toBe(true);
vi.runAllTimers();
expect(document.activeElement).toBe(document.body);
stale.remove();
vi.useRealTimers();
});
it('does not restore focus to an element that was removed', () => {
vi.useFakeTimers();
const button = document.createElement('button');
document.body.appendChild(button);
button.focus();
const { onOpenAutoFocus, onCloseAutoFocus } = useDialogFocusRestore();
onOpenAutoFocus();
button.remove();
onCloseAutoFocus(closeEvent());
vi.runAllTimers();
expect(document.activeElement).toBe(document.body);
vi.useRealTimers();
});
it('leaves control to a consumer that already prevented the event', () => {
vi.useFakeTimers();
const button = document.createElement('button');
document.body.appendChild(button);
button.focus();
const { onOpenAutoFocus, onCloseAutoFocus } = useDialogFocusRestore();
onOpenAutoFocus();
button.blur();
const event = closeEvent();
event.preventDefault();
onCloseAutoFocus(event);
vi.runAllTimers();
expect(document.activeElement).toBe(document.body);
button.remove();
vi.useRealTimers();
});
});

View File

@@ -0,0 +1,41 @@
/**
* Restores focus to the element that was focused when a dialog opened.
*
* reka-ui remembers the active element at content mount as the dialog's
* "trigger" (only when it is not the body) and refocuses it on every close,
* but it never clears that value. A dialog opened while nothing is focused
* (e.g. the command palette via Cmd+K from the body) therefore refocuses
* whatever triggered a *previous* open. Bind these handlers to
* `DialogContent`'s `open-auto-focus` / `close-auto-focus` events to restore
* exactly the previously focused element, or nothing.
*
* A consumer handler that already called `preventDefault()` on
* `close-auto-focus` keeps control; this composable then does nothing.
*/
export function useDialogFocusRestore() {
let previouslyFocused: HTMLElement | null = null;
function onOpenAutoFocus() {
const active = document.activeElement;
previouslyFocused =
active instanceof HTMLElement && active !== document.body ? active : null;
}
function onCloseAutoFocus(event: Event) {
const target = previouslyFocused;
previouslyFocused = null;
if (event.defaultPrevented) {
return;
}
// Prevents both FocusScope's default restore and reka-ui's trigger refocus
event.preventDefault();
// Same tick reka-ui uses, so the dialog content is fully gone first
setTimeout(() => {
if (target?.isConnected) {
target.focus({ preventScroll: true });
}
}, 0);
}
return { onOpenAutoFocus, onCloseAutoFocus };
}