Combobox
Form-associated combobox with two modes, two trigger styles, multi-select chips, match highlighting, debounced search, and loading/empty/count states. A Lit Web Component migrated from the Angular esa-combobox.
Preview
<esa-combobox label="State" mode="select" placeholder="Pick a state"></esa-combobox> Modes
select uses a button trigger with in-panel search; autocomplete uses an input trigger. The text trigger style swaps the bordered field for a link-like control.
<esa-combobox label="Select mode" mode="select" placeholder="Pick a state"></esa-combobox>
<esa-combobox label="Autocomplete mode" mode="autocomplete" placeholder="Type to search"></esa-combobox>
<esa-combobox mode="select" trigger-style="text" placeholder="Filter by state"></esa-combobox> Sizes
<esa-combobox label="Extra small" size="xs"></esa-combobox>
<esa-combobox label="Small" size="sm"></esa-combobox>
<esa-combobox label="Medium" size="md"></esa-combobox>
<esa-combobox label="Large" size="lg"></esa-combobox> States
Multi-select renders chips. Loading, results-count, error, and disabled states are attribute-driven.
<esa-combobox label="States visited" mode="select" multiple placeholder="Add states"></esa-combobox>
<esa-combobox label="With results count" mode="select" results-count="50" help-text="Shows 'Displaying X of Y results'"></esa-combobox>
<esa-combobox label="Loading" mode="select" loading></esa-combobox>
<esa-combobox label="Error" error-text="Selection required"></esa-combobox>
<esa-combobox label="Disabled" disabled></esa-combobox> API
| Prop | Type | Default | Description |
|---|---|---|---|
mode | 'autocomplete' | 'select' | 'select' | 'select' = button trigger with in-panel search; 'autocomplete' = input trigger. Reflected. |
trigger-style | 'field' | 'text' | 'field' | 'field' = bordered control; 'text' = link-like trigger. |
options | { value, label, disabled? }[] | [] | Selectable options. Set as a property. |
multiple | boolean | false | Allow multiple selections with removable chips. |
size | 'xs' | 'sm' | 'md' | 'lg' | 'md' | Control size (reflected attribute). |
label | string | '' | Field label above the control. |
placeholder | string | 'Select...' | Placeholder for the trigger/input. |
disabled | boolean | false | Disables interaction (reflected attribute). |
required | boolean | false | Marks the field required. |
help-text | string | '' | Helper text below the control. |
error-text | string | '' | Error message; sets the error state when present. |
loading | boolean | false | Shows a spinner and "Searching..." state. |
debounce-ms | number | 300 | Debounce window for the search event. |
results-count | number | null | null | When set, renders "Displaying X of Y results". |
value | string | string[] | — | Property accessor. Single = string; multiple = string array. |
Events
| Event | Type | Default | Description |
|---|---|---|---|
change | CustomEvent<{ value: string | string[] }> | — | Fired on selection change. Composed and bubbles. |
search | CustomEvent<{ term: string }> | — | Debounced search term (300ms default), de-duplicated. Composed and bubbles. |
Accessibility
- Autocomplete input carries
role="combobox"witharia-expanded,aria-haspopup="listbox", andaria-autocomplete="list". - Panel is
role="listbox"; options arerole="option"witharia-selected. - Keyboard: ArrowUp/Down navigate, Enter selects, Escape/Tab close. Outside-click closes.
- Matched substrings are wrapped in
<mark>for visible highlight. - Form-associated via
ElementInternals— value submits with the enclosing form.
Theming surface
Every public token this component reads, extracted from its source at build time.
The Resolves to column walks each token's real lineage —
component → semantic → primitive → raw value — so you
can see exactly where re-pointing it lands. A spoke re-skins by overriding these in
its theme-<slug>.css; component internals are never edited. No
hook for what you need? File it with /request-lego.
| Token | Tier | Resolves to |
|---|---|---|
--focus-ring-color | component | #65ba74 via --color-border-focus → --color-grass-8 |
--focus-ring-width | component | 2px |
--form-bg | component | #fcfcfc via --color-surface → --color-gray-1 |
--form-bg-disabled | component | #f0f0f0 via --color-disabled-bg → --color-gray-3 |
--form-border-color | component | #cecece via --color-border → --color-gray-7 |
--form-border-color-error | component | #e5484d via --color-danger → --color-red-9 |
--form-border-color-focus | component | #65ba74 via --color-border-focus → --color-grass-8 |
--form-border-width | component | 1px |
--form-error-color | component | #ce2c31 via --color-danger-strong → --color-red-11 |
--form-font-size-lg | component | clamp(0.875rem, 0.77rem + 0.52vw, 1.125rem) via --type-size-300 |
--form-font-size-md | component | clamp(0.75rem, 0.66rem + 0.44vw, 0.9375rem) via --type-size-200 |
--form-font-size-sm | component | clamp(0.625rem, 0.56rem + 0.32vw, 0.75rem) via --type-size-100 |
--form-font-size-xs | component | clamp(0.5rem, 0.44rem + 0.3vw, 0.625rem) via --type-size-050 |
--form-height-lg | component | 48px |
--form-height-md | component | 40px |
--form-height-sm | component | 32px |
--form-height-xs | component | 28px |
--form-help-color | component | #838383 via --color-text-muted → --color-gray-10 |
--form-label-color | component | #646464 via --color-text-secondary → --color-gray-11 |
--form-padding-x-lg | component | 1rem via --spacing-400 |
--form-padding-x-md | component | 0.75rem via --spacing-300 |
--form-padding-x-sm | component | 0.625rem via --spacing-250 |
--form-padding-x-xs | component | 0.5rem via --spacing-200 |
--form-padding-y-lg | component | 0.75rem via --spacing-300 |
--form-padding-y-md | component | 0.5rem via --spacing-200 |
--form-padding-y-sm | component | 0.375rem via --spacing-150 |
--form-padding-y-xs | component | 0.25rem via --spacing-100 |
--form-placeholder-color | component | #838383 via --color-text-muted → --color-gray-10 |
--form-radius-lg | component | 0.5rem via --radius-200 |
--form-radius-md | component | 0.5rem via --radius-200 |
--form-radius-sm | component | 0.25rem via --radius-100 |
--form-radius-xs | component | 0.25rem via --radius-100 |
--form-text-color | component | #202020 via --color-text-primary → --color-gray-12 |
--icon-size-small | component | 16px |
--color-active-overlay | semantic | rgba(0, 88, 98, 0.08) |
--color-border | semantic | #cecece via --color-gray-7 |
--color-border-light | semantic | #d9d9d9 via --color-gray-6 |
--color-danger-border | semantic | #fdbdbe via --color-red-6 |
--color-danger-strong | semantic | #ce2c31 via --color-red-11 |
--color-disabled-text | semantic | #8d8d8d via --color-gray-9 |
--color-hover-overlay-strong | semantic | rgba(0, 0, 0, 0.05) |
--color-primary-strong | semantic | #2a7e3b via --color-grass-11 |
--color-surface | semantic | #fcfcfc via --color-gray-1 |
--color-surface-sunken | semantic | #f0f0f0 via --color-gray-3 |
--color-text-muted | semantic | #838383 via --color-gray-10 |
--color-text-primary | semantic | #202020 via --color-gray-12 |
--color-warning-subtle | semantic | #fefbe9 via --color-status-warning-subtle → --color-yellow-2 |
--font-sans | semantic | 'DM Sans', sans-serif |
--transition-fast | semantic | 150ms ease |
--type-size-100 | semantic | clamp(0.625rem, 0.56rem + 0.32vw, 0.75rem) |
--type-size-150 | semantic | clamp(0.6875rem, 0.61rem + 0.38vw, 0.875rem) |
--font-weight-medium | primitive | 500 |
--radius-full | primitive | 9999px |
--shadow-200 | primitive | 0 4px 20px -4px rgba(0, 0, 0, 0.06) |
--spacing-050 | primitive | 0.125rem |
--spacing-100 | primitive | 0.25rem |
--spacing-200 | primitive | 0.5rem |
--spacing-300 | primitive | 0.75rem |
--z-dropdown | primitive | 50 |