Skip to content

Add <CommandPalette> - #799

Open
NullVoxPopuli-ai-agent wants to merge 3 commits into
universal-ember:mainfrom
NullVoxPopuli-ai-agent:nvp/command-palette
Open

Add <CommandPalette>#799
NullVoxPopuli-ai-agent wants to merge 3 commits into
universal-ember:mainfrom
NullVoxPopuli-ai-agent:nvp/command-palette

Conversation

@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor

The K pattern, as a primitive: a combobox (the input) wired to a listbox (the results), optionally in a modal <dialog>.

It owns the input, the keyboard, the aria wiring, and the open state. It does not filter. The caller renders the results, in the order the caller wants, from wherever they come from — an array, a fetch, a ranked index. kolay is the motivating consumer: its new searcher already ranks pages, and needs somewhere to put them.

<CommandPalette @onSelect={{this.go}} @hotkey="mod+k" as |p|>
  <p.Trigger>Search</p.Trigger>

  <p.Dialog>
    <p.Input aria-label="Search" />
    <p.List as |l|>
      {{#each (this.filter p.query) as |r|}}
        <l.LinkItem @href={{r.path}}>{{r.title}}</l.LinkItem>
      {{/each}}
    </p.List>
  </p.Dialog>
</CommandPalette>

Dialog is optional. Leave it out and the same Input and List render inline, for a search page of its own.

What the platform does, instead of us

behavior mechanism
top layer, focus trap, focus restore, focusing the input <dialog> + showModal()
click outside to close closedby="any"
Esc native <dialog>
keyboard nav that does not move focus aria-activedescendant
the caller's results changing MutationObserver

No z-index, no portal, no focus-trap library, no autofocus attribute, and no document-level click listener. showModal() already focuses the first focusable element in the dialog, which is the Input.

There is no tabster mover here, deliberately: focus has to stay in the <input> or the user stops typing.

Enter dispatches a real click() on the active option, so one handler covers the mouse and the keyboard, and a LinkItem routes exactly as it would have for a mouse — -click included, which is also the one click that does not close the palette.

Two calls worth a second opinion

  • closedby="any" is not yet Baseline. Where it is missing, the palette degrades to Esc-only. I did not add a JS fallback — that would put back the document click listener this is meant to delete. It is set before ...attributes, so callers can override it.
  • No Invoker Commands (commandfor / command), Baseline since Dec 2025. A declarative trigger would open the dialog behind the component's back, and the toggle event on <dialog> that would fix that has thinner support than closedby does. Trigger is a plain button for now.

Tests

11 new tests in test-app, all passing: aria wiring, arrow keys, wrapping, Enter, click, pointer activation, disabled items, a changing result list, the dialog lifecycle, the hotkey, the controlled query, and LinkItem routing under setupApplicationTest.

The 4 <InViewport /> failures on this machine are pre-existing and unrelated (IntersectionObserver under headless Chrome).

Docs

New page under 5-floaty-bits, with a live demo, the async-results shape, and the keyboard table.

🤖 Generated with Claude Code

@bolt-new-by-stackblitz

Copy link
Copy Markdown

Review PR in StackBlitz Codeflow Run & review this pull request in StackBlitz Codeflow.

@github-actions

github-actions Bot commented Aug 16, 2026

Copy link
Copy Markdown
Contributor
Project Preview URL
Docs https://a5cce27f.ember-primitives.pages.dev

Logs

@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor Author

CI's job fails before it reaches this branch's code: spawn ember-tsc EACCES while building packages/docs-support. It is pre-existing — every PR run since at least Aug 9 fails there identically, including renovate lockfile-only PRs (example). Nothing here touches packages/docs-support.

Run locally instead: ember-primitives lint:types + lint:js clean, and test-app test:ember green except the 4 pre-existing <InViewport /> failures (IntersectionObserver under headless Chrome).

@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor Author

The CI failure I noted above is fixed in #800 (a turbo/pnpm bin race, unrelated to this branch). All 12 checks pass there, so once it lands this branch can be rebased and will get a real CI run.

One correction to my earlier note: the four <InViewport /> failures are local to my machine, not pre-existing on CI — Default Tests passes on the runner in #800.

The ⌘K pattern, as a primitive. It owns the input, the keyboard, the
aria wiring, and the open state. It does not filter -- the caller
renders the results, in the order the caller wants, from wherever they
come from.

The platform does the work that a command palette usually reimplements:

- <dialog> + showModal() for the top layer, the focus trap, focus
  restore, and focusing the input. No z-index, no portal, no focus-trap
  library, no autofocus attribute.
- closedby="any" for click-outside, so there is no document-level click
  listener. Where it is unsupported, the palette degrades to Escape.
- aria-activedescendant for the keyboard, so focus never leaves the
  input and the user can keep typing. This is why there is no tabster
  mover here.
- A MutationObserver to notice the caller's results changing, so the
  best result is active whenever the list is new, and the user's choice
  survives a re-render.

Enter dispatches a real click on the active option, so one handler
covers the mouse and the keyboard, and a LinkItem navigates through the
router exactly as it would have for a mouse.

Dialog is optional: leave it out and the same Input and List render
inline, for a search page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
NullVoxPopuli and others added 2 commits August 16, 2026 15:32
`querySelectorAll` returns a static, indexable, countable list. Copying
it into an array bought nothing and allocated on every read -- and these
reads sit in a keydown handler and a MutationObserver callback, so they
run constantly.

Indexing and length come straight off the NodeList now, and the two
callers that wanted array methods (`findIndex`, `map().join()`) are
plain loops.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The palette rendered and drove its own <dialog>: element ref, open
state, the `close` event, `showModal`. <Dialog> already does all of it,
so it does it now, and the palette keeps only what is specific to a
palette.

<Dialog> hands its API to a block, so the two callers that are not
templates -- the global hotkey and closing on select -- take it out of
template scope with a modifier that captures `open`, `close`, and the
element. Those handles stay undefined when no `Dialog` is rendered,
which is what keeps the inline palette working: opening and closing
become no-ops rather than assertions.

This is not smaller: +76/-71. A wrapper component's Glint signature and
the capture modifier cost about what the `<dialog>` handling did. What
it buys is one implementation of the open/close semantics instead of
two, so <Dialog>'s behaviour is the palette's behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

2 participants