<!-- GENERATED by scripts/build-llms.mjs from llms/layout.md — do not edit this file. -->

# `lr-filter-bar`

- **Import** `import '@aceshooting/lyra-ui/components/lr-filter-bar.js';` (stable tag alias; registers the tag)
- **Class** `LyraFilterBar`, also available unregistered from `@aceshooting/lyra-ui/components/layout/filter-bar/filter-bar.class.js`
- **Family** `components/layout/` — see `llms/index.md` for its siblings
- **Status** `stable` since `4.1.0` — see the maturity and deprecation policy in `llms/shared.md`
- **Release history** [CHANGELOG.md](../../CHANGELOG.md); family-wide breaking-change summaries: [llms-full.txt](../../llms-full.txt)
- **Deprecations** none
- **Optional peers** none
- **Themeable via** 29 parts, 2 custom properties — see this component's own `@csspart`/`@cssprop` list below
- **Library-wide behavior** (events, form association, `locale`/`strings`, tokens, TS types): `llms/shared.md`

---

## `lr-filter-bar`

Dashboard filter row that composes Lyra inputs and removable chips, with reset and loading states.

Choice option entries must expose string `value` and `label` data fields; malformed entries are
omitted independently, while supplied empty strings remain valid. A custom definition requires its
adapter and a callable `render`; a rejected definition does not reserve its filter ID. Valid
siblings remain available. Exceptions thrown by an admitted trusted renderer still propagate. A
chip-only definition requires neither options nor an adapter — a stable filter ID and a label are
the whole schema, since a malformed `formatValue`/`isEmpty` still has a correct fallback and so is
guarded where it is used rather than rejected outright.

**Lean registration entry.** `components/layout/filter-bar/filter-bar.js` (the default entry)
eagerly imports every composed control this bar could possibly render — `<lr-select>`,
`<lr-combobox>`, `<lr-dropdown>` + `<lr-dropdown-item>` (the `'checkbox-menu'` branch),
`<lr-date-input>`, `<lr-input>`, `<lr-chip>`/`<lr-chip-group>` (the active-filter row), and
`<lr-button>`/`<lr-spinner>` (the reset action and the loading status) — because `filters` is a
runtime value it cannot inspect ahead of time. A bar that only ever declares `'select'`/`'text'`
filters still pays for `<lr-combobox>` and `<lr-date-input>` through that entry: a measured ~69.5 kB
gzip more than importing only what it uses. A consumer who knows their own filter `type`s ahead of
time can import `components/layout/filter-bar/filter-bar-register.js` instead, which registers
`<lr-filter-bar>` and nothing else, then import each composed control's own registration entry for
the filter `type`s actually declared:

| Filter `type` | Registration entry |
| --- | --- |
| `'select'` | `components/forms/select/select.js` |
| `'combobox'` | `components/forms/combobox/combobox.js` |
| `'checkbox-menu'` | `components/overlays/overlay/dropdown.js` **and** `components/layout/menu/dropdown-item.js` |
| `'date'` / `'date-range'` | `components/forms/date-picker/date-input.js` |
| `'text'` | `components/forms/input/input.js` |
| `'chip'` | none — renders no control |

Two more are unconditional regardless of which filter `type`s are declared: `<lr-button>` renders
the reset action on every bar, and `<lr-chip>`/`<lr-chip-group>` render the active-filter row
whenever any filter has a value (further gated by `activeFiltersDisplay`, but never provably absent
for a generic bar) — `components/forms/button/button.js` and `components/overlays/chip/chip.js` +
`components/overlays/chip/chip-group.js`. `<lr-spinner>`
(`components/overlays/spinner/spinner.js`) is the one built-in dependency the lean entry omits even
though every bar could use it: `loading` is a plain boolean any consumer can leave unset entirely,
unlike a filter `type`, which `filters` always names outright — import it too if the bar ever sets
`loading`. A filter definition whose `type` has no matching import above renders no usable control
until something else registers it, the same trade `icon-button-register.js` documents for
`<lr-icon-button>`'s own `icon`/`src` attribute.

**Properties:**

- `filters: readonly LyraFilterBarFilterDefinition[] = []` (attribute: false) — filter schema in
  render order. Every definition carries a nonempty, whitespace-stable, unique `filterId` and a
  `label` that is a non-blank string; invalid definitions (including a missing, non-string, or
  blank `label`) and later duplicate filter IDs are ignored deterministically. The first 10,000
  definitions and nested collection entries are detached and deeply frozen at assignment; the
  optional Lit `icon` payload retains its rendering identity. Create and reassign a new array after
  changes. Writing `null` or `undefined` clears the schema; reads remain the canonical non-null
  empty array.
- `value: LyraFilterBarValue = {}` (attribute: false) — sparse current values keyed by `filterId`.
  Cleared fields are omitted. Reads, writes, event details, and string-array fields are immutable
  snapshots rather than references to caller-owned data, capped at 10,000 record keys and 10,000
  entries per string-array field. Create and reassign a new record after changes. Writing `null` or
  `undefined` clears the value; reads remain the canonical non-null empty record. Built-in controls
  use strings/string arrays; if an untyped boundary supplies a boolean, `false` is canonical empty
  and omitted while `true` remains set. Custom controls instead use their adapter's `isEmpty` or
  `clearValue` contract, so either boolean can be meaningful in a custom domain.
- `label: string = ''` — accessible-name fallback for the internal `role="group"`. A host
  `aria-label` wins by attribute presence, including an explicitly empty value.
- `disabled: boolean = false` (reflected) — disables every filter control and reset action.
- `loading: boolean = false` (reflected) — shows the status spinner and disables reset while leaving
  filters editable.
- `hasActiveFilters: boolean` (read-only) — whether any configured filter currently has a value,
  including one sitting at its own declared `defaultValue`. Drives the reset button's own disabled
  state in every `activeFiltersDisplay` mode except `'changed'`; the getter itself is unaffected by
  `activeFiltersDisplay`.
- `hasChangedFilters: boolean` (read-only) — whether any filter's value differs from its own
  declared `defaultValue`, using the same equality `activeFiltersDisplay: 'changed'` filters its
  chip row on: a `readonly string[]` default compares positionally, everything else compares with
  `Object.is`. This is the counterpart to `hasActiveFilters`, not a synonym — a bar whose filters
  were all declared with non-empty defaults and never touched reads `hasActiveFilters === true` and
  `hasChangedFilters === false`, because a bar whose defaults narrow the view on load has not been
  narrowed by the user. A filter with no declared `defaultValue` counts as changed the moment it
  holds any value at all (there is nothing for it to still equal), and clearing a filter that *does*
  declare one counts as changed too, since `reset()` would restore it — which is the one case where
  this getter and the `'changed'` chip row differ, the row's entries being non-empty by
  construction. Always live, never cached.
- `invalidFilterIds: readonly string[]` (read-only) — immutable ids of required filters whose
  values are unset.
- `activeFiltersDisplay: 'all' | 'changed' | 'hidden' = 'all'` (reflected, attribute
  `active-filters-display`) — which currently-active filters render as removable chips in the row
  below the fields. `'all'` (the default, and this component's only behavior before this property
  existed) shows one chip per non-empty filter, including one sitting at its own `defaultValue`.
  `'changed'` shows a chip only for a filter whose value differs from its own `defaultValue` — so a
  bar whose defaults narrow the view on load does not claim the user narrowed it — and a filter with
  no declared `defaultValue` counts as changed as soon as it has any value at all. `'hidden'` never
  renders the row. Array values compare against `defaultValue` positionally (same length, same entry
  at each index), matching this component's only other array-equality precedent (a custom adapter's
  own `clearValue` comparison); a `'date-range'` value is a single composed string, so it compares
  like any other string. Removing a chip always clears that filter, unaffected by this property.
  `'changed'` additionally gates the reset button on `hasChangedFilters` instead of
  `hasActiveFilters`, so an untouched defaults-only bar — which renders no chip in this mode — no
  longer offers an enabled reset that would change nothing. Enablement under `'all'` and `'hidden'`
  is unchanged, `disabled`/`loading` still win in every mode, and `reset()` itself is untouched.

The composed reset action uses `lr-button`'s default `m` size tier, matching the default rendered
height of adjacent select, combobox, input, and date fields instead of introducing a shorter action
inside the same controls row.
The host, root, controls, active-filter row, composed chip group, and chips all zero nested flex
auto minima and cap themselves to the allocated inline size. A single unbroken localized active
value therefore stays inside a 320px LTR or RTL bar, with the chip's own label ellipsis retaining
overflow ownership rather than widening the page.

Each edit exposes one filter-bar `lr-input` carrying a detached, deeply frozen snapshot of the
complete value object, plus the `filterId` that changed and (new in 12.0.0) `appliedPreset` — see
"Date-range quick ranges" below. A built-in or
custom control's own `lr-input`/`lr-change` aliases stay inside the wrapper so their incompatible
detail shapes cannot escape as duplicate bar events; native-style `input`/`change` events from the
composed controls continue bubbling normally.

**Methods:** `checkValidity(): boolean` returns whether every required filter is set without
revealing errors; `reportValidity(): boolean` returns the same state and reveals every current
required-field error; `reset(): void` restores each definition's `defaultValue` (or unsets it),
unless the bar is disabled.

**Events:** `lr-input`, `lr-reset`, `lr-validity-change`.

**Slots:** `end` — extra host-supplied controls (for example, a "Save search" or "Export"
action) rendered inside `controls`, next to the reset button. Hidden and claiming no layout
space while nothing is slotted.

**CSS parts:** `base`, `controls`, `field`, `field-<filterId>`, `end`, `filter-control`,
`filter-control-label`, `filter-control-label-group`, `filter-control-field`,
`filter-control-input`, `filter-control-start`, `filter-control-end`, `filter-control-listbox`,
`filter-control-option`, `filter-control-tags`, `filter-control-tag`, `filter-control-tag-label`,
`filter-control-tag-remove-button`, `filter-control-tag-remove-button-base`,
`filter-control-clear-button`, `filter-control-expand-button`, `filter-control-expand-icon`,
`filter-control-popup`, `filter-control-error`, `filter-control-hint`, `active-filters`, `chips`,
`chip`, `reset-button`, `status`.

The `filter-control-*` parts are semantic aliases forwarded from each built-in control's shadow
surface. `filter-control-field` consistently reaches the select trigger, combobox container, or
text/date input wrapper; `filter-control-input` reaches the corresponding display or editable input.
Listbox/option aliases apply to select and combobox filters, `filter-control-tags`/
`filter-control-tag`/`filter-control-tag-label` apply to a `multiple` combobox filter's selected-tag
chips (`filter-control-tag-label` is capped by that control's own `--tag-max-size`), and
expand-button/popup apply to date filters. `filter-control-tag-remove-button`/
`filter-control-tag-remove-button-base` reach a selected tag's own remove button and its inner icon
wrapper — the same reach a standalone `lr-combobox`/`lr-select` consumer already has, now available
from `lr-filter-bar` too, for a consumer re-skinning filter tags as pills who needs the remove
target inside one to be stylable. This lets a consumer theme the composed tier from
`lr-filter-bar::part(...)` without depending on the built-in control type selected by a filter
definition. Custom renderers retain ownership of their own part forwarding.

A `multiple` `'combobox'` filter collapses past its own `max-options-visible` (3 by default, an
`<lr-combobox>` property this component does not forward) into a localized "+N" overflow indicator,
the same substance as `lr-select`'s own `multiple`-mode overflow chip. The one remaining difference:
`lr-select`'s overflow chip carries a second, distinguishing `tag-overflow` part
(`part="tag tag-overflow tag__base"`) so a consumer can style just that chip; `lr-combobox`'s
overflow chip carries only the plain `tag` part, with no equivalent token to forward as
`filter-control-tag-overflow`. Adding one is `<lr-combobox>`'s own surface to grow, not something
`lr-filter-bar`'s `exportparts` can manufacture for a part its composed child never renders — noted
here as a known, deliberate gap rather than silently undocumented.
On a `'checkbox-menu'` filter, `filter-control-field` is the trigger button's own frame — the
element inside `<lr-button>` that draws the border, background and radius, not the chrome-less
button host, so a `::part(filter-control-field) { border-color: … }` rule works there exactly as it
does for every other filter type. `filter-control-start` is that trigger's adornment wrapper (where
a definition `icon` lands), `filter-control-input` is its selection summary, `filter-control-label`
is the trigger's own label text (not a stacked label above the control), `filter-control-listbox` is
the dropdown's popup surface, `filter-control-option` is one `role="menuitemcheckbox"` row, and
`filter-control-error` is the revealed required message — rendered by the bar itself, because the
composed dropdown has no error chrome of its own. The trigger also renders a `with-caret` disclosure
chevron, matching `lr-select`'s own — forwarded as `filter-control-expand-icon`, the same name a
select/combobox/date-input filter's own chevron already uses, so one consumer rule styles every
filter type's expand icon. `filter-control-label-group` reaches the trigger's own label wrapper —
the flex row this component lays `filter-control-label` and `filter-control-input` out in, which
also grows to fill the stretched trigger (via `with-caret`) so its content starts at the leading
edge instead of centring; no other filter type renders this part, since every other type's label and
input are two independent elements with no shared wrapper of their own. This component does not
render a stacked label above a `'checkbox-menu'` field the way every other built-in type does:
every other type's stacked label is rendered by the composed control itself, and there is no
equivalent shared "stacked label" template inside `<lr-filter-bar>` for this branch to reuse without
inventing a new one, so `labelVisibility` keeps its narrower meaning here (whether the trigger's own
baked-in label text is visible or screen-reader-only).

`field` wraps one filter's composed control and its validation spacer inside `controls`; its
flex-basis is themeable via `--lr-filter-bar-field-basis` (default `var(--lr-size-12rem)`).
`--lr-filter-bar-gap` (default `var(--lr-space-s)`) themes the gap between filter fields, the
`end` slot, the reset button, and the loading status in the `controls` row. Both are byte-identical
to the previous hardcoded values when unset.

Each field wrapper also carries a second, per-filter part token, `field-<filterId>` (for example
`part="field field-status"`), so a single field can be targeted directly --
`lr-filter-bar::part(field-status) { flex: 2 1 20rem; }` -- setting any layout property, not just a
width, while `::part(field)` rules continue to match every field unchanged. The `field-<filterId>`
token is present only when `filterId` reads as a plain CSS ident (ASCII letters/digits/`-`/`_`,
starting with a letter); an id that doesn't (for example one containing whitespace) renders `field`
alone, exactly as before this part existed, rather than risking a `part` attribute whose
space-separated token list fabricates an unrelated second token.

A `'select'`, `'combobox'` or `'checkbox-menu'` filter's required `options` entries are
`LyraFilterBarOption { value, label, icon?, searchText?, disabled? }`. `searchText` is extra text
the option also matches on, forwarded verbatim to `<lr-option>`'s own `search-text`, so a row can
keep a short visible `label` ("Urgent") while still matching a long canonical key ("SEV-1
production outage"). It affects a `'combobox'` filter only: the attribute is written on every
choice type's `<lr-option>`, but `<lr-select>`'s listbox type-ahead matches the option's `label`
alone and never reads it, and a `'checkbox-menu'` has no text entry to match against. `disabled`
marks the option non-actionable: forwarded to `<lr-option disabled>` for `'select'`/`'combobox'`
and to the composed `<lr-dropdown-item disabled>` for `'checkbox-menu'`, so the row renders
genuinely disabled (no tab/roving stop, no hover/press affordance) and arrow-key navigation already
steps past it, since that is the composed control's own existing `disabled` behavior. Omitted or
`false` renders the option exactly as before this field existed.
`icon` is optional Lit content — a status dot, a type glyph, a flag — rendered into the composed
`<lr-option>`'s own `start` slot as inert, `aria-hidden` chrome, so it never joins the option's
accessible name:

```ts
options: [
  {
    value: "open",
    label: "Open",
    icon: html`<lr-icon name="circle"></lr-icon>`,
  },
  { value: "closed", label: "Closed" },
];
```

Each filter definition's `type` selects which existing Lyra input renders it — this component
composes them and never invents a control of its own. `'select'`/`'combobox'` map to their
same-named counterparts (with `combobox`'s `multiple` opting into a multi-value filter),
`'date'`/`'date-range'` both map to `<lr-date-input>` (single vs. `mode="range"`), and `'text'` maps
to `<lr-input>` for an open-ended free-text query rather than a closed choice set. A `'text'`
filter's value is the raw query string, verbatim, and its chip shows exactly that string — the same
text the user typed, not a truncated or normalized form. `'chip'` is the one type that renders no
control at all (see **Chip-only filters** below): its value belongs to a widget elsewhere on the
page, so the bar renders only its active-filter chip and gives it no toolbar cell.

Every built-in (non-`'custom'`) filter definition additionally accepts optional `size: LyraSize`,
`icon: unknown` and `labelVisibility: 'visible' | 'hidden' | 'auto'` fields, and every one whose
composed control ships a clear action also accepts `clearable: boolean`. They are forwarded verbatim to that
control's own same-named property — `icon` into its `start` slot exactly like a choice option's own
`icon`, rendered inert and `aria-hidden`; `clearable` reaching `<lr-date-input>` under its own
`with-clear` spelling, since that control has no `clearable`. `'text'` also accepts
`inputType: LyraInputType` (forwarded to the composed `<lr-input>`'s own `type`, e.g.
`'search'`/`'email'`/`'tel'`/`'url'`), and `'combobox'` also accepts `emptyText: string` (forwarded
to its `empty-text`, the row its listbox shows when a query matches none of the declared options). A
`'custom'` definition deliberately accepts none of them: its renderer owns the control's markup
outright, so a field the bar could not forward anywhere would be inert API. Every one of these is
optional and defaults to that composed control's own default, so an existing filter definition
renders unchanged.

`labelVisibility: 'hidden'` routes the filter's `label` to the composed control's own `aria-label`
instead of rendering it as a stacked visible label, and — when the definition declares no
`placeholder` of its own — also uses it as the placeholder. The label is re-routed, never dropped,
so a compact toolbar row still names every field for assistive technology; visually hiding
`::part(filter-control-label)` in CSS, the only previous option, removed the accessible name along
with the text.

`labelVisibility: 'auto'` is the width-dependent middle between the two. It renders exactly what
`'visible'` renders — the same stacked label element, the same accessible name computed from it, no
`aria-label` and no placeholder fallback — and the bar's own stylesheet visually clips that label
once the bar's allocation drops below `30rem`. The threshold is a container query on the host, so it
reads the bar's own allocated width, not the viewport's: the same definitions render labelled across
a dashboard and unlabelled in a 320px side panel, dialog or split pane, with no host-side breakpoint
logic. The label element is never removed at any width, so the field's accessible name is identical
in both states, and `'auto'` deliberately does not route the name onto the control the way
`'hidden'` does — doing so would name a wide-allocation field twice. A `'checkbox-menu'` filter
participates through its own trigger label run, the same one `'hidden'` already clips there. The
threshold is fixed rather than themeable: a CSS container query's prelude cannot read a custom
property (`var()` is not substituted in an at-rule prelude), so a `--lr-*` hook for it would parse
and silently never apply.

`'combobox'` also accepts the same `debounce?: number` (ms) `'text'` already had: it coalesces a
burst of rapid selection changes (picks, a multi-select toggle, an
`allowCustomValue`/`allowCreate` commit, or the clear action) into one delayed commit. Unlike
`'text'`'s uncontrolled-with-sync field, the composed `<lr-combobox>`'s `.value=` binding stays
fully controlled: while a commit is pending it renders that pending selection rather than the
last-committed `value`, so the control's own display never reverts mid-delay. A pending debounce
is flushed by the control's own blur and cancelled by `reset()`, a chip removal, and
disconnection — identical to `'text'`.

### `'checkbox-menu'` filters

A `'checkbox-menu'` filter composes `<lr-dropdown>` plus one `<lr-dropdown-item type="checkbox">`
(`role="menuitemcheckbox"`) per option, behind a single toolbar trigger button. The menu stays open
across toggles, so several categories can be switched in one visit. Its value is a `string[]`,
identical to a `'combobox'` with `multiple`, so the two are interchangeable everywhere the bar's own
bookkeeping is concerned — the same `value` record, active-filter chips, `reset()` path, `required`
validation and single full-value `lr-input`. Choose between them on interaction, not on data shape:
reach for `'checkbox-menu'` when the set is small and fixed and typing to filter would only be in
the way.

Rows are controlled by `value` rather than self-toggling, so a toggle the bar refuses (a `disabled`
bar, a filter removed mid-interaction) can never leave a checkmark the bar disagrees with.

It is the one built-in type that renders no stacked label above its control: the trigger button
carries the `label` as its own text next to the selection summary, and `labelVisibility: 'hidden'`
makes that text visually hidden — never removed — so the button keeps its accessible name. In the
one case where the hidden label would be the *only* thing the button says (hidden routing, no
declared `placeholder`, nothing selected) the label routes to the visible summary instead of being
emitted twice, so the trigger's accessible name stays "Teams", never "Teams Teams".
`labelVisibility: 'auto'` clips that same trigger label run, and only below the `30rem` threshold —
the label run is always emitted under `'auto'`, since nothing is routed to the summary there.

Because its trigger is a button rather than a field, a `required` `'checkbox-menu'` deliberately
renders **no** required asterisk and sets **no** `aria-invalid`: the shared required marker has no
selector that matches a button trigger's label, and `<lr-button>` does not forward a host
`aria-invalid` onto the element that owns the button role, so writing one would be silently inert. A
revealed required error still reaches assistive technology — it joins the trigger's accessible name
as a screen-reader-only run, alongside the visible `filter-control-error` line under the field.

```ts
const filters: LyraFilterBarFilterDefinition[] = [
  {
    filterId: "teams",
    label: "Teams",
    type: "checkbox-menu",
    placeholder: "Any team",
    options: [
      { value: "core", label: "Core" },
      { value: "infra", label: "Infrastructure" },
      { value: "design", label: "Design" },
    ],
  },
];
// bar.value -> { teams: ["core", "design"] }
```

A filter bar detached and reattached while a checkbox menu is open comes back closed, like every
other transient state the bar owns.

### Date-range quick ranges

A `'date-range'` definition also accepts `presets?: readonly LyraDateRangePreset[]` (new in 12.0.0),
forwarded to its composed `<lr-date-input>` exactly like `min`/`max`, so the quick-range row
("Today", "Last 7 days", "All time") renders inside that filter's own calendar popover. Entries are
`LyraDateRangePreset { label, start?, end?, id? }` with ISO `YYYY-MM-DD` bounds; an omitted bound is
open and resolves to the filter's `min`/`max`, and an open bound with no corresponding limit renders
that button disabled. The optional `id` is a caller-owned correlation key, never read by the bar
itself -- it exists purely so `appliedPreset.id` (below) is typed without a cast. `presets` is
deliberately **not** accepted on a single `'date'` filter: a preset names two dates, so
`lr-date-picker` ignores the list outside range mode, and a list passed there is dropped rather
than rendering a row that cannot do anything.

The `lr-input` emitted by such a commit carries `appliedPreset`, the definition entry whose button
produced it — the bar's own frozen snapshot, so it compares identical to `filters[i].presets[j]`. It
is `undefined` for every other filter type and for a range picked or typed by hand. A filter bar
whose values round-trip through a query string needs it because `value` holds only the frozen ISO
range: persisting "Last 7 days" as a preset id keeps it meaning the last 7 days after the next
reload, and re-deriving it by string-matching `value` is both the mapping table `presets` exists to
delete and ambiguous (Today and This month coincide on the 1st). It rides the event rather than
`value` because it is metadata about one edit, not a filter value — `value` stays the plain,
JSON-serializable record it has always been.

```ts
const filters: LyraFilterBarFilterDefinition[] = [
  {
    filterId: "period",
    label: "Reporting period",
    type: "date-range",
    min: "2020-01-01",
    max: "2030-12-31",
    presets: [
      { label: "Last 7 days", start: "2026-08-13", end: "2026-08-19" },
      { label: "This month", start: "2026-08-01", end: "2026-08-31" },
      { label: "All time" },
    ],
  },
];

bar.addEventListener("lr-input", (event) => {
  const { value, filterId, appliedPreset } = event.detail;
  persist({ ...value, periodPreset: appliedPreset?.label });
});
```

Before this, the only way to give a filter-bar date range a quick-range row was `type: 'custom'`,
which means hand-rendering an `lr-date-input` plus a full adapter (`clearValue`, `isEmpty`,
`formatValue`) to set one property, and forfeits the built-in date-range chip localization described
next.

Date chips localize exactly one round-trip-valid ISO `YYYY-MM-DD` segment; date-range chips require
exactly two slash-separated segments. Four-digit
years `0000`–`0099` retain those literal years rather than inheriting JavaScript's 1900 offset;
impossible days/months, extra/missing segments, inverted ranges, and a range with either invalid
endpoint stay verbatim instead of silently rolling into another date or discarding data.

A `'text'` filter is the one control that is **not** a fully controlled `.value=` binding.
Re-rendering a text field from `value` mid-typing would push a stale value back in and drop the
caret to the end, so the field owns its own value while the user types, and an external `value`
write is synced back into it only once no edit is in flight (a host write, a chip removal, and
`reset()` all take that path).

`'text'` filters also accept an optional per-filter `debounce` (ms) — how long to wait after the
last keystroke before committing the typed value to `value` and emitting a single `lr-input`, so a
server-side query runs once per pause instead of once per character. Omitted, `0`, or a non-finite
value means no debounce at all: every keystroke commits immediately. A pending debounce is always
**flushed** by the field's own `change`/blur, so a blur never loses the last keystroke, and
**cancelled outright** by `reset()`, by removing that filter's chip, and on disconnect — a stale
keystroke can never overwrite a reset or fire after teardown. `'combobox'` accepts the same
`debounce`, and so does `'custom'` (see below); `debounce` is ignored for every other `type`, whose
commits are discrete choices with nothing to debounce.

### Custom controls

Use `type: 'custom'` when an existing Lyra control does not fit the built-in filter types. Provide a
`custom` object with a `render(context)` function and an `adapter`. The renderer owns the control's
markup and should bind the context's `value`, `disabled`, `required`, and `errorText` as appropriate;
`context.onValueChange` (or its `onInput`/`onChange` aliases) reads the event through
`adapter.valueFromEvent` and commits it to the filter bar. `context.setValue(value)` is available for
controls that expose a value without an event payload, and `context.onFocusout` marks the filter
touched for required validation. Every context also carries its `filterId`, a monotonic
`generation`, and an `AbortSignal`; replacement/removal of the schema, disconnection, and reconnect
abort stale contexts, whose callbacks become inert.

A `'custom'` definition also accepts the same optional `debounce?: number` (ms) `'text'`/
`'combobox'` already have: it delays committing whatever `context.onValueChange`/`onInput`/
`onChange` reads through `adapter.valueFromEvent`, coalescing a burst of rapid commits into one.
Omitted, `0`, or a non-finite value means no debounce, exactly as before this field existed. While
one is pending, `context.value` carries that pending value rather than the last-committed one, so a
renderer binding it as a fully controlled value never reverts mid-delay; a pending commit is
**flushed** by `context.onFocusout` and **cancelled outright** by `reset()`, removing that filter's
chip, and disconnect — identical to `'text'`/`'combobox'`. This is what closes the gap those two
types' own debounce left: before this field existed, a custom free-text filter had to hand-roll the
same timer, flush, and cancellation lifecycle itself just to match `'text'`.

The adapter's required `clearValue` is used when the active chip is removed. Its optional
`isEmpty(value)` defines domain emptiness; without one, the bar compares against `clearValue`
(including shallow string-array equality). Its optional `formatValue(value, locale)` controls chip
display; `locale` is the filter bar's `effectiveLocale`, the same value every built-in filter
type's own chip formatting already receives, so an existing single-argument `formatValue`
implementation keeps working unchanged — JS simply ignores a second parameter it never declared.
Consequently `false` remains a meaningful active value unless the adapter explicitly declares it
empty. Custom values may be strings, string arrays, booleans, or `undefined`, so controls such as
`lr-time-range`, `lr-checkbox`, and an async-backed `lr-combobox` can participate in the same
controlled value, active-chip, reset, disabled, and validation contract:

```ts
const filters: LyraFilterBarFilterDefinition[] = [
  {
    filterId: "archived",
    label: "Include archived",
    type: "custom",
    custom: {
      adapter: {
        valueFromEvent: (event) =>
          (event as CustomEvent<{ checked: boolean }>).detail.checked,
        clearValue: false,
        formatValue: (value) => (value === true ? "Enabled" : "Disabled"),
      },
      render: (context) => html`
        <lr-checkbox
          .checked=${context.value === true}
          ?disabled=${context.disabled}
          @lr-change=${context.onValueChange}
          @focusout=${context.onFocusout}
          >${context.label}</lr-checkbox
        >
      `,
    },
  },
];
```

The custom renderer returns a Lit `TemplateResult`; the filter bar places it in its
`filter-control` part and re-renders it whenever the controlled value or validation state changes.

### Chip-only filters

Use `type: 'chip'` when the value is already owned by a widget elsewhere on the page — a calendar
heatmap cell, a map selection, a chart brush — and the bar's job is only to *show* that the filter
is applied and to let the user take it off. Unlike `type: 'custom'`, which still renders a control
inside the toolbar, a chip-only filter renders **no control and no toolbar cell at all**: no `field`
wrapper is emitted for it, so `lr-filter-bar::part(field)` and `::part(field-<filterId>)` never
match one and a bar whose filters are *all* chip-only shows no empty column — its `controls` row
still holds the reset button (the "clear all" action such a bar needs), the `end` slot, and the
loading spinner, exactly like a bar with no filters at all.

Everything else is unchanged from any other filter type. The value lives in `value` under its own
filter ID, rides every `lr-input`/`lr-reset` detail, counts toward `hasActiveFilters` (so it enables
the reset button) and toward `invalidFilterIds` when the definition is `required`, renders a
removable active-filter chip subject to `activeFiltersDisplay`, and is cleared both by removing that
chip and by `reset()`. A `required` chip-only filter is honoured in **bookkeeping only**: it joins
`invalidFilterIds`, fails `checkValidity()` and moves `lr-validity-change`, but renders no inline
error, because the bar renders no element of its own for it — the owning widget is responsible for
its own error affordance. The inherited `placeholder` is inert here for the same reason it is for
`type: 'custom'`: there is no field to place it in.

A chip-only definition adds three optional fields of its own:

```ts
type: 'chip';
formatValue?: (value: LyraFilterBarFieldValue, locale: string) => string;
clearValue?: LyraFilterBarFieldValue;
isEmpty?: (value: LyraFilterBarFieldValue) => boolean;
```

`formatValue` produces the chip's text, and its `locale` argument is the bar's `effectiveLocale` —
the same locale every built-in type's own chip formatting and a custom adapter's `formatValue`
already receive, and the reason a chip-only value (normally an already-formatted string such as a
localized date) can be localized by the caller. That output is caller data, so — like a filter's
own label — the bar never routes it through its own localization. Omitted, the fallback ladder is
the one a custom adapter's omitted `formatValue` uses: a string array renders as a localized
conjunction list, anything else renders verbatim through `String(value)`, and an unset value renders
as the empty string. Verbatim is exact: a chip-only value is never run through the date branch that
localizes a `'date'`/`'date-range'` chip, so an ISO day is not silently reformatted and a value
containing a slash is not mangled.

`clearValue` is what a chip removal (and `clearFilter()`) writes, defaulting to the empty string —
what every non-multi built-in type writes. Declare an empty array for an array-valued chip-only
filter. `isEmpty` overrides the built-in emptiness rule (absent, `false`, the empty string and the
empty array are empty; everything else is set). **A domain sentinel must pair the two**: declaring
a sentinel clear value without a matching `isEmpty` leaves the bar reading the "cleared" value as
still set, so it keeps rendering a chip for it — the identical pairing a custom adapter's own
`clearValue`/`isEmpty` documents. With the pair declared, the sentinel is never stored in `value`
(cleared keys are omitted) and an absent key reads back as the sentinel for the owning widget.

```ts
const filters: LyraFilterBarFilterDefinition[] = [
  { filterId: "query", label: "Query", type: "text" },
  {
    filterId: "day",
    label: "Day",
    type: "chip",
    formatValue: (value, locale) =>
      new Intl.DateTimeFormat(locale, {
        dateStyle: "medium",
        timeZone: "UTC",
      }).format(new Date(`${String(value)}T00:00:00Z`)),
  },
];

// The calendar heatmap beside the bar owns the value; the bar only shows and removes it.
heatmap.addEventListener("app-select-day", (event) => {
  const { isoDate } = (event as CustomEvent<{ isoDate: string }>).detail;
  bar.value = { ...bar.value, day: isoDate };
});
bar.addEventListener("lr-input", (event) => {
  const { value } = (event as LyraFilterBarInputEvent).detail;
  heatmap.selectedDay = (value["day"] as string | undefined) ?? "";
});
```

**TypeScript:** `LyraFilterBar<Defs extends readonly LyraFilterBarFilterDefinition[] =
readonly LyraFilterBarFilterDefinition[]>` — `value` and the `lr-input`/`lr-reset` detail `value`
narrow to a record keyed per `filterId`, whose value type follows that filter's own definition (a
`'select'`, a non-`multiple` `'combobox'`, `'text'`, `'date'`, and `'date-range'` narrow to
`string`; a `'checkbox-menu'` and a `multiple: true` `'combobox'` narrow to `readonly string[]`; a
`'custom'` filter keeps the full unconstrained field value, and so does a `'chip'` filter, whose
value is owned by a widget this component never renders). Declare the schema with `as const
satisfies readonly LyraFilterBarFilterDefinition[]` and type the element as
`LyraFilterBar<typeof FILTERS>` to pick it up. Types only; the runtime is unchanged, and an untyped
`<lr-filter-bar>` keeps today's `LyraFilterBarValue` (`Readonly<Record<string, string | readonly
string[] | boolean | undefined>>`). `LyraFilterBarValueFor<Defs>` is the standalone alias for the
narrowed record, and `LyraFilterBarInputEvent<Defs>`/`LyraFilterBarResetEvent<Defs>` are stable
per-event aliases so a handler can name one event's type without restating the detail shape.
