Suggest

.suggest[data-ui="suggest"]

Suggest — a search field with a panel of results hanging under it, where the results are places to go rather than values to pick.

How this differs from what it looks like

Not combobox

A combobox picks a value and puts it in the field: its rows are options, the caret never leaves the input, and the arrow keys move aria-activedescendant rather than focus, because you are still typing. A suggest’s rows are links, and choosing one leaves the page — so the arrow keys move real focus into them and Enter is the link’s own. That also decides the ARIA: no role="listbox" and no role="option", because a listbox may not hold headings, groups or an empty state, and role="option" would take the link semantics away from a row that in fact navigates. Site search is a suggest; a country field is a combobox.

Not command-palette

A command palette is opened from anywhere with a shortcut, floats over the page in a modal dialog, and its rows do something on this page. A suggest belongs to a field that is already on screen, hangs under it, and its rows go somewhere. The two coexist happily: treni puts a suggest in its hero and the same suggest inside a dialog opened from the bar, which is a palette’s shape with a suggest’s contents.

Examples

A search field whose results are places to go

View “A search field whose results are places to go” as

Stations

Only stations with a departures board are listed.

<div class="suggest" data-ui="suggest">
  <search class="search" aria-label="Search stations">
    <label class="sr-only" for="station-q">Search stations</label>
    <svg class="icon search-icon" aria-hidden="true">
      <use href="/sprite.svg#icon-search"></use>
    </svg>
    <input class="input search-input" id="station-q" name="station" type="search"
           placeholder="Search" aria-controls="station-results" />
  </search>
  <div class="suggest-panel" id="station-results" hidden></div>
</div>

The panel is empty in the markup and hidden: what goes in it is the page’s.

One function — suggest(root, { source }), where source(query) returns the rows as HTML — and the component does the opening, the dismissal and the arrow keys. It is deliberately not a popover: a popover is dismissed by the first click outside it, and the first click outside it is usually the one putting the caret back in the field. The preview above shows the panel open and in flow so you can see it; on a page the default panel is absolute, so typing does not push the rest of the page down.

Inside a dialog

View “Inside a dialog” as
<div class="suggest suggest-flow" data-ui="suggest">
  <search class="search input-lg" aria-label="Search the site">
    <label class="sr-only" for="site-q">Search the site</label>
    <svg class="icon search-icon" aria-hidden="true">
      <use href="/sprite.svg#icon-search"></use>
    </svg>
    <input class="input input-lg search-input" id="site-q" name="q" type="search"
           aria-controls="site-results" />
  </search>
  <div class="suggest-panel" id="site-results" hidden></div>
</div>

.suggest-flow puts the panel back in the flow and drops its shadow.

Inside a dialog there is nothing underneath to keep still, and an absolute panel would hang outside the dialog’s own box. Escape then does the right thing twice: the first one closes the panel and puts the caret back, and only once the panel is closed does the key reach the dialog.

Tokens 7

Level 2, declared on .suggest itself. Set any of them on that selector to restyle this component without touching the skin.

Level 2 tokens declared by suggest
TokenDefault
--suggest-bgvar(--color-surface-raised)
--suggest-border-colorvar(--color-border)
--suggest-gapvar(--space-2)
--suggest-max-block-sizemin(60vb, 26rem)
--suggest-radiusvar(--radius-box)
--suggest-shadowvar(--elevation-overlay)
--suggest-z-index10

Variants and states

Variants

  • .suggest-flow

Inside it

  • .suggest-panel
  • .suggest-note
  • .suggest-heading

State it reads

Read from the platform, never mirrored into a class that could disagree with it.

  • :focus

Before you ship it

This one needs a script. Opt the markup in with data-ui="suggest" and import import 'mostlycss/js/components/suggest' — that specifier and not the package root, which is a bundle carrying a second copy of the registry, so a page holding both initialises every element twice. What you lose without the script is listed below — for some components that is only polish, for others it is the whole interaction.

What you have to do 5 requirements

  1. Give the field a <label>, as with any .search. A placeholder is not a label.
  2. Give the panel an id and point the field's aria-controls at it. Nothing else associates the two, and nothing renders differently without it.
  3. Ship suggest.ts with this markup. Without it the panel is a hidden empty div that nothing ever opens: the field looks like a search field and does nothing at all.
  4. Return rows that are focusable<a href> or <button>. The arrow keys move focus, so a row that is a plain <div> cannot be reached with the keyboard and cannot be activated with Enter.
  5. Say what the panel leaves out, inside the panel. .suggest-note is the line for it. A limit is declared where the reader trips over it, and the only place these results are read is here.

src/css/components/suggest.css · npx mostlycss add suggest