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:
parent
05c30166f4
commit
8a773a5e3f
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue