+ );
+}
+
+function Page({ open, onEscape, note }: { open: boolean; onEscape: () => void; note?: string }) {
+ return (
+ <>
+
+ {open && }
+ >
+ );
+}
+
+// Like the identity and template managers: a list that gives way to a form,
+// taking the button that had the focus with it.
+function Manager() {
+ const [editing, setEditing] = useState(false);
+ const ref = useFocusTrap({ isActive: true, onEscape: () => setEditing(false) });
+ return (
+
+
+ {editing
+ ?
+ : }
+
+ );
+}
+
+describe('useFocusTrap', () => {
+ it('moves the focus in when it opens and back to the opener when it closes', () => {
+ const { rerender } = render( {}} />);
+ screen.getByText('opener').focus();
+ rerender( {}} />);
+ expect(document.activeElement).toBe(screen.getByText('close'));
+ rerender( {}} />);
+ expect(document.activeElement).toBe(screen.getByText('opener'));
+ });
+
+ it('leaves the focus in the field while the page behind renders again', () => {
+ // A background request renders the page again, and the dialog gets a
+ // new onEscape: the field the user is typing in keeps the focus.
+ const { rerender } = render( {}} />);
+ const field = screen.getByLabelText('name');
+ field.focus();
+ rerender( {}} />);
+ rerender( {}} />);
+ expect(document.activeElement).toBe(field);
+ });
+
+ it('answers Escape with the onEscape of the latest render', () => {
+ const first = vi.fn();
+ const latest = vi.fn();
+ const { rerender } = render();
+ rerender();
+ fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' });
+ expect(latest).toHaveBeenCalledTimes(1);
+ expect(first).not.toHaveBeenCalled();
+ });
+
+ it('keeps the focus in the dialog when a view change removes the element that had it', async () => {
+ render();
+ const edit = screen.getByText('edit');
+ edit.focus();
+ fireEvent.click(edit);
+ await waitFor(() => expect(document.activeElement).toBe(screen.getByText('close')));
+ });
+
+ it('leaves a focus the user moved away alone when the dialog changes', async () => {
+ const { rerender } = render( {}} />);
+ const field = screen.getByLabelText('name');
+ field.focus();
+ field.blur();
+ rerender( {}} note="saved" />);
+ await screen.findByText('saved');
+ await Promise.resolve();
+ expect(document.activeElement).toBe(document.body);
+ });
+});
diff --git a/hooks/use-focus-trap.ts b/hooks/use-focus-trap.ts
index 0bec6891c..60dfe32a9 100644
--- a/hooks/use-focus-trap.ts
+++ b/hooks/use-focus-trap.ts
@@ -13,6 +13,14 @@ export function useFocusTrap({
}: UseFocusTrapOptions) {
const containerRef = useRef(null);
const previousActiveElement = useRef(null);
+ // Callers pass a new onEscape on every render. The trap reads the latest
+ // one from here instead of starting over for it: each new start moved the
+ // focus back to the opener and then to the first element, out of the field
+ // the user was typing in whenever the page behind rendered again.
+ const onEscapeRef = useRef(onEscape);
+ useEffect(() => {
+ onEscapeRef.current = onEscape;
+ });
useEffect(() => {
if (!isActive || !containerRef.current) return;
@@ -29,6 +37,13 @@ export function useFocusTrap({
);
};
+ // The element in the dialog that had the focus last.
+ let lastFocused: HTMLElement | null = null;
+ const handleFocusIn = (e: FocusEvent) => {
+ lastFocused = e.target as HTMLElement;
+ };
+ container.addEventListener('focusin', handleFocusIn);
+
// Focus first element
const focusableElements = getFocusableElements();
const firstElement = focusableElements[0];
@@ -39,8 +54,8 @@ export function useFocusTrap({
// Handle Tab key to trap focus
const handleKeyDown = (e: KeyboardEvent) => {
// Handle Escape
- if (e.key === 'Escape' && onEscape) {
- onEscape();
+ if (e.key === 'Escape' && onEscapeRef.current) {
+ onEscapeRef.current();
return;
}
@@ -68,8 +83,22 @@ export function useFocusTrap({
container.addEventListener('keydown', handleKeyDown);
+ // A view change in the dialog (a list giving way to a form) can remove
+ // the element that had the focus, which then drops to the page behind,
+ // out of the trap. Bring it back to the first element - only then, so a
+ // focus the user moved on purpose stays where it is.
+ const observer = new MutationObserver(() => {
+ const active = document.activeElement;
+ if (lastFocused && !lastFocused.isConnected && (!active || active === document.body)) {
+ getFocusableElements()[0]?.focus();
+ }
+ });
+ observer.observe(container, { childList: true, subtree: true });
+
// Cleanup
return () => {
+ observer.disconnect();
+ container.removeEventListener('focusin', handleFocusIn);
container.removeEventListener('keydown', handleKeyDown);
// Restore focus to previous element
@@ -77,7 +106,7 @@ export function useFocusTrap({
previousActiveElement.current.focus();
}
};
- }, [isActive, onEscape, restoreFocus]);
+ }, [isActive, restoreFocus]);
return containerRef;
}