Skip to content

Latest commit

 

History

History
154 lines (131 loc) · 11.3 KB

File metadata and controls

154 lines (131 loc) · 11.3 KB

Typeahead

Filterable select: the closed state is a trigger button; clicking it swaps in a search input with a match-highlighted option list. Arrow keys navigate, Enter selects, Escape closes. Extracted from agentsview's OptionTypeahead; extended with Forge's TypeaheadTrigger features (clear row, custom values, veto, meta text) and grouped options.

<script lang="ts">
  import { Typeahead, type TypeaheadOption } from "@kenn-io/kit-ui";

  let repo = $state("");
  const repos: TypeaheadOption[] = [
    { name: "kenn-io/forge", label: "kenn-io/forge", displayLabel: "Forge" },
    { name: "kenn-io/agentsview", label: "kenn-io/agentsview", displayLabel: "agentsview" },
  ];
</script>

<Typeahead
  options={repos}
  value={repo}
  fallbackLabel="All repositories"
  placeholder="Filter repositories…"
  onselect={(v) => {
    repo = v;
  }}
/>

Props

Prop Type Default Notes
options TypeaheadOption[] required
value string required Matches option.name; unmatched shows fallbackLabel
fallbackLabel string required Trigger text when nothing is selected
placeholder string required Search input placeholder + aria-label; the closed trigger is named "<placeholder>: <selected label>"
inputAttributes TypeaheadInputAttributes {} Native attributes for the open search input; excludes Typeahead-owned behavior and ARIA attributes
onselect (name: string) => void | boolean | Promise<void | boolean> required Return false (or throw) to veto: the list stays open
title string — Trigger tooltip
emptyLabel string "No matches"
disabled boolean false
allowClear boolean false Prepends a row that selects ""
clearLabel string "None" Label of the clear row
allowCustom boolean false Offers a trimmed non-empty query that is not an exact option name, including when partial matches exist
customLabel string Use "{query}" Label of the custom-value row; {query} is replaced with the trimmed query
placement "auto" | "top" | "bottom" "auto" Force the list above/below; auto flips near the viewport bottom
triggerPrefix string — Dim text before the value on the closed trigger
loading boolean false Replaces option rows with loadingLabel (async sources)
loadingLabel string "Loading…"
remote boolean false Disables local option filtering for caller-supplied remote results
onquery (query: string) => void — Reports input changes and "" when the picker opens or closes
error string — Error row above the options, which stay selectable (clear it in onselect)
icon Snippet<[TypeaheadOption]> — Optional decorative icon before each option and the selected trigger label. Omitted for unmatched, clear, and custom values.
header Snippet — Rendered inside the popover above the options (e.g. a Branches/Tags tab switcher)

Option shape

interface TypeaheadOption {
  name: string; // stable value; must be unique across the whole tree
  label: string; // shown + searched in the list
  displayLabel?: string; // shorter text for the closed trigger
  count?: number;
  meta?: string; // secondary text: searched, rendered dim at the row's end
  children?: TypeaheadOption[]; // makes this a non-selectable expand/collapse group
  expanded?: boolean; // initial expansion for a group (default true)
}

Grouped options

Give an option children to render it as a group row. Groups expand and collapse instead of selecting: Enter and click toggle them, ArrowRight expands, ArrowLeft collapses (or, on a leaf, jumps to the parent row). While filtering, groups are forced open and shown only when they or a descendant match; a group whose own label matches keeps all its descendants. When any option has children the list uses role="tree" semantics (treeitem/aria-expanded/aria-level) instead of a flat listbox. The input advertises the matching popup type through aria-haspopup, and visible tree rows expose their position and sibling count through aria-posinset and aria-setsize. Filtering recalculates that metadata for the visible result tree while retaining the path to a deep match.

Password managers

Password managers can mistake the search input for a login field and open a credential menu over the list. Browsers treat autocomplete="off" as a hint only, so the input also carries each manager's opt-out attribute: data-1p-ignore (1Password), data-bwignore (Bitwarden), data-lpignore (LastPass), and data-form-type="other" (Dashlane). Typeahead owns these attributes; inputAttributes cannot turn them off.

Remote option sources

Set remote when the caller fetches and replaces options from an external source. Local filtering is disabled, while onquery reports each input change so the caller can debounce requests and reject stale responses. The callback also receives "" whenever the picker opens or closes, allowing caller-owned results, loading state, and errors to reset consistently.

Veto and error rows

onselect may return false (or a promise of false), or throw, to keep the list open — use this to reject a value and surface a message through the error prop without losing the user's query. The error renders as a role="alert" row above the options, which stay visible and selectable — the user's retry is what lets you clear the error. error is caller-owned: the component never sets or clears it, so clear it yourself on the next attempt (the demo resets it at the top of onselect) to avoid a menu stuck on a stale error row. loading covers async option sources (e.g. refetching after a header tab switch).

While a loading status row is showing it stands in for the options: arrow keys and Enter are inert (Escape still closes), and aria-activedescendant is dropped so nothing hidden is announced as active.

Escape closes the list from any focused descendant — the input or a focusable header control — consuming the event so a parent overlay does not also close (a header control that already handled Escape, e.g. a SearchInput clearing its text, wins). Keyboard-driven closes (Escape, selection) return focus to the trigger; closes caused by focus leaving the component do not steal focus back.

Enter always commits exactly the row aria-activedescendant names — the clear row clears even while filtering, and a custom value (allowCustom) appears as a real "Use "query"" row whenever the query is not an exact option name, including alongside partial matches, instead of being hidden Enter behavior. Screen-reader users are therefore never told one row is active while Enter selects another. Concurrent async onselect calls are ordered: only the latest attempt may close the list, so a slow earlier selection can't dismiss a newer veto/error.

CSS knobs

--typeahead-min-width (180px preferred, capped by the available container width), --typeahead-max-width (300px), --typeahead-panel-min-width (0, the list is only as wide as the trigger), --typeahead-control-height (26px), --typeahead-control-padding (0 8px), --typeahead-control-font-size (var(--font-size-xs)), --typeahead-icon-width (16px, reserved whenever icon is supplied, including empty slots).

Positioning

The option list is position: fixed via floatingPopoverStyle (shared popover contract): it escapes overflow-hidden ancestors, repositions on scroll/resize/filter changes, and flips above the trigger near the viewport bottom (override with placement). Its width pins to the trigger width so long labels truncate instead of widening the menu. A compact trigger over long labels (a time zone picker) can set --typeahead-panel-min-width on the component; the list then opens at least that wide, positioned with its real width so it slides left to stay inside the viewport rather than hanging past the right edge. The list is never wider than the viewport minus the edge gaps.