Skip to content

Add <CommandPalette>, Add <Dialog> -- [Breaking]: <Modal> was previously aliased to <Dialog> and is now _just_ <Modal> - #799

Merged
NullVoxPopuli merged 1 commit into
universal-ember:mainfrom
NullVoxPopuli-ai-agent:nvp/command-palette
Aug 19, 2026
Merged

Add <CommandPalette>, Add <Dialog> -- [Breaking]: <Modal> was previously aliased to <Dialog> and is now _just_ <Modal>#799
NullVoxPopuli merged 1 commit 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://b18d872a.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.

Comment thread ember-primitives/src/components/command-palette.gts
Comment thread docs-app/app/templates/5-floaty-bits/command-palette.gjs.md
Comment thread docs-app/app/templates/5-floaty-bits/command-palette.gjs.md Outdated
Comment thread docs-app/app/templates/5-floaty-bits/command-palette.gjs.md
Comment thread docs-app/app/templates/5-floaty-bits/command-palette.gjs.md Outdated
Comment thread ember-primitives/src/components/command-palette.gts Outdated
Comment thread ember-primitives/src/components/command-palette.gts Outdated
Comment thread ember-primitives/src/components/command-palette.gts Outdated
Comment thread ember-primitives/src/components/command-palette.gts Outdated
Comment thread ember-primitives/src/components/command-palette.gts Outdated
Comment thread ember-primitives/src/components/command-palette.gts Outdated
Comment thread ember-primitives/src/components/command-palette.gts Outdated
Comment thread ember-primitives/src/components/command-palette.gts Outdated
Comment thread test-app/tests/command-palette/command-palette-test.gts Outdated
Comment thread docs-app/app/templates/5-floaty-bits/command-palette.gjs.md Outdated
Comment thread ember-primitives/src/components/dialog.gts Outdated
Comment thread ember-primitives/src/components/dialog.gts Outdated
Comment thread ember-primitives/src/components/dialog.gts Outdated
Comment thread docs-app/app/templates/5-floaty-bits/command-palette.gjs.md Outdated
Comment thread docs-app/app/templates/5-floaty-bits/command-palette.gjs.md Outdated
Comment thread docs-app/app/templates/5-floaty-bits/command-palette.gjs.md Outdated
Comment thread ember-primitives/src/components/command-palette.gts Outdated
Comment thread docs-app/app/templates/5-floaty-bits/dialog.gjs.md Outdated
Comment thread docs-app/app/templates/5-floaty-bits/dialog.gjs.md Outdated
@NullVoxPopuli NullVoxPopuli added the enhancement New feature or request label Aug 19, 2026
@NullVoxPopuli NullVoxPopuli changed the title Add <CommandPalette> Add <CommandPalette>, Add <Dialog> -- [Breaking]: <Modal> was previously aliased to <Dialog> and is now _just_ <Modal> Aug 19, 2026
registerList = eModifier((element: HTMLElement) => {
this.#list = element;

return () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a destructor here isn't needed. JS garbage collection handles this for us

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That one is gone -- it was the modifier's teardown clearing the element reference, and you are right that nothing needed it.

The other registerDestructor in this file stays, and I want to flag it rather than quietly keep it. It removes a document keydown listener for @hotkey. GC cannot collect that one: document holds a strong reference to the handler, the handler holds the component, and the component outlives its own teardown for as long as the page does.

Tested it rather than argued it. With the destructor removed:

not ok - the hotkey listener goes when the palette does
  actual:   1,1
  expected: 1

A palette that has been torn down still answers ⌘K, and it stacks: every render leaves another live listener. The test is in the branch, so if the destructor is ever dropped it fails rather than leaking quietly.

Comment thread ember-primitives/src/components/command-palette.gts Outdated
Comment thread ember-primitives/src/components/command-palette.gts Outdated
Comment thread ember-primitives/src/components/command-palette.gts
Comment thread ember-primitives/src/components/command-palette.gts Outdated
Comment thread ember-primitives/src/components/command-palette.gts
@NullVoxPopuli-ai-agent
NullVoxPopuli-ai-agent force-pushed the nvp/command-palette branch 2 times, most recently from 9c6f9c7 to d4e1fda Compare August 19, 2026 20:42
Comment thread ember-primitives/src/components/command-palette.gts Outdated
@NullVoxPopuli-ai-agent
NullVoxPopuli-ai-agent force-pushed the nvp/command-palette branch 2 times, most recently from 01b3272 to d2eda01 Compare August 19, 2026 21:06
Comment thread docs-app/app/templates/5-floaty-bits/command-palette.gjs.md Outdated
Comment thread docs-app/app/templates/5-floaty-bits/command-palette.gjs.md Outdated
Comment thread docs-app/app/templates/5-floaty-bits/command-palette.gjs.md Outdated
Comment thread docs-app/app/templates/5-floaty-bits/command-palette.gjs.md
Comment thread docs-app/app/templates/5-floaty-bits/command-palette.gjs.md Outdated
Comment thread ember-primitives/src/components/command-palette.gts Outdated
Comment thread ember-primitives/src/components/command-palette.gts
Comment thread ember-primitives/src/components/command-palette.gts Outdated
Comment thread ember-primitives/src/components/command-palette.gts Outdated
<CommandPalette> is a combobox over a listbox: the input, the keyboard,
and the aria wiring. It does not filter, and it does not open or close
anything.

    <Dialog as |d|>
      <CommandPalette
        @Items={{this.commands}}
        @onselect={{d.close}}
        @onopen={{d.open}}
        @Hotkey="mod+k"
      />
    </Dialog>

Two forms, and the signature is a union so each describes only the
arguments it uses: `@items` renders the rows for you, or a block renders
them yourself. Passing both is a dev error.

One way in for selection. `@onSelect` runs whenever a row is chosen, and
is handed the entry when there is one, so it is both where a row's action
goes and what closes a modal palette. A block form's rows carry their own
action as a click handler, which <kbd>Enter</kbd> reaches too, since
choosing a row dispatches a real click.

Focus never leaves the input; `aria-activedescendant` is what moves, so
the reader can keep typing, and the pointer sets that same state rather
than painting a `:hover` of its own -- two highlights and one truth would
disagree about what <kbd>Enter</kbd> does. Tabster does the finding,
which the app sets up the same way <Menu> requires.

Nothing is registered that has to settle in a second render pass. The
listbox is held by a modifier so it works inside a shadow root, and the
active row is looked up within it.

<Dialog> is the other half: a modal <dialog> around its block, yielding
`open` and `close` already wired to that element. Everything inside is
hidden until it opens and a trigger cannot sit outside it, so it is for
content that opens itself. <Modal> stays for a dialog opened by a button
beside it, and the palette composes with either.

Both have docs pages with running demos -- a modal palette, one inline,
one rendering its own rows, and one searching the Star Wars API for real
loading state -- and 21 tests between them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@NullVoxPopuli
NullVoxPopuli merged commit ef746f4 into universal-ember:main Aug 19, 2026
33 of 34 checks passed
@NullVoxPopuli
NullVoxPopuli deleted the nvp/command-palette branch August 19, 2026 23:26
@github-actions github-actions Bot mentioned this pull request Aug 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants