Skip to content

feat(ui): migrate SearchModal + CommandPalette onto Base UI Autocomplete (#520 PR8) - #562

Merged
jackwener merged 5 commits into
mainfrom
feat/ui-combobox-converge
Jul 6, 2026
Merged

feat(ui): migrate SearchModal + CommandPalette onto Base UI Autocomplete (#520 PR8)#562
jackwener merged 5 commits into
mainfrom
feat/ui-combobox-converge

Conversation

@Astro-Han

@Astro-Han Astro-Han commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Summary

Migrate the two hand-rolled search/command result lists onto Base UI Autocomplete (activedescendant mode), converging the last hand-rolled listbox surfaces in the app onto the Base UI primitive.

  • SearchModal (packages/ui/src/search-modal.tsx): the roving-focus result list (activeResultIndex / moveActiveResult / jumpActiveResult / keyboardSelectionHandledRef / handleResultKeyDown / data-active) is replaced by Autocomplete.Root inline mode="none" autoHighlight="always" filter={null} + Autocomplete.Input (render InputGroupInput) + Autocomplete.List + Autocomplete.Item.
  • CommandPalette (apps/desktop/src/renderer/command-palette.tsx): the hand-rolled activedescendant list (highlight state + onInputKeyDown + reset useEffect + <div role=listbox> + <Button role=option data-active>) is replaced by the same Autocomplete shape, using Autocomplete.Group + Autocomplete.GroupLabel for the grouped command sections.

Why

Closes #520 (item: combobox/search-palette convergence onto Base UI). This is the last hand-rolled listbox surface in the app; PR7 already moved the Dialog shells onto Base UI Dialog, and PR9/PR10 converged card/badge/input primitives. PR8 closes the loop on the in-modal search/command lists.

Scope

Changed:

  • packages/ui/src/search-modal.tsx — rewritten onto Autocomplete; roving-focus machinery deleted.
  • apps/desktop/src/renderer/command-palette.tsx — rewritten onto Autocomplete; highlight state + onInputKeyDown + reset useEffect deleted; Button import dropped (rows are Autocomplete.Item).
  • apps/desktop/src/renderer/styles/sidebar.css.maka-search-modal-result[data-active="true"][data-highlighted]; :focus-visible row rule dropped (input keeps focus in activedescendant mode).
  • apps/desktop/src/renderer/styles/chat-header.css.maka-palette-item[data-active="true"][data-highlighted] (item + icon); .maka-palette-cursor visibility driven by [data-highlighted] (replaces the JS !cmd.hint && active conditional for the CornerDownLeft cursor hint).
  • Contract tests rewritten to lock the Autocomplete shape instead of the roving-focus/hand-rolled implementation:
    • search-modal-lifecycle-contract.test.ts — kbd-nav it-block → Autocomplete shape; focus-policy it-block drops activeResultIndex; empty-query it-block repoints onChangeonValueChange.
    • command-palette-a11y-copy-contract.test.ts — listbox it-block → Autocomplete shape; import regex drops Button; CSS data-active → data-highlighted; commit-gate block boundary is commit() (onInputKeyDown gone); highlight-reset it-block is now autoHighlight="always".
    • renderer-utility-primitives-contract.test.ts — row assert repoints <Button role=option><Autocomplete.Item>; adds Autocomplete import assertion.

Not included:

  • Home/End jump-to-first/last-result binding (see Reviewer notes).
  • CommandPalette content-search / fuzzy-filter logic (unchanged — mode="none" + filter={null} preserves the palette's own filtering).
  • SearchModal debounce / ticket guard / unmount invalidation / snippet rendering / copy (unchanged).

Verification

  • npm run typecheck — clean.
  • npm run -w @maka/ui test — 43/43 pass.
  • npm run -w @maka/desktop test — 2076/2076 pass.
  • Screenshots vs main (fuzz 5%):
    • sidebar-search-modal-open: AE=2655, RMSE 0.0006 (seed has no query → placeholder state; diff is anti-alias-level from the render-prop input).
    • command-palette-open: AE=7250, RMSE 0.0017 (diff from <div role=option> vs <button> default + data-highlighted bg on the first item).

User-facing impact

Keyboard navigation in both modals shifts from roving-focus / hand-rolled activedescendant to Base UI Autocomplete activedescendant:

  • The input keeps focus; the active item is reflected via aria-activedescendant (managed by Autocomplete). Previously SearchModal moved focus to the result button on ArrowDown.
  • ArrowUp/Down/Enter/Escape are owned by Autocomplete (floating-ui useListNavigation).
  • autoHighlight="always": the first result is always highlighted, so Enter on a fresh result set activates the top item without an extra ArrowDown.
  • Home/End now move the input cursor (Base UI ComboboxInput default; the query is editable text). Previously Home/End jumped highlight to the first/last result. Decision (P2-c): accept the Base UI input-cursor behavior — matches the input-first mental model; recovering the jump needs deep-path Base UI internal imports (upgrade risk). Locked by contract.

a11y is more standard (listbox/option/activedescendant owned by the primitive). No CHANGELOG/docs/migration needed (in-app UI, no persisted state).

Reviewer notes

Review follow-up (keepHighlight + empty-state, commit 81743dce):

  • keepHighlight added to both Roots. Source analysis: keepHighlight=false (default) sets resetOnPointerLeave=true (AriaCombobox.js:855), so pointer leave clears activeIndex, then autoHighlight="always" (line 678-682) re-highlights the first item → hover item[2] → leave → Enter ran the first item. keepHighlight makes pointer leave preserve the hovered item. Verified by source + Base UI docs; CDP hover-leave probe could not reliably trigger floating-ui useListNavigation's hover highlight (synthesized pointermove does not fire its hover detection), so pointer-leave preservation is source-guaranteed rather than probe-verified — the manual checklist covers it.
  • CommandPalette empty state unified into Autocomplete.List. Previously a standalone <div> for empty + <Autocomplete.List> only when non-empty → input lost its listbox reference with no matches. Now Autocomplete.List always renders, with the Empty primitive inside. Autocomplete.Empty is not used: filter={null} + mode="none" keeps filteredItems non-empty (the palette's fuzzy filter is external), so Autocomplete.Empty would never trigger.
  • Note: SearchModal's empty state (showEmpty<p> outside List) has the same shape and was not in this review's scope — flagging for awareness; can unify in a follow-up if wanted.

Review fixes (P1 + P2-a + P2-b + P2-c, commit aec3258f):

  • P1 (blocker) — added open to both Autocomplete.Root. Per Base UI docs, inline requires open so the list is treated as visible. Without it, defaultOpen=false → input not data-popup-open, keyboard nav / activedescendant break. Verified via CDP probe: input has data-popup-open + aria-controls + aria-activedescendant; first item data-highlighted; ArrowDown 0→1→2, ArrowUp 2→1.
  • P2-a — added itemToStringValue + onValueChange item-press guard. Object items (result/cmd) now serialize to title/label; onValueChange skips details.reason === 'item-press' so selection never writes the object back into the query. Defensive (inline mode's shouldFillInput is currently false because popupRef.current is null), but correct regardless of future Popup changes.
  • P2-b — contract tests lock open + itemToStringValue + onValueChange item-press filter (catches P1/P2-a regressions), plus the existing inline/mode/autoHighlight/List/Item shape. Keyboard event simulation needs happy-dom (not in maka's test infra — its DOM tests use a hand-rolled FakeElement without an event system), so keyboard nav is covered by the CDP probe + the manual checklist below rather than automated tests.
  • P2-c — Home/End decision: accept Base UI input-cursor behavior. Locked by contract (no jumpActive / onInputKeyDown).

Manual checklist (please confirm by hand, both SearchModal and CommandPalette):

  • Type a query → results appear, first item highlighted.
  • ArrowDown / ArrowUp moves highlight (input keeps focus).
  • Enter on highlighted item activates it once (no double-commit).
  • Escape closes the modal (Escape with a query in SearchModal clears the query first).
  • Home / End — input cursor moves to start/end, highlight does NOT jump.
  • Click an item activates it; query is NOT overwritten with [object Object] or the item label.
  • CommandPalette: grouped sections still render with group labels.
  • Hover an item, move the pointer off the list, press Enter — the hovered item runs, not the first.
  • Type a query with no matches — empty state shows inside the list; input still references a listbox.

Four commits: SearchModal (598b170a), CommandPalette (63e93427), review fixes (aec3258f), keepHighlight + empty-state (81743dce). Each independently revertable.

Checklist

  • Scope matches the PR title and excludes unrelated changes
  • Verification lists commands/results, or explains why they were not run
  • User-facing impact, docs, changelog, migrations, and breaking changes are noted, or marked none
  • Risk, rollback, or review focus is called out for non-trivial changes
  • UI changes include screenshots/video, or explain why not applicable

Astro-Han added 5 commits July 6, 2026 15:28
… PR8)

SearchModal's hand-rolled roving-focus result list (activeResultIndex /
moveActiveResult / jumpActiveResult / keyboardSelectionHandledRef /
handleResultKeyDown / data-active) is replaced by Base UI Autocomplete in
activedescendant mode:

- Autocomplete.Root inline + mode="none" + autoHighlight="always" +
  filter={null}: the list renders inline in the modal body (no floating
  popup), Autocomplete does not re-filter the server-side IPC results, and
  the first result is always highlighted so Enter works without an extra
  ArrowDown.
- Autocomplete.Input renders the input via the shared InputGroupInput
  primitive (render prop); ArrowUp/Down/Enter/Escape keyboard nav is owned
  by Autocomplete (floating-ui useListNavigation).
- Autocomplete.List + Autocomplete.Item replace the hand-rolled
  <ul role=listbox> + <li><button role=option>; item onClick fires
  selectResult for both pointer click and Enter on the highlighted item.
- aria-activedescendant on the input is now managed by Autocomplete.

selectResult navigation (sessionId + turnId, restoreFocus: false), the
debounced IPC search, the inflight ticket guard, the unmount invalidation,
the clear button, the snippet rendering, and all copy/states are unchanged.

The roving-focus kbd-nav interaction (ArrowDown moved focus to the result
button) becomes activedescendant (input keeps focus, active item reflected
via aria-activedescendant). a11y is more standard; the interaction habit
shifts. Home/End now move the input cursor (Base UI ComboboxInput default);
jump-to-first/last result is not bound for now (to be confirmed by manual
testing per PR8 plan).

search-modal-lifecycle-contract: the kbd-nav it-block is rewritten to lock
the Autocomplete shape (Root props + Item onClick + selectResult navigation)
instead of the roving-focus implementation; the focus-policy it-block drops
the activeResultIndex/moveActiveResult assertions; the empty-query it-block
repoints onChange -> onValueChange. CSS .maka-search-modal-result[data-active]
-> [data-highlighted] (Autocomplete item highlighted state).

Verification: typecheck clean, @maka/desktop 2076/2076, @maka/ui 43/43,
sidebar-search-modal-open screenshot AE=2655 (fuzz 5%, RMSE 0.0006) vs main.
CommandPalette's hand-rolled activedescendant result list (highlight
state + onInputKeyDown + reset useEffect + <div role=listbox> +
<Button role=option data-active>) is replaced by Base UI Autocomplete:

- Autocomplete.Root inline + mode="none" + autoHighlight="always" +
  filter={null}: the list renders inline in the modal body, Autocomplete
  does not re-filter the palette's own fuzzy + content-search combined
  list, and the first command is always highlighted so Enter works
  without an extra ArrowDown.
- Autocomplete.Input renders the input via the shared InputGroupInput
  primitive (render prop); ArrowUp/Down/Enter/Escape keyboard nav is
  owned by Autocomplete. aria-controls + aria-activedescendant are
  managed by Autocomplete — no manual wiring.
- Autocomplete.List + Autocomplete.Group + Autocomplete.GroupLabel +
  Autocomplete.Item replace the hand-rolled <div role=listbox> +
  <div group> + <Button role=option>. Autocomplete.Item fires onClick
  for both pointer click and Enter on the highlighted item, so commit()
  covers both paths.
- The CornerDownLeft cursor hint is now CSS-driven
  (.maka-palette-cursor visibility via [data-highlighted]) instead of
  the JS `!cmd.hint \&\& active` conditional, since the hand-rolled
  highlight state is gone.

commit() (commitPendingRef + committedCommandId + await run + finally
close), the fuzzy filter, useThreadSearch content-search, grouped
rendering, and all copy/states are unchanged.

The kbd-nav interaction stays activedescendant (input keeps focus,
active item reflected via aria-activedescendant) — same mode as before,
now owned by Autocomplete. Home/End now move the input cursor (Base UI
ComboboxInput default); jump-to-first/last command is not bound for now
(to be confirmed by manual testing per PR8 plan).

Contracts: command-palette-a11y-copy-contract #1 (listbox) rewritten to
lock the Autocomplete shape; #2 import regex drops Button (no longer
used); #3 CSS data-active -> data-highlighted; #6 commit-gate block
boundary is commit() (onInputKeyDown gone); #7 highlight reset is now
autoHighlight="always" (no hand-rolled state). renderer-utility-primitives
row assert repoints <Button role=option> -> <Autocomplete.Item>. CSS
.maka-palette-item[data-active] -> [data-highlighted] + .maka-palette-cursor.

Verification: typecheck clean, @maka/desktop 2076/2076, @maka/ui 43/43,
command-palette-open screenshot AE=7250 (fuzz 5%, RMSE 0.0017) vs main.
P1 (blocker): both Autocomplete.Root used `inline` without `open`. Per
Base UI docs, `inline` requires `open` so the list is treated as visible:
"Specify open unconditionally in conjunction with this prop so the list is
considered visible: <Autocomplete.Root inline open>". Without `open`,
defaultOpen=false -> the input is not data-popup-open and keyboard nav /
activedescendant do not work. Add `open` to SearchModal + CommandPalette.

P2-a: object items (<Autocomplete.Item value={result/cmd}>) had no
itemToStringValue, and onValueChange did not filter item-press. Add
itemToStringValue (result.title / cmd.label) so item-press never writes
[object Object] into the query, and skip onValueChange when
details.reason === 'item-press'. In inline mode selectionMode='none' + no
Popup means shouldFillInput is currently false (popupRef.current null), so
this is defensive — but correct regardless of future Popup changes.

P2-b: contract tests now lock open + itemToStringValue + onValueChange
item-press-filter on Autocomplete.Root (catches P1/P2-a regressions), plus
the existing inline/mode/autoHighlight/List/Item shape.

Verified via CDP probe (command-palette-open fixture): input has
data-popup-open + aria-controls + aria-activedescendant; first item
data-highlighted (autoHighlight); ArrowDown 0->1->2, ArrowUp 2->1.
typecheck clean, @maka/ui 43/43, @maka/desktop 2076/2076.
command-palette-open AE=48000 (RMSE 0.011) vs main — larger than pre-fix
AE=7250 because open now correctly renders the first-item highlight bg +
input data-popup-open state (the bug state hid these). search-modal-open
AE=2655 (RMSE 0.0006, unchanged).
P2-c decision: accept Base UI ComboboxInput's default — Home/End move the
input cursor, not the highlight. The old roving-focus jumpActiveResult
(SearchModal) / hand-rolled onInputKeyDown highlight jump (CommandPalette)
must not return. Lock via doesNotMatch on jumpActive( / onInputKeyDown.
…eview P2)

P2-1 (keepHighlight): both Roots had autoHighlight="always" but no
keepHighlight. keepHighlight=false (default) sets resetOnPointerLeave=true
(AriaCombobox.js:855), so pointer leave clears activeIndex, then the
autoHighlight="always" effect (line 678-682) re-highlights the first item
-> hover item[2] -> leave -> Enter ran the first item, not the hovered one.
Add keepHighlight so pointer leave preserves the hovered item.

P2-2 (empty-state): CommandPalette rendered a standalone <div> for empty
and <Autocomplete.List> only when non-empty, so the input lost its listbox
reference with no matches. Unify on Autocomplete.List always, with the Empty
primitive inside. Autocomplete.Empty is not used: filter={null} + mode="none"
keeps filteredItems non-empty (the palette's fuzzy filter is external), so
Autocomplete.Empty would never trigger.

Contracts lock keepHighlight on both Roots + empty-state-must-not-use-
standalone-div.

keepHighlight verified by source (resetOnPointerLeave = !keepHighlight) +
Base UI docs. CDP hover-leave probe could not reliably trigger floating-ui
useListNavigation's hover highlight (synthesized pointermove does not fire
its hover detection), so pointer-leave preservation is source-guaranteed,
not probe-verified. typecheck clean, 2076/2076, screenshots unchanged
(command-palette-open AE=48000, search-modal-open AE=2655 vs main — same as
prior commit, keepHighlight/empty-state don't affect non-empty visuals).
@jackwener
jackwener merged commit a59d4f1 into main Jul 6, 2026
@jackwener
jackwener deleted the feat/ui-combobox-converge branch July 6, 2026 12:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

refactor(ui): converge unmanaged design specs (line-height, font-weight, letter-spacing, …)

2 participants