HabChipInput
Search a list, add what you pick, and the picks stay in the box as removable chips.
<HabChipInput
v-model="picks"
:options="options"
label="Reviewers"
placeholder="Search people"
:max="4"
/>The control for tags, members, linked objects, recipients — anything where the answer accumulates.
Against HabSelect
HabSelect answers "which one", and the answer replaces the last. This answers "which of these", where every answer accumulates and has to stay visible and removable.
Past roughly eight picks, a list with an Add button reads better than a box of chips.
Why Combobox and not TagsInput
reka ships both, and this contract needs something from each: a filtered menu with keyboard navigation, and chips that Backspace removes. They cannot simply be nested — each wants to own the input element and the value.
Combobox is the base because its half is the hard half: filtering, the portalled listbox, roving focus through the options, and the full combobox/listbox ARIA wiring. What TagsInput would have contributed is chips, which are markup, and "Backspace on an empty field removes the last chip", which is four lines.
The box grows
Downward, as chips wrap. This is the design system's one sanctioned exception to the 34px control grid, because the alternative is hiding what the user has already chosen.
It reuses HabInput's .hab-input / .hab-input-box class hooks rather than restating the focus indicator, so this field focuses like every other one. That has a structural requirement worth knowing: base.css uses .hab-input:focus-within > .hab-input-box, a direct-child selector — and ComboboxRoot renders a wrapper of its own, so .hab-input sits on that wrapper. Put anything between the two and the focus indicator silently stops firing. The spec asserts the parent relationship for exactly that reason.
Keyboard
| Key | Does |
|---|---|
| click | opens the menu, listing every option |
| ↓ / ↑, menu closed | opens the menu, listing every option |
| type | filters the menu |
| ↑ / ↓ | moves through the options, the "Add …" row included |
| Enter | adds the highlighted one — the typed text if none match |
| Backspace, empty field | removes the last chip |
| Esc | closes the menu |
The menu opens on click, not on focus: tabbing past the field must not throw a menu over the fields below it. From the keyboard, ↓ opens it to browse, and typing opens it filtered.
A click opens it only when there is a row to show. A remote field's options are usually empty until the first search, and opening then would answer "No results" to a question nobody had asked yet; typing still opens it, because by then it is an answer.
The Backspace guard matters: with text in the field it deletes text, not a pick.
With allowCreate and nothing matching, the "Add …" row is the only option there is, so it is the one highlighted and type, Enter creates. That is what a tag field is for, and it is why the row is a real option rather than a button beside the options: as a button it sat outside the roving highlight, ↑/↓ never reached it and Enter had nothing to select, so a tag could only be created with the mouse.
allowCreate, max, remote
allowCreate offers "Add ‘typed text’" when nothing matches. The typed text becomes both the value and the label, which is what a tag is — it has no identity apart from its name.
It combines with options: the field then offers the list and the create row, as the Topics demo above shows. The row is offered whenever the typed text is not exactly an existing label, so typing "Onc" beside "Oncology" offers both — fine for tags, worth a thought for people, where it invites near-duplicates.
max caps the picks. The count is rendered as n/max in the box rather than only blocking input, so the cap is visible before the user hits it.
remote is for options fetched per keystroke: the field then stops filtering the list it was given, because the server has already done it. Listen for search to fetch.
The field does the filtering, not reka — ignore-filter is unconditional and remote turns off the field's own pass. That is the create row's doing: reka reads an item's filter text once, when it mounts, and the create row's text is the typed text, so it would be filtered out of its own menu the moment the term grew past the character it was born with. The matching rule is reka's own, kept: a substring test insensitive to case and to accent, so "muller" still finds "Müller". It matches the trimmed term, where reka used the raw one — a trailing space is the tail of a word someone is still typing, not a narrower search.
Two details
The menu never offers what is already picked. Leaving a chosen option in the list invites a second click that appears to do nothing — the pick is already there, so the only visible change is the menu closing.
A disabled field's chips are not dismissable. HabChip infers removability from an @remove listener being attached, so a disabled field attaches none at all rather than passing a flag.
A provisional value
<HabChipInput v-model="synonyms" label="Synonyms" help="From the AI, not verified" marker />marker says the picks came from somewhere that has not been checked yet — drafted by an AI, carried in from an import. The box takes a faint accent fill and the help line takes accent ink.
It is the same flag, with the same semantics and the same treatment, as HabInput's — deliberately. A synonym list drafted by an AI is provisional in exactly the way a drafted description is, and in the terminology panel the two sit adjacent; one field carrying the fill and the next unable to would read worse than neither having it.
So the same order holds here: it is a flag beside state, not a member of it, because nothing is wrong with the picks. error and disabled keep their own fill, and the mark draws no glyph and writes no words — what it means is the host application's to say, in help and beside the label.
Trailing affordances
The append slot puts nodes at the trailing edge of the box, after the n/max counter rather than replacing it.
They do not wrap with the chips. The box itself no longer wraps; only the region holding the chips and the input does, and the glyph and the appended nodes sit outside it. With every child in one wrapping container, where those nodes land is decided by how many chips there are — they ride along to the last line, or wrap below it. A field whose generate button moved as picks accumulated would not read as the same kind of thing as the HabInput above it.
Each affordance carries its own accessible name; the component names none of them.
Accessibility
- The input is a
comboboxand takes the label as its accessible name. - The open menu's listbox is named after the field, and the input's
aria-controlspoints at it. reka ships neither — the same two wiresHabAutocompletedraws. - Each chip's dismiss button is named after its own chip — "Remove Ada Lovelace", not "Remove".
helpis announced as the field's description, and rendered in error ink whenstateiserror.- Not asserted in the specs: committing a pick by pointer. reka's option rows need a real pointer sequence happy-dom cannot produce — the same limitation as
HabSelect. The keyboard and removal paths are asserted. - Also unasserted: clicking an
appendaffordance while the option list is open. The slot sits inside the combobox's anchor, so reka may treat the click as within the combobox and suppress the dismiss, leaving a stale list over the field. It needs the real-browser layer to settle either way. If you put a control there that is meant to be used mid-search, check it by hand until then.
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
allowCreate | boolean | false | Offer "Add ‘typed text’" when nothing matches, for free tagging. The created option's value is the typed text. |
createLabel | ((typed: string) => string) | undefined | Names the create row, given the typed text. A function rather than a string because both the word order and the quotation marks differ per language — German quotes „like this“, French « like this ». Required whenever allowCreate is set. |
emptyLabel | string | undefined | Shown in the menu when nothing matches. No default — omit it and the menu shows nothing, which is what the empty state looks like untranslated. |
help | string | undefined | Helper text below the box. Rendered in error ink when state is error. |
icon | string | null | undefined | Leading icon in the box as SVG path data from @mdi/js. Defaults to the search glyph; pass null to drop it. |
label | string | undefined | Visible label, associated with the control. |
marker | boolean | false | Marks the picks 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, deliberately: a synonym list drafted by an AI is provisional in exactly the way a drafted description is, and the two sit adjacent in the same panel. It is a flag beside state rather than a member of it, because nothing is wrong with the value; error and disabled keep their own fill, and it sets no aria-invalid. It draws no glyph and writes no words. What the mark MEANS is the host application's to say, in help and beside the label. |
max | number | undefined | Cap the number of picks. Shows n/max in the box and blocks past it. |
modelValue | HabChipOption[] | [] | The picks, in order. Bind with v-model. |
options | HabChipOption[] | [] | What can be picked. |
placeholder | string | undefined | — |
remote | boolean | false | Set when options are fetched per keystroke: reka then stops filtering the list it was given, because the server has already done it. |
state | "default" | "disabled" | "error" | "default" | — |
Events
| Event | Payload | Description |
|---|---|---|
search | term: string | — |
update:modelValue | picks: HabChipOption[] | — |
Slots
| Slot | Bindings | Description |
|---|---|---|
append | any | Extra affordances at the trailing edge of the box, AFTER the n/max counter rather than replacing it — a bracket-syntax hint beside a generate button. They sit OUTSIDE the region the chips wrap in, which is what keeps them pinned and vertically centred however many chips there are. A field whose generate button moved as picks accumulated would not read as the same kind of thing as the HabInput above it. Each affordance carries its own accessible name; the component names none of them. |
leading | any | Leading adornment, replacing the glyph. Takes precedence over icon. |