From cf53ff68616bdf1359bfc39304157d2b228f3175 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Tue, 19 May 2026 06:56:48 -0700 Subject: [PATCH] studio: restore focus to opener when settings dialog closes (#5612) The settings dialog opens via a global Ctrl+, keydown handler in __root.tsx, not via a . Radix's FocusScope tries to capture document.activeElement at mount as the focus-restore target, but settings-dialog.tsx schedules a requestAnimationFrame that focuses the active tab button right after mount, racing FocusScope's previous- focus capture. On Escape or close-button click, focus then lands on instead of the textarea (or button, or wherever the user was). A Playwright focus-management probe confirmed: open dialog, press Tab 15 times (trap holds), press Escape, document.activeElement === BODY. This is a WCAG 2.4.3 (Focus Order) violation: keyboard-only users have to re-Tab from the start of the page after every settings visit. Fix: capture document.activeElement in the Zustand store at the moment openDialog() runs, then restore via onCloseAutoFocus on DialogContent. Use opener.isConnected so a stale node from a re-rendered tree falls back to Radix's default. closeDialog deliberately does NOT clear the opener slot - onCloseAutoFocus reads it on the render after open=false, so clearing in the same set() would null it before restoration. Probe re-run confirms focus restored to the TEXTAREA opener after Escape, after close-button click, on both repeats. Tab + Shift+Tab trap still holds (unchanged Radix behaviour). Co-authored-by: danielhanchen --- .../src/features/settings/settings-dialog.tsx | 10 ++++++++++ .../settings/stores/settings-dialog-store.ts | 15 +++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/studio/frontend/src/features/settings/settings-dialog.tsx b/studio/frontend/src/features/settings/settings-dialog.tsx index e52257735e..384e359f31 100644 --- a/studio/frontend/src/features/settings/settings-dialog.tsx +++ b/studio/frontend/src/features/settings/settings-dialog.tsx @@ -74,6 +74,7 @@ export function SettingsDialog() { const activeTab = useSettingsDialogStore((s) => s.activeTab); const setActiveTab = useSettingsDialogStore((s) => s.setActiveTab); const closeDialog = useSettingsDialogStore((s) => s.closeDialog); + const opener = useSettingsDialogStore((s) => s.opener); const reduced = useReducedMotion(); const tabButtonRefs = useRef>({ general: null, @@ -98,6 +99,15 @@ export function SettingsDialog() { { + // Restore focus to the element that triggered openDialog(). + // Radix's FocusScope races our rAF-scheduled tab-button focus + // and loses the previous-focus reference, so we restore by hand. + if (opener && opener.isConnected) { + e.preventDefault(); + opener.focus({ preventScroll: true }); + } + }} className={cn( "!max-w-none h-[560px] w-[820px] p-0 overflow-hidden", "shadow-border rounded-xl border-border", diff --git a/studio/frontend/src/features/settings/stores/settings-dialog-store.ts b/studio/frontend/src/features/settings/stores/settings-dialog-store.ts index a2e32f26a3..7ac5422ff1 100644 --- a/studio/frontend/src/features/settings/stores/settings-dialog-store.ts +++ b/studio/frontend/src/features/settings/stores/settings-dialog-store.ts @@ -15,6 +15,11 @@ export type SettingsTab = interface SettingsDialogState { open: boolean; activeTab: SettingsTab; + // Element focused at the moment openDialog() ran. Radix's FocusScope + // would normally track this, but the rAF-scheduled focus() in + // settings-dialog.tsx races its previous-focus capture, leaving focus + // on after close. We restore explicitly via onCloseAutoFocus. + opener: HTMLElement | null; openDialog: (tab?: SettingsTab) => void; closeDialog: () => void; setActiveTab: (tab: SettingsTab) => void; @@ -47,11 +52,21 @@ function loadInitialTab(): SettingsTab { export const useSettingsDialogStore = create((set) => ({ open: false, activeTab: loadInitialTab(), + opener: null, openDialog: (tab) => set((state) => ({ open: true, activeTab: tab ?? state.activeTab, + opener: + typeof document !== "undefined" && + document.activeElement instanceof HTMLElement && + document.activeElement !== document.body + ? document.activeElement + : null, })), + // Do NOT clear `opener` here. onCloseAutoFocus runs on the next render + // pass after `open: false` lands, so the opener must still be readable + // from the store at that point. The next openDialog() overwrites it. closeDialog: () => set({ open: false }), setActiveTab: (tab) => { try {