Add <CommandPalette>, Add <Dialog> -- [Breaking]: <Modal> was previously aliased to <Dialog> and is now _just_ <Modal> - #799
Conversation
|
|
|
|
CI's job fails before it reaches this branch's code: Run locally instead: |
|
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 |
c99afc6 to
b500b0b
Compare
a5eb6f5 to
ea38b05
Compare
<CommandPalette>, Add <Dialog> -- [Breaking]: <Modal> was previously aliased to <Dialog> and is now _just_ <Modal>
| registerList = eModifier((element: HTMLElement) => { | ||
| this.#list = element; | ||
|
|
||
| return () => { |
There was a problem hiding this comment.
a destructor here isn't needed. JS garbage collection handles this for us
There was a problem hiding this comment.
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.
9c6f9c7 to
d4e1fda
Compare
01b3272 to
d2eda01
Compare
d2eda01 to
7c60c72
Compare
<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>
7c60c72 to
1265e05
Compare
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.
kolayis the motivating consumer: its newsearcheralready ranks pages, and needs somewhere to put them.Dialogis optional. Leave it out and the sameInputandListrender inline, for a search page of its own.What the platform does, instead of us
<dialog>+showModal()closedby="any"<dialog>aria-activedescendantMutationObserverNo z-index, no portal, no focus-trap library, no
autofocusattribute, and no document-level click listener.showModal()already focuses the first focusable element in the dialog, which is theInput.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 aLinkItemroutes 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.commandfor/command), Baseline since Dec 2025. A declarative trigger would open the dialog behind the component's back, and thetoggleevent on<dialog>that would fix that has thinner support thanclosedbydoes.Triggeris 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, andLinkItemrouting undersetupApplicationTest.The 4
<InViewport />failures on this machine are pre-existing and unrelated (IntersectionObserverunder 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