diff --git a/src/renderer/src/components/automations/AutomationListSearchField.test.tsx b/src/renderer/src/components/automations/AutomationListSearchField.test.tsx index b43e018f98b..c0d12ed3608 100644 --- a/src/renderer/src/components/automations/AutomationListSearchField.test.tsx +++ b/src/renderer/src/components/automations/AutomationListSearchField.test.tsx @@ -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) }) }) diff --git a/src/renderer/src/components/sidebar/WorktreeParentPickerPopover.test.ts b/src/renderer/src/components/sidebar/WorktreeParentPickerPopover.test.ts index ff761bbc249..4f7df50e9d3 100644 --- a/src/renderer/src/components/sidebar/WorktreeParentPickerPopover.test.ts +++ b/src/renderer/src/components/sidebar/WorktreeParentPickerPopover.test.ts @@ -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 }, diff --git a/src/renderer/src/components/sidebar/WorktreeParentPickerPopover.tsx b/src/renderer/src/components/sidebar/WorktreeParentPickerPopover.tsx index ad9ee845c8d..8647dabbcdd 100644 --- a/src/renderer/src/components/sidebar/WorktreeParentPickerPopover.tsx +++ b/src/renderer/src/components/sidebar/WorktreeParentPickerPopover.tsx @@ -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(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()