Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,58 @@ async function readRepo(path: string): Promise<string> {
}

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,
/<div className="maka-palette-list" id="maka-palette-list" role="listbox" aria-label="命令面板结果">/,
'palette results listbox must expose a name in the accessibility tree',
/<Autocomplete\.Root[\s\S]*?inline[\s\S]*?\bopen\b[\s\S]*?mode="none"[\s\S]*?autoHighlight="always"[\s\S]*?\bkeepHighlight\b[\s\S]*?filter=\{null\}/,
'Autocomplete.Root must render `inline open` + keepHighlight: `inline open` so the list is treated as visible (Base UI docs); keepHighlight so pointer leave preserves the hovered highlight (hover item -> leave -> Enter runs that item, not the first — #562 P2); mode="none" (palette owns fuzzy + content-search filtering) + autoHighlight="always"',
);
assert.match(
src,
/<Autocomplete\.Root[\s\S]*?itemToStringValue=\{\(cmd\) => 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, /<Autocomplete\.Input/, 'Palette input must be Autocomplete.Input');
assert.match(
src,
/<Autocomplete\.List className="maka-palette-list" id="maka-palette-list" aria-label="命令面板结果">/,
'Palette results must render as Autocomplete.List (listbox) with an accessible name',
);
assert.match(
src,
/<Autocomplete\.Group[\s\S]*?<Autocomplete\.GroupLabel className="maka-palette-group-label">/,
'Palette groups must render as Autocomplete.Group + GroupLabel',
);
assert.match(
src,
/<Autocomplete\.Item[\s\S]*?onClick=\{\(\) => 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,
/<div className="maka-palette-list"/,
'Empty state must render inside Autocomplete.List, not a standalone div — input must always reference a listbox container (#562 P2)',
);
});

Expand All @@ -35,7 +76,7 @@ describe('Command palette accessibility and visible copy', () => {

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(
Expand Down Expand Up @@ -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;/);
Expand Down Expand Up @@ -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<void>/, 'command actions may be async and must be awaited by commit()');
assert.match(commandPaletteBlock, /const commitPendingRef = useRef\(false\)/);
Expand All @@ -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',
);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, /<input\b/, 'Command palette search must use shared Input');
assert.doesNotMatch(source, /<button\b/, 'Command palette rows must use shared Button');
assert.doesNotMatch(source, /<kbd\b/, 'Command palette shortcut glyphs must use shared primitive Kbd');
assert.match(source, /<InputGroup[\s\S]*className="maka-palette-input-wrap"[\s\S]*aria-label="命令面板搜索"[\s\S]*onMouseDown=\{\(event\) => \{/);
assert.match(source, /<InputGroupInput[\s\S]*className="maka-palette-input"/);
assert.match(source, /<InputGroupAddon align="inline-end" className="maka-palette-input-hint-addon">/);
assert.match(source, /<Button[\s\S]*role="option"[\s\S]*className="maka-palette-item"/);
assert.match(source, /<Autocomplete.Item[\s\S]*className="maka-palette-item"/, 'Command palette rows must be Autocomplete.Item (#520 PR8)');
assert.match(source, /<KbdGroup className="maka-shortcut-group">[\s\S]*<Kbd className="maka-shortcut-kbd">↑<\/Kbd>[\s\S]*<Kbd className="maka-shortcut-kbd">↓<\/Kbd>/);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLButtonElement>, 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,
/<Autocomplete\.Root[\s\S]*?inline[\s\S]*?\bopen\b[\s\S]*?mode="none"[\s\S]*?autoHighlight="always"[\s\S]*?\bkeepHighlight\b[\s\S]*?filter=\{null\}/,
'Autocomplete.Root must render `inline open` + keepHighlight: `inline open` so the list is treated as visible (Base UI docs); keepHighlight so pointer leave preserves the hovered highlight (hover item -> 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,
/<Autocomplete\.Root[\s\S]*?itemToStringValue=\{\(result\) => 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, /<Autocomplete\.Input/, 'Search input must be Autocomplete.Input');
assert.match(searchModal, /<Autocomplete\.List/, 'Results must render as Autocomplete.List (listbox)');
assert.match(
searchModal,
/<Autocomplete\.Item[\s\S]*?onClick=\{\(\) => 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 () => {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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,
Expand Down
Loading