Focus search inputs for immediate typing (#13264)

* Focus search inputs for immediate typing

- Autofocus inputs in AutomationListSearchField, SettingsSidebar, and WorktreeParentPickerPopover
- Only autofocus Settings search when opening directly, not via deep-link
- Use modal mode and explicit focus management in popover for proper restoration
- Forward CommandInput ref and add autofocus test coverage

* 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:43 -07:00 committed by GitHub
parent 05c30166f4
commit 8a773a5e3f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 109 additions and 2 deletions

View File

@ -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(
<AutomationListSearchField
query=""
isTooLarge={false}
onQueryChange={() => undefined}
onClear={() => undefined}
/>
)
})
const input = container.querySelector('input')
expect(input).not.toBeNull()
expect(document.activeElement).toBe(input)
})
})

View File

@ -34,6 +34,7 @@ export function AutomationListSearchField({
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
ref={inputRef}
autoFocus
value={query}
aria-label={translate(
'auto.components.automations.AutomationListSearchField.label',

View File

@ -1204,6 +1204,8 @@ function Settings(): React.JSX.Element {
hasRepos={repos.length > 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}

View File

@ -47,6 +47,7 @@ type SettingsSidebarProps = {
hasRepos: boolean
searchQuery: string
searchInputRef?: RefObject<HTMLInputElement | null>
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({
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
ref={searchInputRef}
autoFocus={searchAutoFocus}
value={searchQuery}
onChange={(event) => onSearchChange(event.target.value)}
placeholder={translate(

View File

@ -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 },

View File

@ -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,
@ -299,7 +313,8 @@ export function WorktreeParentPickerPopover({
}
return (
<Popover open={open} onOpenChange={onOpenChange}>
// Why: modal traps focus (incl. post-menu xterm restore); non-modal loses search focus.
<Popover modal open={open} onOpenChange={onOpenChange}>
<PopoverAnchor virtualRef={virtualAnchorRef} />
<PopoverContent
align="start"
@ -307,6 +322,17 @@ export function WorktreeParentPickerPopover({
sideOffset={8}
collisionPadding={PICKER_VIEWPORT_PADDING}
className="flex max-h-(--radix-popover-content-available-height) w-80 flex-col p-0"
onOpenAutoFocus={(event) => {
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
/>
<CommandList ref={listRef} className="max-h-72 min-h-0 flex-1">
{filtered.length === 0 ? (

View File

@ -138,6 +138,7 @@ function CommandInput({
wrapperClassName,
iconClassName,
trailing,
ref,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input> & {
wrapperClassName?: string
@ -155,6 +156,7 @@ function CommandInput({
>
<SearchIcon className={cn('mr-2 h-4 w-4 shrink-0 opacity-50', iconClassName)} />
<CommandPrimitive.Input
ref={ref}
data-slot="command-input"
className={cn(
'flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',

View File

@ -18,6 +18,8 @@ test.describe('Settings sidebar search on the Shortcuts pane', () => {
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.