diff --git a/src/renderer/src/components/automations/AutomationListSearchField.test.tsx b/src/renderer/src/components/automations/AutomationListSearchField.test.tsx new file mode 100644 index 000000000..c0d12ed36 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationListSearchField.test.tsx @@ -0,0 +1,39 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { AutomationListSearchField } from './AutomationListSearchField' + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) + +describe('AutomationListSearchField', () => { + it('autofocuses the search input on mount', () => { + act(() => { + root.render( + undefined} + onClear={() => undefined} + /> + ) + }) + + const input = container.querySelector('input') + expect(input).not.toBeNull() + expect(document.activeElement).toBe(input) + }) +}) diff --git a/src/renderer/src/components/automations/AutomationListSearchField.tsx b/src/renderer/src/components/automations/AutomationListSearchField.tsx index 3a2bceb7c..d1691a490 100644 --- a/src/renderer/src/components/automations/AutomationListSearchField.tsx +++ b/src/renderer/src/components/automations/AutomationListSearchField.tsx @@ -34,6 +34,7 @@ export function AutomationListSearchField({ 0} searchQuery={settingsSearchInputQuery} searchInputRef={searchInputRef} + // Why: deep-links open panes/modals that own focus; plain entry lands in search. + searchAutoFocus={settingsNavigationTarget == null} onBack={closeSettingsPageWithPromptGuard} onSearchChange={setSettingsSearchQuery} onSelectSection={scrollToSection} diff --git a/src/renderer/src/components/settings/SettingsSidebar.tsx b/src/renderer/src/components/settings/SettingsSidebar.tsx index 5c912b051..c56e71b96 100644 --- a/src/renderer/src/components/settings/SettingsSidebar.tsx +++ b/src/renderer/src/components/settings/SettingsSidebar.tsx @@ -47,6 +47,7 @@ type SettingsSidebarProps = { hasRepos: boolean searchQuery: string searchInputRef?: RefObject + searchAutoFocus?: boolean onBack: () => void onSearchChange: (query: string) => void onSelectSection: ( @@ -134,6 +135,7 @@ export function SettingsSidebar({ hasRepos, searchQuery, searchInputRef, + searchAutoFocus = false, onBack, onSearchChange, onSelectSection @@ -196,6 +198,7 @@ export function SettingsSidebar({ onSearchChange(event.target.value)} placeholder={translate( diff --git a/src/renderer/src/components/sidebar/WorktreeParentPickerPopover.test.ts b/src/renderer/src/components/sidebar/WorktreeParentPickerPopover.test.ts index ff761bbc2..4f7df50e9 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 49af0e99e..8647dabbc 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, @@ -299,7 +313,8 @@ export function WorktreeParentPickerPopover({ } return ( - + // Why: modal traps focus (incl. post-menu xterm restore); non-modal loses search focus. + { + event.preventDefault() + inputRef.current?.focus() + }} + // 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() @@ -333,7 +359,6 @@ export function WorktreeParentPickerPopover({ 'auto.components.sidebar.WorktreeParentPickerPopover.searchPlaceholder', 'Search worktrees...' )} - autoFocus /> {filtered.length === 0 ? ( diff --git a/src/renderer/src/components/ui/command.tsx b/src/renderer/src/components/ui/command.tsx index 743cf7ee0..c9ecc5be7 100644 --- a/src/renderer/src/components/ui/command.tsx +++ b/src/renderer/src/components/ui/command.tsx @@ -138,6 +138,7 @@ function CommandInput({ wrapperClassName, iconClassName, trailing, + ref, ...props }: React.ComponentProps & { wrapperClassName?: string @@ -155,6 +156,7 @@ function CommandInput({ > { const searchInput = orcaPage.getByPlaceholder('Search settings') await expect(searchInput).toBeVisible() + // Why: plain Settings open should land focus in search so typing starts immediately. + await expect(searchInput).toBeFocused() await searchInput.fill('shortcuts') // The query matches the pane title, so the Shortcuts pane auto-activates.