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> import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import '@esa/ecology/esa-filter-dropdown';
@Component({
selector: 'app-filter-dropdown-demo',
standalone: true,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
template: `
<esa-filter-dropdown name="status" label="Status"></esa-filter-dropdown>
<esa-filter-dropdown name="dept" label="Department" multiple></esa-filter-dropdown>
`,
})
export class FilterDropdownDemo {} 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> import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import '@esa/ecology/esa-filter-dropdown';
@Component({
selector: 'app-filter-dropdown-demo',
standalone: true,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
template: `
<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>
`,
})
export class FilterDropdownDemo {} 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 } --> import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import '@esa/ecology/esa-filter-dropdown';
@Component({
selector: 'app-filter-dropdown-demo',
standalone: true,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
template: `
<esa-filter-dropdown name="priority" label="Priority"></esa-filter-dropdown>
<!-- options include { label: 'High', value: 'high', disabled: true } -->
`,
})
export class FilterDropdownDemo {} API
| Prop | Type | Default | Description |
|---|---|---|---|
name | string | '' | Filter key carried in emitted events. |
label | string | '' | Trigger label; selection appends "label: value". |
options | FilterOption[] | [] | 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. |
Binding these events from Angular
Every one of these is a CustomEvent, so the payload is on
$event.detail — $event.target.value reads empty, with no
error. The names are lowercase and Angular passes them to
addEventListener verbatim, so camel-casing one means it never fires.
(esa-filter-change)="onEsaFilterChange($event.detail)"(selection-change)="onSelectionChange($event.detail)"
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,
split by blast radius: the hooks that belong to this component,
then the shared and system tokens it consumes alongside everything else. 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.
Type roles it names
This component does not set a size, weight, face or tracking of its own — it names a composite, and the composite carries all five. A composite is a class rather than a token, so none of it appears in the tables below; re-theme text by re-pointing the token family, not by editing the component.
| Role | Re-point | Reach |
|---|---|---|
.typography-microcopy-lg-subtle |
| +7 more componentsesa-combobox, esa-command-palette, esa-date-picker, esa-entity-search, esa-input-tag, esa-select, esa-text-field |
.typography-microcopy-md-subtle |
| +7 more componentsesa-combobox, esa-date-picker, esa-input-tag, esa-pagination, esa-search-panel, esa-select, esa-text-field |
.typography-microcopy-sm |
| +3 more componentsesa-filter-clear-button, esa-pill, esa-sidebar-nav |
.typography-microcopy-sm-subtle |
| +3 more componentsesa-app-shell, esa-file-list, esa-file-upload |
.typography-microcopy-xs-strong |
| +5 more componentsesa-badge, esa-button-toggle, esa-chip-group, esa-sidebar-nav, esa-tab-layout |
.typography-microcopy-xs-subtle |
| +7 more componentsesa-combobox, esa-date-picker, esa-filter-pills, esa-input-tag, esa-select, esa-text-field, esa-tooltip |
Wired to this component
No other component reads these — re-point them and esa-filter-dropdown is the
only thing that moves.
| Token | Tier | Resolves to |
|---|---|---|
--filter-dropdown-min-width | component | 200px |
Shared tokens it reads
Owned by a family or by the semantic layer. Still overridable — but scope the
override (esa-foo { --form-border-color: … }) unless you mean to move
everything that reads it. Reach says what else that is.
| Token | Reach | Resolves to |
|---|---|---|
--focus-ring-color | component focus ring group · +34 more | #3e9b4f via --color-border-default-focus → --color-grass-10 |
--focus-ring-offset | component focus ring group · +31 more | 2px |
--focus-ring-width | component focus ring group · +35 more | 2px via --border-width-focus → --border-width-200 |
--form-border-color-focus | component forms group · +11 more | #3e9b4f via --color-border-default-focus → --color-grass-10 |
--border-width-default | semantic every component | 1px via --border-width-100 |
--color-background-brand | semantic every component | #46a758 via --color-grass-9 |
--color-background-brand-subtle | semantic every component | #fbfefb via --color-grass-1 |
--color-background-elevation-raised | semantic every component | #fcfcfc via --color-gray-1 |
--color-background-elevation-sunken | semantic every component | #f0f0f0 via --color-gray-3 |
--color-border-default | semantic every component | one role per property, so a theme can move the ring's weight and its colour independently. Tier 3's --focus-ring-width now reads this instead of holding a bare 2px, which is the same shape --focus-ring-color has always had (a thin tier-3 alias over a tier-2 role) — width was simply never given the tier-2 half.
*
* This role was rejected once, and the rejection is worth recording because its PREMISE was wrong rather than its reasoning. The argument against an `emphasis` width was that every 2px border in the kit is micro-geometry — spinner rings, the kbd keycap lip, a chevron. The focus ring was not in that enumeration and does not fit it: 33 components render it, more than read any other tier-3 token, and it is not micro-geometry but an accessibility affordance whose weight is a legitimate brand axis (a spoke that needs a heavier ring for contrast has to be able to reach it). It is also not a `border` in any component — every consumer paints it with `outline` or `box-shadow` — which is precisely why a survey of BORDERS missed it. The name still says `border` because tier 2 already calls the ring's colour --color-border-default-focus |
--color-content-brand | semantic every component | #2a7e3b via --color-grass-11 |
--color-content-default | semantic every component | #202020 via --color-gray-12 |
--color-content-default-knockout | semantic every component | #fcfcfc via --color-gray-1 |
--color-content-default-secondary | semantic every component | #646464 via --color-gray-11 |
--elevation-4 | semantic every component | 0 via --shadow-offset-x |
--radius-md | semantic every component | 0.5rem via --radius-200 |
--radius-pill | semantic every component | 9999px via --radius-full |
--radius-sm | semantic every component | 0.25rem via --radius-100 |
--transition-fast | semantic every component | 150ms via --duration-150 |
--z-dropdown | semantic every component | 50 |
--font-style-italic | primitive every component | italic |
--spacing-100 | primitive every component | 0.25rem |
--spacing-150 | primitive every component | 0.375rem |
--spacing-200 | primitive every component | 0.5rem |
--spacing-300 | primitive every component | 0.75rem |
--spacing-400 | primitive every component | 1rem |
--spacing-500 | primitive every component | 1.5rem |