Skip to content

Add <Search />: site-wide search as a command palette - #377

Merged
NullVoxPopuli merged 8 commits into
universal-ember:mainfrom
NullVoxPopuli-ai-agent:nvp/search-palette
Aug 27, 2026
Merged

NullVoxPopuli merged 8 commits into
universal-ember:mainfrom
NullVoxPopuli-ai-agent:nvp/search-palette

Conversation

@NullVoxPopuli-ai-agent

@NullVoxPopuli-ai-agent NullVoxPopuli-ai-agent commented Aug 16, 2026 •

Copy link
Copy Markdown
Contributor

The ⌘K for a kolay site. Built on <CommandPalette>, which shipped in ember-primitives 0.62.0.

import { Search } from 'kolay/components';

<template>
  <Search />
</template>

Nothing to index and nothing to configure. The docs() plugin already writes every page's title, headings, and prose into the compiled docs, so this is searcher rendered, the utility that shipped in #372 and until now had only the search page to be used by. Same for stripFormatting and highlightSearch, which produce and mark the excerpts.

What it is

<CommandPalette> filled in with kolay's index, inside a <Modal> so the trigger button can sit outside the dialog:

  • the <dialog> handles the layer, the focus trap, and returning focus to whatever opened it
  • aria-activedescendant handles the keyboard, so ↑/↓ move the selection while the caret stays where the reader is typing
  • Enter clicks the active result, which is a real link, so the router navigates and ⌘-click still opens a new tab
  • Esc, and a click outside, close it

Async throughout: a plain .md page's text is fetched on demand, and the palette re-activates the best result whenever the list changes underneath.

Arguments and blocks

@hotkey (default "mod+k", "" to disable), @minLength (3), @limit (20), @placeholder.

Both blocks are optional and replace a default. :trigger is yielded open and the modifier that returns focus to the trigger when the palette closes. :result is yielded the result and the query.

Styling

Default styles ship in kolay.css, which consumers already import. Colours are --kolay-search-* variables, so a host site sets values rather than restating layout. The docs app maps them to pico's in six lines.

The active result is [data-active="true"], set by the pointer and the keyboard alike. There is no :hover rule, deliberately: one selector for both inputs means they can never disagree about what Enter will do.

The palette's stylesheet also answers frameworks that style a bare dialog as a full-screen box around an <article> card. Pico is one. min-width: 100% outranks any width the palette sets, and centring the children squashes the input and the results.

Two fixes this needed

The addon typecheck. {{on}} did not typecheck anywhere in src/, which is why there was no way to write a trigger button by hand. Two tsconfig settings disagreed with what Glint needs: moduleResolution: node16 stopped Glint's module augmentations from attaching, so on stayed the bare Opaque<"modifier:on"> that ember-source declares; and types named @glint/ember-tsc, the tool's own API, rather than @glint/ember-tsc/types, the file carrying those augmentations. types also read ember-source out of docs-app/node_modules and now reads the addon's own copy. With the augmentations attached, one real error surfaced in typedoc/signature/component.gts, fixed in the same commit.

stripFormatting and inline HTML. An excerpt rendered press <kbd>K</kbd> verbatim. It drops inline HTML now, keeping the text it wrapped. Lowercase tag names only, so <Search /> written in prose survives.

The docs site

The header's search form is replaced by the palette, which deletes ~75 lines of form and scoped CSS. The /search page stays: the palette is for looking something up and leaving, the page is for a search worth linking to.

Verified

Suites: markdown-only 14/14, docs-app 97 (93 pass, 4 pre-existing skips), test:node 250/250, pnpm lint 21/21 including lint:published-types across node10, node16, and bundler.

Driven in a real browser on the docs app, light and dark: the trigger opens the dialog, the input is a focused combobox, typing gives ranked results with role="option" and highlighted query marks, ↓ moves aria-activedescendant and [data-active] together, and Enter navigates and closes the dialog.

🤖 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.

@vercel

vercel Bot commented Aug 16, 2026

Copy link
Copy Markdown

@NullVoxPopuli is attempting to deploy a commit to the universal-ember Team on Vercel.

A member of the Team first needs to authorize it.

NullVoxPopuli and others added 2 commits August 19, 2026 21:12
`{{on}}` did not typecheck anywhere in `src/`, because two tsconfig settings
disagreed with what Glint needs:

- `moduleResolution: node16` stopped Glint's module augmentations from
  attaching, so `on` stayed the bare `Opaque<"modifier:on">` that
  ember-source declares and no `resolve` overload accepted it.
- `types` named `@glint/ember-tsc`, which is the tool's own API, rather than
  `@glint/ember-tsc/types`, which is the file that carries those
  augmentations.

`types` also read ember-source out of `docs-app/node_modules`. It reads the
addon's own copy now.

With the augmentations attached, a real error appears: `getSignature` returns
`undefined` when a declaration carries no signature, and the union check does
not narrow that away.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`<Search />` is `<CommandPalette>` from ember-primitives, filled in with what
`searcher` ranks. It renders a trigger button and a modal `<dialog>`, and it
needs nothing configured: the `docs()` plugin already wrote every page's
title, headings, and prose into the compiled docs.

The `:trigger` block replaces the button, and is yielded `open` and the
modifier that returns focus to it. The `:result` block replaces the group,
title, and excerpt.

`stripFormatting` now drops inline HTML, keeping the text it wrapped, so an
excerpt reads "press K" rather than "press <kbd>K</kbd>". Lowercase tag names
only, so a component written in prose survives.

The palette's own stylesheet answers frameworks that style a bare `dialog` as
a full-screen box around an `<article>`: `min-width: 100%` outranks any width
the palette sets, and centring the children squashes it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@NullVoxPopuli-ai-agent
NullVoxPopuli-ai-agent marked this pull request as ready for review August 20, 2026 01:13
Comment thread src/browser/components/search.gts Outdated
@vercel

vercel Bot commented Aug 26, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
kolay-docs-app Ready Ready Preview Aug 27, 2026 4:09pm

The trigger replaced an `<input>` and did not match its size. Its height came
from the key hints, which frameworks draw as a filled block in the body's own
colour: right for a keystroke named in prose, and far too loud for a hint on a
button. It ran 51px against the input's 42px and read as the loudest thing in
the header.

The keys are drawn as keys now, quiet and outlined, and sit at the far end of
the button with the label at the near end. The palette's own input also takes
`height: auto`, since a framework's fixed input height was making the field
98px tall for one line of text.

The docs app gets back the two width breakpoints the old header search had.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three things were wrong inside the modal.

The site's `header div:first-child` rule reached into the `<dialog>`, because
`<Modal>` renders it where it is written. It laid the field out on the
baseline with a 1rem gap, so the icon sat above the input's centre. That rule
only ever meant the header's own nav group, so it takes a child combinator
now, and the field's rule is qualified by the dialog so no host descendant
rule can lay it out again.

The input carried the site's focus ring, which the dialog clipped: the field
runs the full width, out to a rounded corner and `overflow: hidden`. Focus
never leaves that input while the palette is open, so the ring was permanent
decoration rather than a signal. It is suppressed, specifically enough to
outrank a framework's own input focus styles.

The icon, each result, and the status line now share one left edge, held in
a single custom property rather than restated four times.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The docstring said there was nothing to configure while the signature
exposed four knobs, each behind its own `?? DEFAULT` getter. The hotkey is
fixed at mod+k, and the rest are constants: how much to type before the index
is consulted, how many results to render, and the placeholder.

The two blocks stay. They replace markup rather than tune behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread src/browser/components/search.gts Outdated
</button>
{{/if}}

<m.Dialog class='kolay__search'>

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.

we need to explain esc to close, like the nvp.ui style. and we need search cancel buttons

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.

Both done.

The footer carries what the listbox is doing on one side and press [Esc] to close on the other, using <Key> the way nvp.ui's menu does.

A clear button appears beside the input once there is something to clear, and hands the caret back to the input so the reader can carry on typing.

Verified with real key events: Esc closes the dialog and focus returns to the trigger, so the hint is accurate. Two tests added in markdown-only (16 pass).

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.

Both fixed.

Closing with a mouse. You were right, there was no way. The footer hint is a button now: it reads Close beside the Esc chip, so it still names the key while being a target. The dialog also carries closedby="any", so a click outside dismisses it. Checked with real mouse events at the CDP level, not synthetic ones: both paths close it and return focus to the trigger.

Height. A modal <dialog> is anchored top and bottom, so an auto height fills the viewport instead of the content, then clamps at max-height. That is why it sat at 665px with an empty 549px results box inside it. Releasing the bottom anchor makes it content-sized. Measured: empty 122px, no matches 124px, three results 518px. The results box collapses to 0 rather than holding its padding open.

One thing this turned up: frameworks give buttons a bottom margin, and the close button had picked up 20px of it. Zeroed on all three buttons.

Two tests added, markdown-only is 17 pass.

The footer carries both: what the listbox is doing on one side, and "press
Esc to close" on the other, the way nvp.ui's menu says it.

Clearing the query was a keyboard-only move. A button appears beside the
input once there is something to clear, and hands the caret back so the
reader can carry on typing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was no way to close it without a keyboard. The footer's hint is a
button now, reading "Close" beside the key it names, and the dialog carries
`closedby="any"`, so a click outside dismisses it the way the platform does.

The palette was 665px tall with nothing in it. A modal `<dialog>` is anchored
top and bottom, so an auto height fills the viewport rather than the content;
releasing the bottom anchor lets it be as tall as its results and no taller.
The empty state is 122px now. The results box collapses with it, rather than
holding a strip of padding open.

Frameworks give buttons a bottom margin. None of these want one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@NullVoxPopuli
NullVoxPopuli merged commit 6bbee8f into universal-ember:main Aug 27, 2026
6 checks passed
@NullVoxPopuli
NullVoxPopuli deleted the nvp/search-palette branch August 27, 2026 16:14
@NullVoxPopuli NullVoxPopuli added the enhancement New feature or request label Aug 27, 2026

This branch was successfully deployed

1 active deployment
Preview — fbec15d6 Deployed Aug 27, 2026 by vercel[bot]
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