@tapestry-ui/combobox
v0.3.0
Published
A WCAG 2.1 AA combobox for Preact — type to filter or open the whole list, APG keyboard, match highlighting, headless behavior, skin via CSS. The polished replacement for a native select.
Maintainers
Readme
@tapestry-ui/combobox
A combobox for Preact: type to narrow, or open the whole list like a select. WCAG 2.1 AA, headless behavior, skinned entirely through CSS.
Part of Tapestry UI — components forged inside a woven-tapestry game and a private war-room dashboard, then published so they have to survive strangers.
npm i @tapestry-ui/comboboximport { Combobox } from '@tapestry-ui/combobox';
import '@tapestry-ui/combobox/styles.css'; // structural only — no colors
<Combobox
label="Property"
options={properties.map((name) => ({ value: name }))}
value={chosen}
onChange={setChosen}
placeholder="all properties"
/>Why not a <select>
A native select cannot be styled past its edges and cannot be typed into. With eighteen options, "scroll until you see Fairview" is the wrong interaction — you already know the name. This is both halves at once: a text field that filters as you type, and a chevron that shows you everything.
The two contracts
Typing is a query, never a value. A component that replaces a <select>
must never end up holding something nobody chose. Abandon the field half-typed
and the committed label comes back. Only Enter, Tab, a click, or — with
allowCustom — blurring on free text commits anything.
The container contract, and its one exception. Tapestry UI components never
own scrolling. The popup is the exception, deliberately: it is an overlay over
the page rather than part of your layout, so capping its height constrains
nothing you laid out. The cap is still yours —
--tui-combobox-list-max-h: none removes it.
Props
| Prop | Type | Notes |
|---|---|---|
| options | ComboboxOption[] | { value, label?, description?, disabled? }; label defaults to value |
| label | string | required — the accessible name, read before the value |
| showLabel | boolean | render the label visibly too (default: screen readers only) |
| value / onChange | string / (value) => void | controlled; the component reports intent and never drifts |
| defaultValue | string | uncontrolled starting value |
| placeholder | string | |
| allowCustom | boolean | commit typed text that matches no option |
| openOnFocus | boolean | show the whole list the moment the field is focused |
| filter | (query, option) => boolean | the query arrives lowercased and de-accented |
| emptyLabel | string | shown when nothing matches (default "no match") |
| toggleLabel | string | accessible name for the chevron |
| maxVisible | number | rendered-row cap (default 100); the rest are counted, not drawn |
| disabled / required | boolean | |
| name | string | adds a hidden input so the field posts like a <select> |
| class / inputClass / listClass / optionClass / id | string | skin hooks |
foldCase(text) is exported — the same lowercase-and-strip-accents the default
filter uses, in case you write your own.
Keyboard
| Key | Does | |---|---| | ↓ / ↑ | open the list; move the highlight, wrapping at the ends | | Home / End | first / last option | | Enter | commit the highlighted option (and not submit the form) | | Tab | commit the highlighted option and move on | | Esc | close the list; again restores the committed label | | typing | filter, highlighting the matched run |
Disabled options are never a destination. DOM focus stays in the input the
whole time — the highlight is carried by aria-activedescendant, so a screen
reader reads the option without losing the text field, and the option count is
announced from a polite live region as the list narrows.
Styling
Structural CSS ships in styles.css; it picks no colors. Skin it with custom
properties, or ignore the file and style the [data-part] hooks yourself.
Never fork the package to restyle it.
| Property | Default |
|---|---|
| --tui-combobox-pad | 0.4em 2em 0.4em 0.6em |
| --tui-combobox-focus | 2px solid currentColor |
| --tui-combobox-toggle-w | 2em |
| --tui-combobox-chevron-w | 0.625em |
| --tui-combobox-list-max-h | 15em (none to uncap) |
| --tui-combobox-option-pad | 0.4em 0.6em |
| --tui-combobox-z | 20 |
Parts: root (data-state="open|closed", data-placement="below|above"),
label, field, input, toggle, list, option
(data-state="active|idle"), option-label, option-desc, match, empty,
more, status, value.
The popup opens upward on its own when there is no room below it — a phone keyboard eats the bottom half of the screen, and a popup under the fold is a popup that isn't there.
License
MIT © Brandon Minton
