Skip to content

Commit 040d424

Browse files
committed
Bugfix: closing Search or Select files with Esc no longer kills pane keyboard navigation
`QueryDialog` (the shared Search/Select orchestrator) never restored focus on close, unlike `CommandPalette` and `ModalDialog`. Focus fell to `<body>`, so arrow keys stopped moving the pane cursor and natively scrolled the pane instead, until the user clicked back in. Worse, any dialog opened afterwards captured `<body>` as its own restore target, so the dead-keyboard state survived dialog round-trips. The dialog now captures `document.activeElement` synchronously on mount and restores it on destroy, the same pattern the palette uses. Regression test verified red-first against the unfixed code.
1 parent 6bdb188 commit 040d424

2 files changed

Lines changed: 31 additions & 0 deletions

File tree

apps/desktop/src/lib/query-ui/QueryDialog.svelte

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,9 @@
188188
queryInputElement?.focus()
189189
}
190190
191+
/** Element that had focus when the dialog opened (the pane container). Restored on close. */
192+
let previousActiveElement: HTMLElement | null = null
193+
191194
function openRecentPopover(): void {
192195
recentPopoverOpen = true
193196
}
@@ -197,6 +200,8 @@
197200
}
198201
199202
onMount(async () => {
203+
// Capture synchronously, before the awaits below and before focusInput() moves focus.
204+
previousActiveElement = document.activeElement instanceof HTMLElement ? document.activeElement : null
200205
notifyDialogOpened(config.dialogType).catch(() => {})
201206
window.addEventListener('keydown', handleEscapeCapture, true)
202207
// D8: mark the dialog as freshly opened so ⏎ owns "run-search" by default
@@ -244,6 +249,13 @@
244249
unlistenAutoApply?.()
245250
window.removeEventListener('keydown', handleEscapeCapture, true)
246251
if (debounceTimer) clearTimeout(debounceTimer)
252+
// Restore focus to whatever had it before we opened (the pane container), if it's
253+
// still in the DOM. Without this, focus falls to <body> after close: arrow keys stop
254+
// moving the pane cursor and natively scroll the pane instead, until the user clicks
255+
// back in. Same pattern as CommandPalette and ModalDialog.
256+
if (previousActiveElement?.isConnected) {
257+
previousActiveElement.focus()
258+
}
247259
// State is intentionally NOT cleared. Close + reopen preserves the user's
248260
// query/filters/results/cursor. The only reset path is ⌘N inside the dialog.
249261
})

apps/desktop/src/lib/query-ui/QueryDialog.svelte.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,3 +413,22 @@ describe('QueryDialog lastDialogEvent ownership', () => {
413413
cleanup()
414414
})
415415
})
416+
417+
describe('QueryDialog focus restore', () => {
418+
it('returns focus to the previously focused element on unmount', async () => {
419+
const outside = document.createElement('button')
420+
document.body.appendChild(outside)
421+
outside.focus()
422+
const { cleanup } = mountQueryDialog()
423+
// Let the async onMount settle and move focus into the dialog's input.
424+
await tick()
425+
await Promise.resolve()
426+
await tick()
427+
expect(document.activeElement).not.toBe(outside)
428+
cleanup()
429+
await tick()
430+
// Pre-fix this landed on <body>, leaving pane keyboard nav dead after Escape.
431+
expect(document.activeElement).toBe(outside)
432+
outside.remove()
433+
})
434+
})

0 commit comments

Comments
 (0)