Filter Dropdown
Interactive dropdown filter (Lit web component) with a searchable panel, full keyboard navigation, and single or multi select. The trigger fills with the primary color when active and shows an inline clear control.
Preview
Click the trigger to open the panel, type to filter, and use Arrow/Enter/Escape to navigate. Outside-click and Escape close it. Options are set as a property in a small init script.
<esa-filter-dropdown name="status" label="Status"></esa-filter-dropdown>
<script>
import '@esa/ecology/esa-filter-dropdown';
customElements.whenDefined('esa-filter-dropdown').then(() => {
document.querySelector('esa-filter-dropdown').options = [
{ label: 'Open', value: 'open' },
{ label: 'In review', value: 'review' },
{ label: 'Closed', value: 'closed' },
];
});
</script> Select modes
Single select closes on choice; multiple keeps the panel open, shows checkmarks, and renders "first +N" in the trigger.
<esa-filter-dropdown name="status" label="Status"></esa-filter-dropdown>
<esa-filter-dropdown name="dept" label="Department" multiple></esa-filter-dropdown> Sizes
<esa-filter-dropdown label="XS" size="xs"></esa-filter-dropdown>
<esa-filter-dropdown label="Small" size="sm"></esa-filter-dropdown>
<esa-filter-dropdown label="Medium" size="md"></esa-filter-dropdown>
<esa-filter-dropdown label="Large" size="lg"></esa-filter-dropdown> States
Disabled options are dimmed, skipped by keyboard navigation, and not selectable.
<esa-filter-dropdown name="priority" label="Priority"></esa-filter-dropdown>
<!-- options include { label: 'High', value: 'high', disabled: true } --> API
| Prop | Type | Default | Description |
|---|---|---|---|
name | string | '' | Filter key carried in emitted events. |
label | string | '' | Trigger label; selection appends "label: value". |
options | { label, value, disabled? }[] | [] | Selectable options (set as a property, not an attribute). |
multiple | boolean | false | Allow multiple selections; trigger shows "first +N". |
placeholder | string | '' | Search-input placeholder (defaults to "Search…"). |
size | 'xs' | 'sm' | 'md' | 'lg' | 'md' | Trigger height, padding, font size, and radius. |
Events
| Prop | Type | Default | Description |
|---|---|---|---|
selection-change | { value: string | string[] } | — | Fires on every selection change. value is a string in single mode, an array in multiple mode. |
esa-filter-change | { name, filters: ActiveFilter[] } | — | Bubbling/composed. Carries the filter name plus the full active-filter list for a parent container to coordinate state. |
Accessibility
- Trigger is a native
<button>witharia-haspopup="listbox"andaria-expandedreflecting open state. - Panel is a
role="listbox"; each option isrole="option"witharia-selectedandaria-disabled. - Keyboard: ArrowUp/Down highlight (skipping disabled), Enter selects, Escape closes. Opening focuses the search input.
- Visible focus ring on the trigger via
--focus-ring-*tokens.
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 |
|---|---|---|
--filter-dropdown-bg | component | #fcfcfc via --color-surface → --color-gray-1 |
--filter-dropdown-border | component | #cecece via --color-border → --color-gray-7 |
--filter-dropdown-min-width | component | 200px |
--filter-dropdown-radius | component | 0.5rem via --radius-200 |
--focus-ring-color | component | #65ba74 via --color-border-focus → --color-grass-8 |
--focus-ring-width | component | 2px |
--color-border | semantic | #cecece via --color-gray-7 |
--color-primary | semantic | #46a758 via --color-grass-9 |
--color-primary-subtle | semantic | #f5fbf5 via --color-grass-2 |
--color-surface | semantic | #fcfcfc via --color-gray-1 |
--color-surface-sunken | semantic | #f0f0f0 via --color-gray-3 |
--color-text-inverse | semantic | #fcfcfc via --color-gray-1 |
--color-text-muted | semantic | #838383 via --color-gray-10 |
--color-text-primary | semantic | #202020 via --color-gray-12 |
--focus-ring-offset | semantic | 2px |
--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) |
--type-size-200 | semantic | clamp(0.75rem, 0.66rem + 0.44vw, 0.9375rem) |
--type-size-300 | semantic | clamp(0.875rem, 0.77rem + 0.52vw, 1.125rem) |
--font-weight-medium | primitive | 500 |
--font-weight-semibold | primitive | 550 |
--radius-100 | primitive | 0.25rem |
--radius-200 | primitive | 0.5rem |
--radius-300 | primitive | 0.5rem |
--radius-full | primitive | 9999px |
--shadow-200 | primitive | 0 4px 20px -4px rgba(0, 0, 0, 0.06) |
--spacing-100 | primitive | 0.25rem |
--spacing-150 | primitive | 0.375rem |
--spacing-200 | primitive | 0.5rem |
--spacing-300 | primitive | 0.75rem |
--spacing-400 | primitive | 1rem |
--spacing-500 | primitive | 1.5rem |
--z-dropdown | primitive | 50 |