Restore focus when closing worktree parent picker popover

- Find the nearest focusable ancestor of the anchor row to restore focus
  to instead of letting it drop on the detached input element
- Simplify focus assertion in AutomationListSearchField test to verify
  actual focus behavior rather than autofocus attribute presence
This commit is contained in:
Jinjing
2026-08-08 17:15:33 -07:00
parent 08eab3c880
commit 349fdde9b2
3 changed files with 55 additions and 3 deletions
@@ -34,6 +34,6 @@ describe('AutomationListSearchField', () => {
const input = container.querySelector('input')
expect(input).not.toBeNull()
expect(input?.hasAttribute('autofocus') || document.activeElement === input).toBe(true)
expect(document.activeElement).toBe(input)
})
})
@@ -1,6 +1,9 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Worktree } from '../../../../shared/types'
import {
getWorktreeParentPickerFocusRestoreTarget,
handleWorktreeParentPickerKeyDown,
selectWorktreeParent
} from './WorktreeParentPickerPopover'
@@ -173,6 +176,36 @@ describe('clampWorktreeParentPickerIndex', () => {
})
})
describe('getWorktreeParentPickerFocusRestoreTarget', () => {
function makeSidebarRow(): { listbox: HTMLElement; row: HTMLElement } {
const listbox = document.createElement('div')
listbox.tabIndex = 0
listbox.setAttribute('role', 'listbox')
const row = document.createElement('div')
row.setAttribute('role', 'option')
listbox.appendChild(row)
return { listbox, row }
}
afterEach(() => {
document.body.innerHTML = ''
})
it('restores focus to the focusable container of the anchored row', () => {
const { listbox, row } = makeSidebarRow()
document.body.appendChild(listbox)
expect(getWorktreeParentPickerFocusRestoreTarget(row)).toBe(listbox)
})
it('skips a row whose sidebar was already unmounted or never anchored', () => {
const { row } = makeSidebarRow()
expect(getWorktreeParentPickerFocusRestoreTarget(row)).toBeNull()
expect(getWorktreeParentPickerFocusRestoreTarget(null)).toBeNull()
})
})
describe('parent picker keyboard input', () => {
it.each([
{ isComposing: true, keyCode: 13 },
@@ -61,6 +61,20 @@ function getAnchorRect(anchorElement: HTMLElement | null): AnchorRect | null {
return anchorElement?.getBoundingClientRect() ?? null
}
const FOCUSABLE_ANCHOR_SELECTOR =
'a[href],button,input,select,textarea,[tabindex]:not([tabindex="-1"])'
// Why: the anchor is a non-focusable `role="option"` row, so closing focus has
// to land on its nearest focusable container (the sidebar listbox).
export function getWorktreeParentPickerFocusRestoreTarget(
anchorElement: HTMLElement | null
): HTMLElement | null {
if (!anchorElement?.isConnected) {
return null
}
return anchorElement.closest<HTMLElement>(FOCUSABLE_ANCHOR_SELECTOR)
}
export function selectWorktreeParent({
childWorktreeId,
parentWorktreeId,
@@ -312,8 +326,13 @@ export function WorktreeParentPickerPopover({
event.preventDefault()
inputRef.current?.focus()
}}
// Why: virtual anchor has no trigger for Radix to restore focus to.
onCloseAutoFocus={(event) => event.preventDefault()}
// Why: virtual anchor has no trigger for Radix to restore focus to, so
// drive it back to the anchored row's listbox instead of dropping it on
// the detached input (i.e. document.body).
onCloseAutoFocus={(event) => {
event.preventDefault()
getWorktreeParentPickerFocusRestoreTarget(anchorElement)?.focus()
}}
onInteractOutside={(event) => {
if (suppressInitialOutsideCloseRef.current) {
event.preventDefault()