HabAutocomplete
Choose one of a long list, by typing. The control HabSelect tells you to reach for once its list outgrows a dropdown.
<HabAutocomplete
v-model="type"
label="Type"
:options="types"
placeholder="Search types…"
empty-label="No matching type"
/>The box is HabInput's — same height, same border, same type size — so a form row of mixed controls lines up. The sheet and its rows are the same popover shell HabSelect and HabMenu use.
Which control
| answers | search | |
|---|---|---|
HabSelect | which one | no |
HabAutocomplete | which one | yes |
HabChipInput | which of these — the answer accumulates | yes |
Under roughly a dozen options prefer HabSelect: a sheet with no filter is faster to the answer than one that asks you to type first. Past that, scrolling a list to find a value you already know is slower than typing it.
When the answer is a set rather than one value, it is HabChipInput — every pick stays visible and removable.
Opening, and the chevron
A click anywhere in the box opens the sheet with the whole list, before anything is typed — the same as HabSelect. Arrow-down opens it from the keyboard.
Set chevron for HabSelect's trailing arrow, when the field should read as "pick from a list" before it reads as "search". Pair it with :icon="null" to drop the search glyph, as above, or keep both:
<HabAutocomplete v-model="plan" label="Plan" :options="plans" :icon="null" chevron />Clicking the chevron toggles the sheet and leaves focus in the input, so typing still filters. It is hidden from assistive technology: the input is already the combobox, and a second control would only repeat it.
Options and groups
Pass plain strings where the value and the label are the same, or objects for a separate label, an icon, a right-aligned meta hint and a group:
<HabAutocomplete
v-model="type"
label="Type"
:options="[
{ value: 'string', group: 'Primitives' },
{ value: 'number', group: 'Primitives' },
{ value: 'Types.status', label: 'status', group: 'Project types', meta: 'enum' },
]"
/>A run of adjacent options sharing a group becomes one labelled band, so order by group — a list that alternates repeats the heading. Adjacency rather than collection is deliberate: re-ordering the list you passed would silently override the order you chose, and a list ordered by relevance has every right to repeat a heading.
The chosen row carries the accent tint and a trailing check, and keeps that tint on hover — two tinted rows in one list read as two selections. Its ink is --hab-accent-text, not -on: -on is the accent as text on a neutral surface, and this row is the accent tint.
Filtering, and fetching
Filtering is the component's, matched against the option's label, case-insensitively and on the trimmed term. Set remote when you are fetching per keystroke — the list is then rendered exactly as given, because the server has already filtered it:
<HabAutocomplete v-model="owner" label="Owner" remote :options="results" @search="fetchPeople" />@search fires on every keystroke in both modes.
Picking the only option
<HabAutocomplete v-model="owner" label="Owner" :options="people" select-single />selectSingle picks the option by itself when there is exactly one, under the rules on HabSelect — with one addition here: it counts the options passed in, never what a search has narrowed them to. Typing down to one match does not pick it.
With remote the options passed in are the search result, so it counts only options that arrived while no term was typed. Closing the sheet clears the term but does not fetch again, so until new options arrive the list may still be the last narrowed result — and that is not picked either.
One case the component cannot catch: a response that arrives after the search it answered was cleared. It looks exactly like a fresh unfiltered list, and a one-row stale result is picked. Discard responses to superseded searches in your fetch — a caller that does not also shows the wrong list.
Off by default, because picking emits update:modelValue with no user action; HabSelect's page has the reason in full.
A control fused to the start
The prependAddon slot holds a control that belongs to the field — the kind of value it holds, chosen from a menu. It sits flush at the start of the box, runs its full height and is divided from the value by a line, as on HabInput. It takes the search glyph's place, so the glyph is not drawn while it is filled. Put a HabFieldAddon in it; it follows the field's read-only and disabled state without being told.
There is no appendAddon; that is HabInput's alone. The end of this box is where chevron draws, inside the box's end padding, and no end segment has been needed here.
Code values
mono sets the box's text in the mono face — what is typed, and the chosen value shown when the sheet is closed — for a value that is code rather than words. The options in the sheet stay in the sans, as do the label and help: the prop is about the field's value, and the sheet is the same one every list in the kit draws. It is a prop rather than a class for the reason on HabButton.
States and density
error reddens the box and sets aria-invalid; the help line switches to --hab-error-text. compact matches a compact field at --hab-control-sm.
marker marks the value as provisional — drafted by an AI, carried in from an import — identically to HabInput's and HabChipInput's. It is not a validation state, and a field in error or disabled keeps its own fill: a state you have to act on outranks a note about where the value came from.
Read-only
readonly shows the chosen option to be read: its label in full ink on the raised surface, no border to click into, and no placeholder or chevron. The sheet never opens — not by a click, arrow-down or typing — so nothing is chosen and search never fires; selectSingle does not pick either. The input stays focusable and selectable and is announced as read-only — unlike state="disabled", which says "not now" and is dimmed. See HabReadonly for the difference, and for making a whole region read-only at once.
<HabAutocomplete v-model="plan" label="Plan" :options="plans" readonly />Error and disabled keep their own boxes when read-only.
Focusing it from code
focus() moves focus to the input — the combobox, where clicking the label puts it. With a prependAddon, the addon comes first in the tab order and focus() skips it. It does not open the sheet. A disabled field does not take focus. It takes the browser's FocusOptions. Type the template ref with InstanceType:
<script setup lang="ts">
import { useTemplateRef } from "vue";
import { HabAutocomplete } from "@livereader/habitus-vue";
const field = useTemplateRef<InstanceType<typeof HabAutocomplete>>("field");
// later: field.value?.focus();
</script>open() focuses the input and opens the sheet, as a click in the box does. Reach for it when the field is revealed on demand — an "+ Add…" button that turns into the search — so the click that asked for the list does not have to be repeated to see it. It does nothing while disabled or read-only, as no click would open the sheet then either.
Accessibility
- Wraps reka-ui's Combobox, so filtering, the portalled listbox, roving focus and the
combobox/listboxwiring are the primitive's. - The visible label is a real
<label for>, which also buys click-to-focus. - Two wires are drawn here rather than by reka, both measured with axe and both critical without them: the listbox is named from the field's label (
aria-input-field-name), and the input'saria-controlsis bridged from the mounted sheet's own id. reka binds that attribute to a root-contextcontentIdthat is initialised to""and never filled, while the content mounts under an id it generates for itself — so the attribute ships empty. Worth raising upstream. - The sheet is portalled, so a field near the bottom of the viewport flips rather than clipping.
- Known limitation: with the list open, axe reports
aria-hidden-focusagainst reka's own focus sentinels (data-reka-focus-guard), as it does forHabSelect. It is the primitive's markup, not ours.
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
chevron | boolean | false | Trailing chevron, HabSelect's, for a field that should read as "pick from a list" before it reads as "search". Clicking it toggles the sheet. Ignored when a trigger slot is given — see below. Off by default: the sheet already opens on a click anywhere in the box, so the chevron is a signal, not a second way in. A trigger slot draws its own from the slot's open: the arrow belongs inside the token, which is the caller's markup. |
density | "default" | "compact" | "default" | Shrinks the box to --hab-control-sm, matching a compact HabInput. |
emptyLabel | string | undefined | Shown in the sheet when the term matches nothing. No default: pass it, or fill the empty slot, or an empty sheet renders a blank band. |
help | string | undefined | Helper text below the box. Announced as the control's description, and rendered in --hab-error-text when state is error. |
icon | string | null | undefined | Leading icon in the box as SVG path data. Defaults to the search glyph; pass null to drop it. |
label | string | undefined | Visible label. Associated with the control by for/id. |
marker | boolean | false | Marks the value as provisional — drafted by an AI, carried in from an import, inherited from somewhere else. The box takes a faint accent fill and the help line takes accent ink. Identical in semantics and treatment to HabInput's and HabChipInput's, deliberately: a field of this family should not have to be told twice what "provisional" looks like. NOT a validation state — nothing is wrong with the value, it has simply not been confirmed by a person yet. |
modelValue | string | undefined | The chosen value, for v-model. |
mono | boolean | false | Sets the box's text — what is typed, and the chosen value shown when closed — in the mono face, for a value that is code rather than words. The options in the sheet stay in the sans, as do the label and help. A mono class reaching in from outside would compete with the field's own sans by stylesheet order. |
options | (string | HabAutocompleteOption)[] | [] | What can be picked, or plain strings where value and label are the same. |
placeholder | string | undefined | Shown when nothing is chosen. |
readonly | boolean | undefined | The value is shown to be read, not chosen: the chosen option's label in full ink on the raised surface, no border to click into, no placeholder and no chevron, and the sheet never opens — not by click, keyboard or typing. Still focusable and selectable, and announced as read-only — unlike state="disabled", which says "not now" and is dimmed. Unset, it follows a surrounding HabReadonly; false keeps this field editable inside one. selectSingle does not pick while read-only. |
remote | boolean | false | Set when options are fetched per keystroke: the list is then rendered as given, because the server has already filtered it. |
selectSingle | boolean | false | Pick the option by itself when there is exactly one: with the model empty and the field not disabled, it emits update:modelValue with that option's value — on mount, or whenever the options change to a single one, as they do when they load after mount. Off by default, because it emits with no user action. A call site that acts on the emit at once — one that submits an answer, or writes to the server and broadcasts — would do so just by rendering. Only the call site knows whether a change is that kind, so it opts in. It never replaces a value that is set, even one that is not among the options. Once the model has held a value under that one option, emptying it is taken as deliberate: the option is picked again only after the options have been something else in between. It counts the options passed in, never what a search has narrowed them to, so typing down to one match does not pick it. With remote the options passed in ARE the search result, so it counts only options that arrived while no search term was typed. |
state | "default" | "disabled" | "error" | "default" | Validation and availability state. error also sets aria-invalid. |
Events
| Event | Payload | Description |
|---|---|---|
search | term: string | — |
update:modelValue | value: string | — |
Slots
| Slot | Bindings | Description |
|---|---|---|
empty | any | Replaces emptyLabel when nothing matches — an action, an explanation. |
prependAddon | any | A control fused to the start of the box — usually a HabFieldAddon as a menu trigger, the kind of value the field holds. Flush, full height and divided from the value, as on HabInput. It replaces the search glyph's place at the start, so the glyph is not drawn. |
Exposed
| Member | Type | Description |
|---|---|---|
focus | (options?: FocusOptions) => void | Moves focus to the input — the combobox, which is where clicking the label puts it, rather than to a prependAddon that Tab would reach first. It does not open the sheet. A disabled field does not take focus, as a disabled native input does not. |
open | (options?: FocusOptions) => void | Focuses the input and opens the sheet, as a click in the box does — for a search revealed on demand ("+ Add…" becoming the field), where the click that asked for it should not have to be repeated to see the list. Nothing happens while disabled or read-only, as no click would open it then either. |