Add <CommandPalette> - #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 |
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>
c99afc6 to
b500b0b
Compare
`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>
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