Skip to content

Suggestion

The server writes the field and the list. The component filters as the user types, handles the arrow keys, and announces the number of matches to screen readers.

The browser’s own <datalist> does some of the same, but cannot be styled, behaves differently in every browser, and says nothing about how many matches there were.

// main.ts, runs once when the app starts
import { defineLtSuggestion } from "@lo-tek/interfaces/suggestion"
import "@lo-tek/interfaces/tokens.css"
import "@lo-tek/interfaces/suggestion.css"
defineLtSuggestion()
const suggestion = lt.suggestion({ id: "airport", keys: ["BGO", "MMX"], help: true })
<lt-suggestion>
<label class="lt-label" for="airport" id="airport-label">Airport</label>
<input class="lt-input" id="airport" name="airport" type="text" role="combobox"
autocomplete="off" aria-expanded="false" aria-controls="airport-list"
aria-autocomplete="list" aria-describedby="airport-help airport-status">
<ul class="lt-suggestion__list" id="airport-list" role="listbox"
aria-labelledby="airport-label" popover="manual" hidden>
<li class="lt-suggestion__option" id="airport-option-BGO" role="option"
aria-selected="false">Bergen</li>
<li class="lt-suggestion__option" id="airport-option-MMX" role="option"
aria-selected="false">Malmö</li>
</ul>
<p class="lt-suggestion__empty" hidden>No matches</p>
<span class="lt-sr-only" id="airport-status" role="status"></span>
<p class="lt-help-text" id="airport-help">We show the departures from the airport you choose.</p>
</lt-suggestion>

The field is a real <input> with name, so it is included in the submission before the script has run. The component used to create the whole field itself, and then there was neither a label nor an input field for a user waiting for JavaScript. See Markup and behaviour.

When the markup comes from a template without JavaScript, the template writes for on the label and id on the field, as the “Plain HTML” recipe shows, or puts the label around the field. The component sets the roles, gives the list and the options ids, writes aria-controls on the field, and keeps the list closed until the user touches the field. Every <li> in the list becomes an option; a heading in the list needs role="presentation" to be left alone. What the server wrote stays, and what the component filled in is put back if an update removes it. The status element must be in the markup: it is content the server sends, and without it the number of matches is not announced.

The component never writes an id on the field. Datastar and idiomorph recognise an element by its id, and a field with an id the server did not send would be replaced on the next update, along with focus and what the user has typed. The component writes for on the label from the field’s id when the label is not around the field, as <lt-field> does; write it in the template too, since that is what gives the field a name without JavaScript. The options get ids from the key in keys, like the airport code, so an update recognises them. A key that is empty, contains whitespace or appears twice throws, in TypeScript as in Kotlin. If the ids are missing, as in a template without a builder function, the component gives the list and the options ids. If the server sends the whole list again, they are replaced, and that costs nothing: the component wires them again and finds the highlighted option again by its text.

The field always shows the text of the option the user chose. If the form should submit a code, like the airport code, give the options data-value and write a hidden field for the code:

<lt-suggestion>
<label class="lt-label" for="airport">Airport</label>
<input class="lt-input" id="airport" name="airport-text" type="text">
<input class="lt-suggestion__value" type="hidden" name="airport">
<ul class="lt-suggestion__list">
<li data-value="BGO">Bergen</li>
<li data-value="MMX">Malmö</li>
</ul>
<span class="lt-sr-only" role="status"></span>
</lt-suggestion>

The component writes the code in the hidden field when the user chooses, and clears it again when the user changes the text, so the form never submits the code for Bergen with “Malmö” in the field. When the form is reset, the text goes back to what the server sent, and the code follows the text: the code the server sent when the text is the server’s, otherwise the chosen one, otherwise the option with that text, otherwise none. The field gets input and change when the code is written, so data-bind sees it; the events do not bubble, so a data-on:input on <lt-suggestion> that fetches suggestions is not triggered by them. An update from the server that clears the hidden field gets the code back as long as the text is the chosen one. suggestion-select has both: evt.detail.label is the text and evt.detail.value the code.

Without JavaScript the text is submitted with an empty field for the code, and the server looks up the code itself. If the text is ambiguous, like Portland, which has an airport in both Oregon and Maine, the server answers with a page where the user chooses. The code is then in two places, in data-value and in the hidden field, and that is a deliberate choice: HTML has no element that shows one text and submits another value when the user can also type freely. lt.suggestion() gives the hidden field as suggestion.value; you write name yourself.

The component hides an option when its text does not contain what is in the field. If you filter yourself, and your filter is a different one, the two disagree, and then yours should apply. You say so with prefiltered.

<lt-suggestion prefiltered>…</lt-suggestion>

If you filter on exactly the same thing, that the text contains the search term, you need nothing: the two agree, and the component hides nothing you would show. The attribute is for the times you do not agree:

Your filterWhat happens without prefiltered
Search without diacritics, malmo finds MalmöThe component hides Malmö, and the list looks empty
Search on a code that is not shown, BGOThe same
Fuzzy search, zurik finds ZürichThe matches the component does not recognise disappear
Asynchronous searchThe list flickers while the answer is on its way

If you render only the matches, and your filter is narrower than the component’s, like startsWith, it is safe on the other hand: everything you keep also contains the search term, so the component hides none of it. prefiltered is still right for the empty message, as explained below.

This applies as much in React as in Datastar. In React it is easy to miss, because no server is involved:

// Search without diacritics. The component's own filter would hide Malmö again.
const matches = airports.filter((a) => withoutDiacritics(a.name).includes(withoutDiacritics(query)))
const suggestion = lt.suggestion({ id: "airport", keys: matches.map((a) => a.code) })
<lt-suggestion prefiltered>
…
<ul {...suggestion.list}>
{matches.map((a, i) => <li {...suggestion.options[i]} key={a.code}>{a.name}</li>)}
</ul>
</lt-suggestion>

Then build with the keys of the matches, not of the whole list. The number of keys decides whether “No matches” has hidden: it does as long as you have matches.

You do. The empty message is part of what the list shows, and with prefiltered you decide what it shows. lt.suggestion({ id, keys, pending }) writes hidden on it as long as you have matches or are waiting for an answer, so if you render the field again for each search, it is done. You do not need to know that the list is closed: the stylesheet hides the empty message when the list before it has hidden, so it must come after the list. An empty list under an empty field does not open, so the message does not appear before the user has typed something. When you are waiting for an answer, pass pending: true, and the message stays hidden until the answer has arrived. In a template without a builder function you write hidden on it yourself. The component does not touch it, and that is deliberate: it cannot tell “the search found nothing” from “the answer has not arrived yet”, and an asynchronous search therefore said “No matches” in every pause between the keystroke and the answer.

The number of matches is announced no matter who filtered. It is not markup, but a message in [role="status"] for someone who cannot see the screen, and it comes only when the number differs from last time. With prefiltered it comes when you have rendered the list again, not on the keystroke, and the first number is not announced, since it is the starting point. Without prefiltered the component filters itself on every keystroke, when the field gets focus, and when an update changes an open list, and the number is announced then.

The component still handles the arrow keys, the highlight and aria-activedescendant, which neither React nor the server does.

prefiltered is about what is shown. If the server should also decide whether the list is expanded and which option is highlighted, set server-controlled next to it. You rarely need that in React: the component must be allowed to put aria-selected back, since a new render resets it.

The component announces “No matches”, “One match” and {n} matches when nothing else is specified. The text is replaced with attributes on <lt-suggestion>, where {n} stands for the number:

<lt-suggestion count-none="Aucun résultat" count-one="{n} résultat" count-other="{n} résultats">
AttributeApplies to
count-noneZero matches. A separate sentence, not an inflection
count-oneThe one form. Write {n} in the text: in French the form also covers 0, and in Russian also 21
count-otherAll other numbers, and the fallback when a form is missing. If you set any of the attributes, this one must be there, otherwise numbers whose form is missing are announced in English
count-zero, count-two, count-few, count-manyThe forms some languages have in addition, like Polish few and many

Which form a number takes is decided by Intl.PluralRules from the lang of the page. See Translation.

lt.suggestion() writes hidden on the list when it is closed, so the markup is right from the first paint. If the list is written bare, lt-suggestion:not(:defined) .lt-suggestion__list in suggestion.css keeps it hidden until defineLtSuggestion() has run.

See Markup and behaviour.

AttributeTypeEffect
prefilteredbooleanThe app has already filtered the list, and the component leaves it alone. See When someone else filters.
server-controlledbooleanThe server decides whether the list is expanded and which option is highlighted. The component does not put them back after an update from the server, as it does without the attribute.
count-none, count-zero, count-one, count-two, count-few, count-many, count-othertextThe announcement of the number of matches. See Number of matches in other languages.
ClassWhat it is
.lt-suggestion__listThe list itself. hidden when it is closed
.lt-suggestion__optionOne option. The highlighted one has aria-selected="true"
.lt-suggestion__emptyThe text shown when nothing matches
.lt-suggestion__valueThe hidden field that gets the code of the option the user chose
VariableFallback
--lt-suggestion-max-heightcalc(var(--lt-spacing-10) * 4), which is 10rem
--lt-suggestion-option-paddingvar(--lt-spacing-2) var(--lt-spacing-3)
--lt-suggestion-radiusvar(--lt-spacing-1)

The list is in the top layer when it is open, so it sits above everything else without z-index, and a parent with overflow, like the body of a dialog, does not clip it. The component places it right below the field, as wide as the field, and above the field when there is no room below.

.lt-suggestion__list {
--lt-suggestion-max-height: 24rem;
}

The field is a role="combobox" with aria-autocomplete="list", and the list a role="listbox" with role="option" inside. If the server writes them with lt.suggestion(), they are in the HTML before the script runs. If the markup comes without them, the component sets them when it is registered.

  • The list has a name, the label. lt.suggestion() gives the label an id and the list aria-labelledby pointing at it. If the markup comes without them, the component does the same, but if the label is around the field, it gets no id, since an update would then replace the label and the field inside it. The list then gets the label’s text as aria-label. A name the server wrote stays.
  • Every option says whether it is selected, with aria-selected, from the start.
  • While the answer is on its way the list says aria-busy="true", which lt.suggestion({ id, pending: true }) writes, and “No matches” stays hidden.
  • Focus stays in the field. The highlight moves with aria-activedescendant, not by moving focus, so the user can keep typing.
  • The number of matches is announced. Without it, someone who cannot see the screen gets no message that the list narrowed. The component writes the text, and if an update from the server clears the element, the component writes it again.
  • Escape closes the list without clearing the field, and a click outside does the same.
  • An option with aria-disabled="true" is shown, but skipped by the arrow keys and cannot be chosen.
  • A field with readonly or disabled does not open the list.
  • The choice happens on click, which assistive technologies also send when they activate an element.
  • During an IME composition the keys belong to the composition, and Enter does not choose anything.
KeyDoes
Down arrowOpens the list, or moves to the next option
Up arrowOpens the list on the last option, or moves to the previous one
PgUp, PgDnMoves to the first or last option
EnterChooses the highlighted one
EscapeCloses the list

Home and End move the cursor in the text, as in a plain field.