GitHub

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.

Stable wc import '@esa/ecology/esa-filter-dropdown';

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

PropTypeDefaultDescription
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

PropTypeDefaultDescription
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> with aria-haspopup="listbox" and aria-expanded reflecting open state.
  • Panel is a role="listbox"; each option is role="option" with aria-selected and aria-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.

TokenTierResolves 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