diff --git a/apps/desktop/src/main/__tests__/command-palette-a11y-copy-contract.test.ts b/apps/desktop/src/main/__tests__/command-palette-a11y-copy-contract.test.ts index 6d11c60aeb..78cae34ee3 100644 --- a/apps/desktop/src/main/__tests__/command-palette-a11y-copy-contract.test.ts +++ b/apps/desktop/src/main/__tests__/command-palette-a11y-copy-contract.test.ts @@ -14,17 +14,58 @@ async function readRepo(path: string): Promise { } describe('Command palette accessibility and visible copy', () => { - it('names the command results listbox controlled by the search input', async () => { + it('uses Base UI Autocomplete for the command results listbox (#520 PR8)', async () => { const src = await readRepo('apps/desktop/src/renderer/command-palette.tsx'); assert.match( src, - /aria-controls="maka-palette-list"/, - 'palette input must keep its aria-controls link to the results list', + /import \{ Autocomplete \} from '@base-ui\/react\/autocomplete'/, + 'CommandPalette must consume Base UI Autocomplete for the result list', ); assert.match( src, - /
/, - 'palette results listbox must expose a name in the accessibility tree', + / leave -> Enter runs that item, not the first — #562 P2); mode="none" (palette owns fuzzy + content-search filtering) + autoHighlight="always"', + ); + assert.match( + src, + / cmd\.label\}/, + 'Autocomplete.Root must serialize object commands via itemToStringValue — without it, item-press can write [object Object] back into the query', + ); + assert.match( + src, + /onValueChange=\{\(next, details\) => \{[\s\S]*?details\.reason === 'item-press'[\s\S]*?setQuery\(next\)/, + 'Autocomplete value changes must skip item-press reasons (selection must not write the command object back into the query)', + ); + assert.match(src, //, + 'Palette results must render as Autocomplete.List (listbox) with an accessible name', + ); + assert.match( + src, + //, + 'Palette groups must render as Autocomplete.Group + GroupLabel', + ); + assert.match( + src, + / commit\(cmd\)\}/, + 'Each command must be Autocomplete.Item with onClick firing commit (pointer click or Enter on highlighted)', + ); + // P2-c: Home/End decision — accept Base UI ComboboxInput's input-cursor + // default. The old hand-rolled highlight jump (Home/End -> first/last) is + // gone and must not return. + assert.doesNotMatch( + src, + /\bjumpActive\w*\(|onInputKeyDown/, + 'Home/End must NOT jump highlight and there must be no hand-rolled input keydown handler — Base UI input-cursor default is the decided behavior (#562 P2-c)', + ); + // P2: empty state renders inside Autocomplete.List, not a standalone div, + // so the input always references a stable listbox container. + assert.doesNotMatch( + src, + /
{ assert.match( src, - /import \{[^}]*\bButton\b[^}]*\bDialogContent\b[^}]*\bDialogRoot\b[^}]*\bInputGroup\b[^}]*\bInputGroupAddon\b[^}]*\bInputGroupInput\b[^}]*\bKbd\b[^}]*\bKbdGroup\b[^}]*\} from '@maka\/ui';/, + /import \{[^}]*\bDialogContent\b[^}]*\bDialogRoot\b[^}]*\bInputGroup\b[^}]*\bInputGroupAddon\b[^}]*\bInputGroupInput\b[^}]*\bKbd\b[^}]*\bKbdGroup\b[^}]*\} from '@maka\/ui';/, 'CommandPalette must consume shared primitive InputGroup + Dialog primitives from @maka/ui', ); assert.match( @@ -96,7 +137,7 @@ describe('Command palette accessibility and visible copy', () => { assert.match(styles, /\.maka-palette-item\[data-pending="true"\]\s*\{[\s\S]*cursor:\s*progress;/); assert.match( styles, - /\.maka-palette-item\[data-active="true"\]\s*\{[\s\S]*background:\s*var\(--state-selected-bg\)/, + /\.maka-palette-item\[data-highlighted\]\s*\{[\s\S]*background:\s*var\(--state-selected-bg\)/, 'Palette active row uses the neutral state-selected token, not a brand rail', ); assert.match(styles, /\.maka-palette-icon\s*\{[\s\S]*width:\s*18px;[\s\S]*height:\s*18px;/); @@ -127,9 +168,11 @@ describe('Command palette accessibility and visible copy', () => { const src = await readRepo('apps/desktop/src/renderer/command-palette.tsx'); const mainSrc = await readRendererShellCombinedSource(); const commandTypes = await readRepo('apps/desktop/src/renderer/command-palette-types.ts'); - const commandPaletteBlock = src.match(/export function CommandPalette[\s\S]*?function onInputKeyDown/)?.[0] ?? ''; + // #520 PR8: onInputKeyDown is gone (Autocomplete owns ArrowUp/Down/Enter), + // so the block boundary is the commit() helper now. + const commandPaletteBlock = src.match(/export function CommandPalette[\s\S]*?function commit/)?.[0] ?? ''; const commitBlock = src.match(/function commit\(cmd: Command \| undefined\) \{[\s\S]*?\n \}/)?.[0] ?? ''; - const rowBlock = src.match(/const commandCommitPending = committedCommandId === cmd\.id;[\s\S]*?onClick=\{\(\) => commit\(cmd\)\}/)?.[0] ?? ''; + const rowBlock = src.match(/const commandCommitPending = committedCommandId === cmd\.id;[\s\S]*?data-pending=\{commandCommitPending \? 'true' : undefined\}/)?.[0] ?? ''; assert.match(commandTypes, /run\(\): void \| Promise/, 'command actions may be async and must be awaited by commit()'); assert.match(commandPaletteBlock, /const commitPendingRef = useRef\(false\)/); @@ -153,19 +196,21 @@ describe('Command palette accessibility and visible copy', () => { assert.match(rowBlock, /data-pending=\{commandCommitPending \? 'true' : undefined\}/); }); - it('resets active command to the first result when the result set changes', async () => { + it('resets active command to the first result when the result set changes (#520 PR8)', async () => { const src = await readRepo('apps/desktop/src/renderer/command-palette.tsx'); - const highlightEffect = src.match(/useEffect\(\(\) => \{[\s\S]*?Reset highlight whenever the result set changes\.[\s\S]*?\}, \[combined\]\);/)?.[0] ?? ''; - + // #520 PR8: Autocomplete's autoHighlight="always" owns highlight reset — + // the first item is always highlighted, so Enter on a fresh result set + // always activates the top command. The old hand-rolled highlight state + + // useEffect reset is gone. assert.match( - highlightEffect, - /setHighlight\(0\);/, - 'CommandPalette must reset highlight to the first new result after filtering/search results change', + src, + /autoHighlight="always"/, + 'Autocomplete.Root must use autoHighlight="always" so the first command is always highlighted and Enter works without an extra ArrowDown', ); assert.doesNotMatch( - highlightEffect, - /Math\.min\(current,\s*Math\.max\(0,\s*combined\.length - 1\)\)/, - 'CommandPalette must not preserve a stale lower-row highlight across a new result set', + src, + /\[highlight, setHighlight\]/, + 'CommandPalette must not keep a hand-rolled highlight state — Autocomplete owns it', ); }); diff --git a/apps/desktop/src/main/__tests__/renderer-utility-primitives-contract.test.ts b/apps/desktop/src/main/__tests__/renderer-utility-primitives-contract.test.ts index e7f228971a..11354aba22 100644 --- a/apps/desktop/src/main/__tests__/renderer-utility-primitives-contract.test.ts +++ b/apps/desktop/src/main/__tests__/renderer-utility-primitives-contract.test.ts @@ -118,14 +118,15 @@ describe('renderer utility surfaces use shared UI primitives', () => { it('keeps command palette search and rows on shared primitives', async () => { const source = await readFile(join(process.cwd(), 'src/renderer/command-palette.tsx'), 'utf8'); - assert.match(source, /import \{[^}]*\bButton\b[^}]*\bDialogContent\b[^}]*\bDialogRoot\b[^}]*\bInputGroup\b[^}]*\bInputGroupAddon\b[^}]*\bInputGroupInput\b[^}]*\bKbd\b[^}]*\bKbdGroup\b[^}]*\} from '@maka\/ui';/); + assert.match(source, /import \{[^}]*\bDialogContent\b[^}]*\bDialogRoot\b[^}]*\bInputGroup\b[^}]*\bInputGroupAddon\b[^}]*\bInputGroupInput\b[^}]*\bKbd\b[^}]*\bKbdGroup\b[^}]*\} from '@maka\/ui';/); + assert.match(source, /import \{ Autocomplete \} from '@base-ui\/react\/autocomplete'/, 'CommandPalette must consume Base UI Autocomplete for the result list (#520 PR8)'); assert.doesNotMatch(source, / \{/); assert.match(source, //); - assert.match(source, /[\s\S]*↑<\/Kbd>[\s\S]*↓<\/Kbd>/); }); diff --git a/apps/desktop/src/main/__tests__/search-modal-lifecycle-contract.test.ts b/apps/desktop/src/main/__tests__/search-modal-lifecycle-contract.test.ts index d55b24faf6..7f223d163b 100644 --- a/apps/desktop/src/main/__tests__/search-modal-lifecycle-contract.test.ts +++ b/apps/desktop/src/main/__tests__/search-modal-lifecycle-contract.test.ts @@ -227,30 +227,65 @@ describe('SearchModal lifecycle contract (PR-SIDEBAR-IA-0 Phase 3 P0 fixup)', () ); }); - it('search results support keyboard selection from the input', async () => { + it('search results use Base UI Autocomplete for listbox + keyboard selection (#520 PR8)', async () => { const searchModal = await readFile(SEARCH_MODAL_PATH, 'utf8'); const styles = await readRendererContractCss(); - assert.match(searchModal, /activeResultIndex/, 'SearchModal must track the active result index'); - assert.match(searchModal, /aria-activedescendant=\{activeResultId\}/, 'Search input must expose the active result to assistive tech'); - assert.match(searchModal, /className="maka-search-modal-body" role="region" aria-label="搜索状态和结果" aria-live="polite"/, 'Search modal body region must expose an accessible name'); - assert.match(searchModal, /role="listbox" aria-label="搜索结果"/, 'Search results must expose a listbox for aria-activedescendant'); - assert.match(searchModal, /role="option"[\s\S]*aria-selected=\{activeResultIndex === index\}/, 'Search result rows must expose selected option state'); - assert.match(searchModal, /keyboardKey\(event, \['ArrowDown', 'Down'\]\)[\s\S]*moveActiveResult\(1,\s*\{ focusResult: true \}\)/, 'ArrowDown/Down must move focus to the next result'); - assert.match(searchModal, /keyboardKey\(event, \['ArrowUp', 'Up'\]\)[\s\S]*moveActiveResult\(-1,\s*\{ focusResult: true \}\)/, 'ArrowUp/Up must move focus to the previous result'); - assert.match(searchModal, /function jumpActiveResult\(index: number,\s*options\?: \{ focusResult\?: boolean \}\)/, 'SearchModal must support direct active-result jumps'); - assert.match(searchModal, /keyboardKey\(event, \['Home'\]\)[\s\S]*jumpActiveResult\(0,\s*\{ focusResult: true \}\)/, 'Home must jump focus to the first result'); - assert.match(searchModal, /keyboardKey\(event, \['End'\]\)[\s\S]*jumpActiveResult\(results\.length - 1,\s*\{ focusResult: true \}\)/, 'End must jump focus to the last result'); - assert.match(searchModal, /function selectKeyboardResult\(\) \{[\s\S]*results\[activeResultIndex >= 0 \? activeResultIndex : 0\]/, 'Enter/Return must fall back to opening the first result when no row is active'); - assert.match(searchModal, /const keyboardSelectionHandledRef = useRef\(false\)/, 'SearchModal must keep Enter keydown and keyup from double-activating the same result'); - assert.match(searchModal, /keyboardSelectionHandledRef\.current = true;[\s\S]*selectKeyboardResult\(\)/, 'Enter/Return keydown must mark the selection handled before opening the result'); - assert.match(searchModal, /onKeyUp=\{\(event\) => \{[\s\S]*keyboardSelectionHandledRef\.current\)[\s\S]*keyboardSelectionHandledRef\.current = false;[\s\S]*return;[\s\S]*keyboardKey\(event, \['Enter', 'Return'\]\) && showResults[\s\S]*selectKeyboardResult\(\)/, 'Search input keyup fallback must stay for Electron search-field quirks but skip Enter already handled on keydown'); - assert.match(searchModal, /function handleResultKeyDown\(event: KeyboardEvent, index: number, result: SearchResult\)/, 'Focused search result rows must have their own keyboard handler'); - assert.match(searchModal, /keyboardKey\(event, \['Enter', 'Return', 'Space', ' '\]\)[\s\S]*selectResult\(result\)/, 'Focused search result rows must activate on Enter, Return, or Space'); - assert.match(searchModal, /tabIndex=\{-1\}/, 'Search result rows should be arrow-key focused, not extra tab stops'); - assert.match(searchModal, /onKeyDown=\{\(event\) => handleResultKeyDown\(event, index, result\)\}/, 'Search result rows must wire the keyboard handler'); - assert.match(searchModal, /data-active=\{activeResultIndex === index \? 'true' : undefined\}/, 'Active result must get a visible state hook'); - assert.match(styles, /\.maka-search-modal-result\[data-active="true"\]:not\(\[disabled\]\)/, 'Active search result must have dedicated styling'); + // #520 PR8: SearchModal converges onto Base UI Autocomplete instead of a + // hand-rolled roving-focus listbox. Autocomplete owns the listbox/option + // ARIA + ArrowUp/Down/Enter/Escape keyboard nav (activedescendant mode: + // input keeps focus, active item reflected via aria-activedescendant). + assert.match( + searchModal, + /import \{ Autocomplete \} from '@base-ui\/react\/autocomplete'/, + 'SearchModal must consume Base UI Autocomplete for the result list', + ); + assert.match( + searchModal, + / leave -> Enter runs that item, not the first — #562 P2); mode="none" (server-side IPC filtering, no local filter) + autoHighlight="always" so Enter on the first result works without an extra ArrowDown', + ); + assert.match( + searchModal, + / result\.title/, + 'Autocomplete.Root must serialize object results via itemToStringValue — without it, item-press can write [object Object] back into the query', + ); + assert.match(searchModal, / selectResult\(result\)\}/, + 'Each result must be Autocomplete.Item with onClick firing selectResult (pointer click or Enter on highlighted)', + ); + // selectResult navigation contract is unchanged from the roving-focus era. + assert.match( + searchModal, + /props\.onNavigateToSession\(result\.target\.sessionId,\s*result\.target\.turnId\)/, + 'selectResult must still pass sessionId + turnId to the shell navigation callback', + ); + assert.match( + searchModal, + /props\.onClose\(\{ restoreFocus: false \}\)/, + 'selectResult must still skip focus restore so the destination chat owns focus', + ); + assert.match( + searchModal, + /className="maka-search-modal-body" role="region" aria-label="搜索状态和结果" aria-live="polite"/, + 'Search modal body region must still expose an accessible name', + ); + assert.match( + styles, + /\.maka-search-modal-result\[data-highlighted\]/, + 'Highlighted (active) search result must have dedicated styling (Autocomplete data-highlighted replaces the old data-active)', + ); + // P2-c: Home/End decision — accept Base UI ComboboxInput's input-cursor + // default. The old roving-focus jumpActiveResult (Home/End -> first/last) + // must not return. + assert.doesNotMatch( + searchModal, + /\bjumpActive\w*\(/, + 'Home/End must NOT jump highlight — Base UI input-cursor default is the decided behavior (#562 P2-c)', + ); }); it('search input keeps focus after results load until the user navigates results', async () => { @@ -266,16 +301,10 @@ describe('SearchModal lifecycle contract (PR-SIDEBAR-IA-0 Phase 3 P0 fixup)', () /finalFocus=\{\(\) => \(suppressFocusRestoreRef\.current \? false : true\)\}/, 'SearchModal must skip Base UI focus restore when navigating to a result so the destination owns focus', ); - assert.match( - searchModal, - /setResults\(response\);\s*setError\(null\);\s*setActiveResultIndex\(-1\);/m, - 'Search results must not automatically move active-descendant focus onto the first result while the user is still typing', - ); - assert.match( - searchModal, - /const next = activeResultIndex < 0\s*\?\s*\(delta > 0 \? 0 : results\.length - 1\)/, - 'Arrow navigation should still select the first or last result from the input', - ); + // #520 PR8: activedescendant mode keeps focus in the input; the active + // item is reflected via aria-activedescendant managed by Autocomplete. + // The old activeResultIndex / moveActiveResult / jumpActiveResult + // roving-focus machinery is gone. }); it('search query has an explicit clear button because the native search cancel is hidden', async () => { @@ -327,7 +356,7 @@ describe('SearchModal lifecycle contract (PR-SIDEBAR-IA-0 Phase 3 P0 fixup)', () assert.match(searchModal, /function clearSearchState\(\) \{\s*ticketRef\.current \+= 1;\s*setResults\(\[\]\);/m, 'Shared clear state helper must invalidate in-flight search before clearing results'); assert.match(searchModal, /function updateSearchQuery\(nextQuery: string\) \{[\s\S]*if \(nextQuery\.trim\(\)\.length === 0\) \{[\s\S]*clearSearchState\(\);[\s\S]*\}/, 'Typing/deleting to an empty query must synchronously invalidate in-flight search'); - assert.match(searchModal, /onChange=\{\(event\) => updateSearchQuery\(event\.currentTarget\.value\)\}/, 'Search input changes must go through the synchronized update helper'); + assert.match(searchModal, /onValueChange=\{\(next, details\) => \{[\s\S]*?details\.reason === 'item-press'[\s\S]*?updateSearchQuery\(next\)/, 'Autocomplete value changes must go through the synchronized update helper AND skip item-press reasons (selection must not write the result object back into the query)'); assert.match(searchModal, /keyboardKey\(event, \['Escape'\]\) && query[\s\S]*clearSearchQuery\(\);/, 'Escape clear path must synchronously invalidate in-flight search'); assert.match( searchModal, diff --git a/apps/desktop/src/renderer/command-palette.tsx b/apps/desktop/src/renderer/command-palette.tsx index 6bda557d5f..4e04041e43 100644 --- a/apps/desktop/src/renderer/command-palette.tsx +++ b/apps/desktop/src/renderer/command-palette.tsx @@ -6,7 +6,7 @@ // Arrow/Enter/Esc navigation is local to the input, focus trap + restore + // Esc-dismiss come from DialogRoot/DialogContent (#520 PR7). -import { useDeferredValue, useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'; +import { useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'; import { CalendarDays, ChevronRight, @@ -33,7 +33,6 @@ import { import type { LlmConnection, PermissionMode, SessionSummary, SettingsSection, ThemePreference } from '@maka/core'; import type { NavSelection } from '@maka/ui'; import { - Button, DialogContent, DialogRoot, Empty, @@ -47,6 +46,7 @@ import { Kbd, KbdGroup, } from '@maka/ui'; +import { Autocomplete } from '@base-ui/react/autocomplete'; import { SETTINGS_NAV } from './settings/settings-nav'; import { useThreadSearch } from './use-thread-search'; import { buildContentSearchCommands } from './command-palette-content-search'; @@ -587,7 +587,6 @@ export function CommandPalette(props: { const inputRef = useRef(null); const commitPendingRef = useRef(false); const [query, setQuery] = useState(''); - const [highlight, setHighlight] = useState(0); const [committedCommandId, setCommittedCommandId] = useState(null); // Focus + select the search input as soon as the dialog mounts. @@ -633,11 +632,6 @@ export function CommandPalette(props: { // memory for cmd-K + first-letter navigation. const combined = useMemo(() => [...filtered, ...contentCommands], [filtered, contentCommands]); - useEffect(() => { - // Reset highlight whenever the result set changes. - setHighlight(0); - }, [combined]); - const grouped = useMemo(() => groupCommands(combined), [combined]); function commit(cmd: Command | undefined) { @@ -659,34 +653,6 @@ export function CommandPalette(props: { })().catch(() => undefined); } - function onInputKeyDown(event: KeyboardEvent) { - if (event.nativeEvent.isComposing || event.key === 'Process') return; - if (event.key === 'ArrowDown') { - event.preventDefault(); - setHighlight((current) => (combined.length === 0 ? 0 : Math.min(combined.length - 1, current + 1))); - return; - } - if (event.key === 'ArrowUp') { - event.preventDefault(); - setHighlight((current) => Math.max(0, current - 1)); - return; - } - if (event.key === 'Home') { - event.preventDefault(); - setHighlight(0); - return; - } - if (event.key === 'End') { - event.preventDefault(); - setHighlight(combined.length === 0 ? 0 : combined.length - 1); - return; - } - if (event.key === 'Enter') { - event.preventDefault(); - commit(combined[highlight]); - } - } - return ( - { - const target = event.target as HTMLElement; - if (target.closest('input')) return; - event.preventDefault(); - inputRef.current?.focus(); + {/* + #520 PR8: Autocomplete owns the listbox/option ARIA + ArrowUp/Down/ + Enter/Escape keyboard nav (activedescendant mode). `inline` keeps the + list in the modal body. `mode="none"` + `filter={null}` preserve the + palette's own fuzzy + content-search filtering — Autocomplete does not + re-filter the combined list locally. `autoHighlight="always"` so Enter + on the first command works without an extra ArrowDown. + */} + { + // item-press (click / Enter on highlighted) is a selection, not + // input — never write the command object back into the query. + if (details.reason === 'item-press') return; + setQuery(next); }} + itemToStringValue={(cmd) => cmd.label} + items={combined} > - setQuery(event.currentTarget.value)} - onKeyDown={onInputKeyDown} - autoComplete="off" - spellCheck={false} - aria-controls="maka-palette-list" - aria-activedescendant={combined[highlight] ? `cmd-${combined[highlight]!.id}` : undefined} - /> - - - - -
- {grouped.length === 0 ? ( - - - - - 没有匹配的命令 - 换个关键词,或按 Esc 关闭。 - - - ) : ( - grouped.map((group) => ( -
-
{group.label}
- {group.items.map((entry) => { - const index = entry.index; - const cmd = entry.command; - const active = index === highlight; - const commandCommitPending = committedCommandId === cmd.id; - return ( - - ); - })} -
- )) - )} -
+ {cmd.label} + {cmd.hint && ( + + {cmd.hint} + + )} + {!cmd.hint && ( + + )} + + ); + })} + + )) + )} + +
diff --git a/apps/desktop/src/renderer/styles/chat-header.css b/apps/desktop/src/renderer/styles/chat-header.css index 8b1c1e640f..6408839777 100644 --- a/apps/desktop/src/renderer/styles/chat-header.css +++ b/apps/desktop/src/renderer/styles/chat-header.css @@ -845,7 +845,10 @@ background: var(--state-hover-bg); } -.maka-palette-item[data-active="true"] { +/* #520 PR8: Autocomplete.Item exposes the active (highlighted) item via + data-highlighted (activedescendant mode — the input keeps focus, so the + item itself is no longer :focus-visible). */ +.maka-palette-item[data-highlighted] { background: var(--state-selected-bg); color: var(--foreground); } @@ -881,11 +884,21 @@ color var(--duration-base) var(--ease-out-strong); } -.maka-palette-item[data-active="true"] .maka-palette-icon { +.maka-palette-item[data-highlighted] .maka-palette-icon { background: var(--state-selected-bg); color: var(--foreground); } +/* #520 PR8: the CornerDownLeft cursor hint shows only on the highlighted + item (was `!cmd.hint && active` in JS; now CSS-driven via data-highlighted + so we don't need a hand-rolled highlight state). */ +.maka-palette-cursor { + visibility: hidden; +} +.maka-palette-item[data-highlighted] .maka-palette-cursor { + visibility: visible; +} + .maka-palette-label { overflow: hidden; text-overflow: ellipsis; diff --git a/apps/desktop/src/renderer/styles/sidebar.css b/apps/desktop/src/renderer/styles/sidebar.css index 710d04e4e7..762c645d15 100644 --- a/apps/desktop/src/renderer/styles/sidebar.css +++ b/apps/desktop/src/renderer/styles/sidebar.css @@ -459,14 +459,12 @@ background: var(--state-hover-bg); transform: translateX(1px); } -.maka-search-modal-result[data-active="true"]:not([disabled]) { +/* #520 PR8: Autocomplete.Item exposes the active (highlighted) item via + data-highlighted (activedescendant mode — the input keeps focus, so the + item itself is no longer :focus-visible). */ +.maka-search-modal-result[data-highlighted]:not([disabled]) { background: var(--state-selected-bg); } -.maka-search-modal-result:focus-visible { - outline: none; - background: var(--state-hover-bg); - box-shadow: 0 0 0 var(--focus-ring-width) oklch(from var(--focus-ring) l c h / 0.18); -} .maka-search-modal-result[disabled] { cursor: default; opacity: var(--opacity-muted); diff --git a/packages/ui/src/search-modal.tsx b/packages/ui/src/search-modal.tsx index 61adc80df4..779fea0e55 100644 --- a/packages/ui/src/search-modal.tsx +++ b/packages/ui/src/search-modal.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'; import type { SearchErrorReason, SearchRequest, SearchResult } from '@maka/core'; import { generalizedErrorMessageChinese } from '@maka/core'; +import { Autocomplete } from '@base-ui/react/autocomplete'; import { Search, X } from './icons.js'; import { InputGroup, InputGroupAddon, InputGroupInput } from './primitives/input-group.js'; import { DialogClose, DialogContent, DialogRoot, Button as UiButton } from './ui.js'; @@ -27,6 +28,17 @@ import { DialogClose, DialogContent, DialogRoot, Button as UiButton } from './ui * way for a future hook addition to drift past a stale return * statement. * + * #520 PR8: the result list converges onto Base UI Autocomplete + * (`inline` + `mode="none"` + `autoHighlight="always"` + `filter={null}`). + * Autocomplete owns the listbox/option ARIA structure and the + * ArrowUp/Down/Enter/Escape keyboard navigation in activedescendant + * mode (input keeps focus, the active item is reflected via + * aria-activedescendant). Server-side IPC filtering is preserved by + * `filter={null}` + `mode="none"` (Autocomplete does not re-filter + * the IPC results locally). The previous hand-rolled roving-focus + * machinery (activeResultIndex / moveActiveResult / jumpActiveResult + * / keyboardSelectionHandledRef) is gone. + * * Gate per kenji `7c320898`: * - role="dialog" / aria-modal="true" / explicit title. * - Esc and close button close the modal. @@ -98,12 +110,9 @@ export function SearchModal(props: { const [results, setResults] = useState([]); const [error, setError] = useState<{ reason: SearchErrorReason; message: string } | null>(null); const [pending, setPending] = useState(false); - const [activeResultIndex, setActiveResultIndex] = useState(-1); const inputRef = useRef(null); - const resultRefs = useRef>([]); const ticketRef = useRef(0); const searchMountedRef = useRef(true); - const keyboardSelectionHandledRef = useRef(false); const searchThread = props.deps?.searchThread; const suppressFocusRestoreRef = useRef(false); @@ -125,7 +134,6 @@ export function SearchModal(props: { setResults([]); setError(null); setPending(false); - setActiveResultIndex(-1); return; } const ticket = ++ticketRef.current; @@ -142,11 +150,9 @@ export function SearchModal(props: { if (Array.isArray(response)) { setResults(response); setError(null); - setActiveResultIndex(-1); } else { setResults([]); setError({ reason: response.reason, message: response.message }); - setActiveResultIndex(-1); } } catch (err) { if (!searchMountedRef.current) return; @@ -158,7 +164,6 @@ export function SearchModal(props: { reason: 'provider_error', message: searchModalThrownErrorMessage(err), }); - setActiveResultIndex(-1); } finally { if (searchMountedRef.current && ticket === ticketRef.current) setPending(false); } @@ -166,11 +171,6 @@ export function SearchModal(props: { return () => window.clearTimeout(handle); }, [query, searchThread]); - useEffect(() => { - if (activeResultIndex < 0) return; - resultRefs.current[activeResultIndex]?.scrollIntoView({ block: 'nearest' }); - }, [activeResultIndex]); - function selectResult(result: SearchResult) { if (!props.onNavigateToSession) return; if (result.target?.kind !== 'thread') return; @@ -181,17 +181,11 @@ export function SearchModal(props: { props.onClose({ restoreFocus: false }); } - function selectKeyboardResult() { - if (!showResults) return; - selectResult(results[activeResultIndex >= 0 ? activeResultIndex : 0]!); - } - function clearSearchState() { ticketRef.current += 1; setResults([]); setError(null); setPending(false); - setActiveResultIndex(-1); } function updateSearchQuery(nextQuery: string) { @@ -207,73 +201,14 @@ export function SearchModal(props: { inputRef.current?.focus(); } - function focusSearchResult(index: number) { - window.requestAnimationFrame(() => { - resultRefs.current[index]?.focus({ preventScroll: true }); - }); - } - - function moveActiveResult(delta: 1 | -1, options?: { focusResult?: boolean }) { - if (results.length === 0) return; - const next = activeResultIndex < 0 - ? (delta > 0 ? 0 : results.length - 1) - : (activeResultIndex + delta + results.length) % results.length; - setActiveResultIndex(next); - if (options?.focusResult) focusSearchResult(next); - } - - function jumpActiveResult(index: number, options?: { focusResult?: boolean }) { - if (results.length === 0) return; - const next = Math.max(0, Math.min(results.length - 1, index)); - setActiveResultIndex(next); - if (options?.focusResult) focusSearchResult(next); - } - function keyboardKey(event: KeyboardEvent, keys: string[]) { return keys.includes(event.key) || keys.includes(event.code); } - function handleResultKeyDown(event: KeyboardEvent, index: number, result: SearchResult) { - if (keyboardKey(event, ['Enter', 'Return', 'Space', ' '])) { - event.preventDefault(); - selectResult(result); - return; - } - if (keyboardKey(event, ['ArrowDown', 'Down'])) { - event.preventDefault(); - moveActiveResult(1, { focusResult: true }); - return; - } - if (keyboardKey(event, ['ArrowUp', 'Up'])) { - event.preventDefault(); - moveActiveResult(-1, { focusResult: true }); - return; - } - if (keyboardKey(event, ['Home'])) { - event.preventDefault(); - jumpActiveResult(0, { focusResult: true }); - return; - } - if (keyboardKey(event, ['End'])) { - event.preventDefault(); - jumpActiveResult(results.length - 1, { focusResult: true }); - return; - } - if (keyboardKey(event, ['Escape'])) { - event.preventDefault(); - props.onClose(); - return; - } - if (index !== activeResultIndex) { - setActiveResultIndex(index); - } - } - const incognitoBlocked = error?.reason === 'incognito_active'; const trimmed = query.trim(); const showResults = !error && trimmed.length > 0 && !pending && results.length > 0; const showEmpty = !error && trimmed.length > 0 && !pending && results.length === 0; - const activeResultId = showResults && activeResultIndex >= 0 ? `maka-search-modal-result-${activeResultIndex}` : undefined; const resultsTruncated = showResults && results.some((result) => result.truncated === true); return ( @@ -302,139 +237,130 @@ export function SearchModal(props: {