GitHub

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.

Stable wc import '@esa/ecology/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

PropTypeDefaultDescription
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

EventTypeDefaultDescription
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" with aria-expanded, aria-haspopup="listbox", and aria-autocomplete="list".
  • Panel is role="listbox"; options are role="option" with aria-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.

TokenTierResolves 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