# Filter panel

A column of filters beside a list of records, with the active filters counted, summarized as chips and cleared from one place.

```tsx
import * as React from "react"
import { SearchIcon } from "lucide-react"
import { InputGroup, InputGroupAddon, InputGroupInput } from "@zenoxai/ui/components/input-group"
import { DataTable, DataTableText, createColumnHelper, useDataTable } from "@zenoxai/ui/patterns/data-table"
import { FilterCheckboxList, FilterChips, FilterDate, FilterMultiSelect, FilterPanel, FilterPanelInset, FilterPanelProvider, FilterPanelToolbar, FilterPanelTrigger, FilterRadioList, FilterSection, defineFilterFields, filtersToSearchParams, resolveFilterDate, useFilters, type FilterValues } from "@zenoxai/ui/patterns/filter-panel"
import { SeverityBadge, severityBgClass } from "@zenoxai/ui/patterns/severity-badge"

function FilterPanelDemo() {
  type Severity = "critical" | "high" | "medium" | "low"
  type Indicator = {
    id: string
    value: string
    type: "domain" | "ip" | "url" | "hash"
    severity: Severity
    compromised: boolean
    tags: string[]
    firstSeen: string
  }

  const indicators = React.useMemo<Indicator[]>(() => {
    const domains = ["login-verify", "secure-docs", "cdn-update", "invoice-pay"]
    const tags = ["phishing", "botnet", "c2", "stealer", "ransomware", "loader"]
    const severities: Severity[] = ["critical", "high", "high", "medium", "low"]
    const types: Indicator["type"][] = ["domain", "ip", "domain", "url", "hash"]
    return Array.from({ length: 48 }, (_, index) => {
      const type = types[index % types.length]!
      const value =
        type === "ip"
          ? `203.0.113.${(index * 7) % 250}:443`
          : type === "hash"
            ? `9f2c${(index * 2654435761).toString(16).slice(0, 12)}`
            : type === "url"
              ? `https://${domains[index % 4]}.net/a/${index}`
              : `${domains[index % 4]}-${index}.com`
      return {
        id: `ioc-${index + 1}`,
        value,
        type,
        severity: severities[index % severities.length]!,
        compromised: index % 3 === 0,
        tags: [tags[index % tags.length]!, tags[(index + 2) % tags.length]!],
        firstSeen: `2026-10-${String(24 - (index % 20)).padStart(2, "0")} ${String(
          (index * 5) % 24
        ).padStart(2, "0")}:14:07`,
      }
    })
  }, [])

  const fields = React.useMemo(
    () =>
      defineFilterFields([
        {
          id: "severity",
          label: "Severity",
          kind: "multi",
          options: (["critical", "high", "medium", "low"] as const).map(
            (level) => ({
              value: level,
              label: level[0]!.toUpperCase() + level.slice(1),
              icon: (
                <span
                  className={`size-2 rounded-full ${severityBgClass[level]}`}
                />
              ),
            })
          ),
        },
        {
          id: "type",
          label: "Indicator type",
          kind: "single",
          options: [
            { value: "domain", label: "Domain" },
            { value: "ip", label: "IP:Port" },
            { value: "url", label: "URL" },
            { value: "hash", label: "SHA-256" },
          ],
        },
        { id: "compromised", label: "Compromised", kind: "boolean" },
        {
          id: "firstSeen",
          label: "First seen",
          kind: "date",
          presets: [
            { value: "today", label: "Today", days: 1 },
            { value: "7d", label: "Last 7 days", days: 7 },
            { value: "30d", label: "Last 30 days", days: 30 },
          ],
        },
        {
          id: "tags",
          label: "Tags",
          kind: "multi",
          options: [
            "phishing",
            "botnet",
            "c2",
            "stealer",
            "ransomware",
            "loader",
          ].map((tag) => ({ value: tag, label: tag })),
        },
      ]),
    []
  )

  // In an app, the value lives in the URL: filtersFromSearchParams and
  // filtersToSearchParams read and write it.
  const [value, setValue] = React.useState<FilterValues<typeof fields>>({
    severity: ["critical", "high"],
  })
  const [search, setSearch] = React.useState("")
  const filters = useFilters({ fields, value, onValueChange: setValue })

  // Presets stay relative in the value; resolve them with today's date in
  // the profile's time zone (filterDateToday), fixed here for the example.
  const seen = resolveFilterDate(value.firstSeen, {
    presets: fields[3].presets,
    today: "2026-10-24",
  })
  const seenFrom = seen?.from
  const seenTo = seen?.to

  // A row passes every field except `skip`: counts are disjunctive, so a
  // facet's counts ignore its own selection.
  const matches = React.useCallback(
    (row: Indicator, skip?: string) =>
      (!seenFrom || row.firstSeen.slice(0, 10) >= seenFrom) &&
      (!seenTo || row.firstSeen.slice(0, 10) <= seenTo) &&
      (skip === "severity" ||
        !value.severity ||
        value.severity.includes(row.severity)) &&
      (skip === "type" || !value.type || row.type === value.type) &&
      (skip === "compromised" ||
        value.compromised === undefined ||
        row.compromised === value.compromised) &&
      (skip === "tags" ||
        !value.tags ||
        value.tags.some((tag) => row.tags.includes(tag))) &&
      row.value.toLowerCase().includes(search.trim().toLowerCase()),
    [value, search, seenFrom, seenTo]
  )
  const countsFor = (field: "severity" | "type") => {
    const counts: Record<string, number> = {}
    for (const row of indicators) {
      if (matches(row, field))
        counts[row[field]] = (counts[row[field]] ?? 0) + 1
    }
    return counts
  }
  const rows = React.useMemo(
    () => indicators.filter((row) => matches(row)),
    [indicators, matches]
  )

  const columns = React.useMemo(() => {
    const column = createColumnHelper<Indicator>()
    return [
      column.accessor("value", {
        header: "Indicator",
        meta: { fluid: true, minWidth: 160 },
        cell: (info) => (
          <DataTableText mono strong>
            {info.getValue()}
          </DataTableText>
        ),
      }),
      column.accessor("severity", {
        header: "Severity",
        size: 100,
        cell: (info) => <SeverityBadge level={info.getValue()} />,
      }),
      column.accessor("firstSeen", {
        header: "First seen",
        size: 170,
        sortDescFirst: true,
        cell: (info) => (
          <DataTableText numeric>{info.getValue()}</DataTableText>
        ),
      }),
    ]
  }, [])
  const table = useDataTable({
    data: rows,
    columns,
    getRowId: (row) => row.id,
    defaultSorting: [{ id: "firstSeen", desc: true }],
  })

  return (
    <div className="flex h-[520px] w-full">
      {/* The docs column is narrow, so the sheet starts below 40rem here; an
          app keeps the default 60rem. */}
      <FilterPanelProvider
        filters={filters}
        total={rows.length}
        sheetBelow={40}
      >
        <FilterPanel>
          <FilterSection field="severity">
            <FilterCheckboxList
              field="severity"
              counts={countsFor("severity")}
            />
          </FilterSection>
          <FilterSection field="type">
            <FilterRadioList
              field="type"
              allLabel="All types"
              counts={countsFor("type")}
            />
          </FilterSection>
          <FilterSection field="compromised">
            <FilterRadioList field="compromised" />
          </FilterSection>
          <FilterSection field="firstSeen">
            <FilterDate field="firstSeen" />
          </FilterSection>
          <FilterSection field="tags">
            <FilterMultiSelect field="tags" placeholder="Search tags" />
          </FilterSection>
        </FilterPanel>
        <FilterPanelInset>
          <FilterPanelToolbar>
            <FilterPanelTrigger />
            <InputGroup size="sm" className="max-w-64">
              <InputGroupAddon>
                <SearchIcon />
              </InputGroupAddon>
              <InputGroupInput
                type="search"
                value={search}
                onChange={(event) => setSearch(event.target.value)}
                placeholder="Search indicators"
                aria-label="Search indicators"
              />
            </InputGroup>
            <span className="ms-auto text-sm text-text-secondary tabular-nums">
              {rows.length} indicators
            </span>
          </FilterPanelToolbar>
          <FilterChips />
          <DataTable
            table={table}
            label="Indicators of compromise"
            height="fill"
            total={rows.length}
            resultKey={JSON.stringify({ value, search })}
          />
        </FilterPanelInset>
      </FilterPanelProvider>
    </div>
  )
}
```

## Usage

```tsx
import {
  FilterCheckboxList,
  FilterChips,
  FilterPanel,
  FilterPanelInset,
  FilterPanelProvider,
  FilterPanelToolbar,
  FilterPanelTrigger,
  FilterSection,
  defineFilterFields,
  filtersFromSearchParams,
  filtersToSearchParams,
  useFilters,
} from "@zenoxai/ui/patterns/filter-panel"

const fields = defineFilterFields([
  { id: "severity", label: "Severity", kind: "multi", options: severityOptions },
  { id: "compromised", label: "Compromised", kind: "boolean" },
])

export function AlertList() {
  const [params, setParams] = useSearchParams()
  const value = React.useMemo(
    () => filtersFromSearchParams(fields, params),
    [params]
  )
  const filters = useFilters({
    fields,
    value,
    onValueChange: (next) =>
      setParams(filtersToSearchParams(fields, next, params), { replace: true }),
  })
  return (
    <FilterPanelProvider filters={filters} open={open} onOpenChange={setOpen}>
      <FilterPanel>
        <FilterSection field="severity">
          <FilterCheckboxList field="severity" counts={severityCounts} />
        </FilterSection>
      </FilterPanel>
      <FilterPanelInset>
        <FilterPanelToolbar>
          <FilterPanelTrigger />
        </FilterPanelToolbar>
        <FilterChips />
        <DataTable
          table={table}
          label="Alerts"
          height="fill"
          total={total}
          resultKey={filtersToSearchParams(fields, value).toString()}
        />
      </FilterPanelInset>
    </FilterPanelProvider>
  )
}
```

Use a filter panel for a list with four or more facets, or with ranges and dates. For one to
three facets, put the same checkbox list in a Popover in the toolbar
([example](#toolbar-facets)). Decide which filters a list needs with the
[filtering guideline](/guidelines/filtering).

- **Declare the fields once.** Each field has an `id` (its key in the value and the URL), a
  translated `label` and a `kind`: `multi` (several values, combined with OR), `single` (one
  value), `boolean` (yes, no or no filter), `range` (a floor, a ceiling or both), `date` (a
  preset or custom dates), `text` (free-text values) or `custom` (a value the panel cannot show,
  such as a filter tree from an advanced editor, summarized by your `formatValue`). Define them
  outside the component, or in a `useMemo` when the labels are translated there.
- **The value belongs to the app.** `useFilters` holds no state: pass the value, usually read from
  the URL with `filtersFromSearchParams`, and write every change back with
  `filtersToSearchParams`, which keeps the other params (search, tab) and replaces nothing else.
  Values outside a field's `options` are kept, since a link may hold one; mark the field
  `strict` to drop them. Replace the history entry on a filter change, and debounce the query:
  arrow keys select radios as they move.
- **Count, chips and Clear come from the fields.** The panel header counts the fields with a
  filter, never the values. A field at its default still counts and has a chip, so a list
  narrowed by a default never looks complete. Clear returns every field to its default, and reads
  "Reset" when some field has one. The search box and status tabs are not fields: Clear never
  touches them.
- **Counts are yours.** Pass `counts` from your facets endpoint, computed disjunctively (a
  facet's counts ignore its own selection). Counts are exact, through `labels.formatCount`.
  Options keep their given order unless you pass `order="count"`; `hideZero` hides values with
  no results, except selected ones.
- **Dates stay relative.** A preset is stored as itself (`firstSeen=7d`), so a link still means
  the last seven days tomorrow. Turn the value into dates for your query with
  `resolveFilterDate`, passing today from `filterDateToday` in the profile's time zone. Custom
  dates are `YYYY-MM-DD` as picked; leave one end open for "on or after" or "on or before".
- **Open and closed.** `open` is yours to persist. Closed, the panel takes no width and its
  content is inert, and FilterChips shows the active filters under the toolbar. Inside a detail
  sheet or a dialog, use `collapsible="none"`: the panel is always shown and there is no trigger.
- **Narrow layouts.** Below 60rem of provider width the panel moves into a sheet that the trigger
  opens. The sheet starts closed and is never persisted; its "Show 1,204 results" button (pass
  `total`) closes it. `sheetBelow` moves the breakpoint, or keeps the panel beside the results
  with `false`.
- **The table fills the rest.** Give the provider a bounded height (a flex column) and the
  DataTable `height="fill"`. Its `total` is announced to screen readers when the rows settle after
  the total or `resultKey` changes; pass the filters and search as the key, so a filter that
  leaves the total unchanged is still heard, and sorting or paging is not. Show the number on
  screen yourself, as in the toolbar above.
- **Texts.** Every text of the panel is in `labels` on the provider, or on a control or
  FilterChips used outside one, in English by default. The texts of values go in
  `useFilters({ labels })`: Yes and No, the "+N" of a chip, ranges ("7 or more") and
  `formatNumber` and `formatDate`, which the chips and the date buttons share. Pass
  FilterNumberRange the same `locale`. Pass your translations, labels with numbers as functions,
  and keep the objects stable, as with the fields.

### Controls

| Control              | Field kind          | For                                                                                                                                                                             |
| -------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `FilterCheckboxList` | `multi`             | Several values of a facet, with counts. Five rows, then "Show N more" when three or more would hide; a search box above eight options.                                          |
| `FilterRadioList`    | `single`, `boolean` | One value, or yes and no. A real radio group, with a first "All" row that removes the filter.                                                                                   |
| `FilterMultiSelect`  | `multi`             | Values from a list too long to show (users, entities, tags), as chips in a search input. Pass `onInputValueChange` to load options from the API.                                |
| `FilterNumberRange`  | `range`             | A floor, a ceiling or both (`show`). Commits on Enter and blur, not per keystroke or arrow step. For thresholds people pick from a few values, a radio list of presets instead. |
| `FilterDate`         | `date`              | Presets first, then custom dates in the Calendar, either end open.                                                                                                              |
| `FilterTokenInput`   | `text`              | Free-text values as chips: Enter, a comma or leaving the input adds one; the field's `normalize` cleans it.                                                                     |
| `FilterSwitch`       | `boolean`           | An on/off filter on a `trueOnly` field: on keeps the matching records, off removes the filter. Group several in one section with `fields` and a `title`.                        |

## Examples

### Ranges, dates, free text and switches

The other field kinds, in a panel that stays open, with the URL params they write.

```tsx
import * as React from "react"
import { FilterDate, FilterNumberRange, FilterPanel, FilterPanelProvider, FilterSection, FilterSwitch, FilterTokenInput, defineFilterFields, filtersToSearchParams, useFilters, type FilterValues } from "@zenoxai/ui/patterns/filter-panel"

function FilterPanelMoreControls() {
  const fields = React.useMemo(
    () =>
      defineFilterFields([
        {
          id: "cvss",
          label: "CVSS",
          kind: "range",
          min: 0,
          max: 10,
          step: 0.1,
        },
        {
          id: "published",
          label: "Published",
          kind: "date",
          presets: [
            { value: "7d", label: "Last 7 days", days: 7 },
            { value: "30d", label: "Last 30 days", days: 30 },
          ],
        },
        {
          id: "country",
          label: "Country",
          kind: "text",
          normalize: (country) => country.trim().toUpperCase(),
        },
        {
          id: "kev",
          label: "Known exploited",
          kind: "boolean",
          trueOnly: true,
        },
        {
          id: "internet",
          label: "Internet-facing only",
          kind: "boolean",
          trueOnly: true,
        },
      ]),
    []
  )
  const [value, setValue] = React.useState<FilterValues<typeof fields>>({
    cvss: { min: 7 },
    country: ["BR"],
  })
  const filters = useFilters({ fields, value, onValueChange: setValue })
  const params = filtersToSearchParams(fields, value).toString()

  return (
    <div className="flex w-full flex-col gap-3">
      <div className="flex h-[540px] w-72">
        <FilterPanelProvider filters={filters} collapsible="none">
          <FilterPanel>
            <FilterSection field="cvss" description="Base score, 0 to 10.">
              <FilterNumberRange field="cvss" />
            </FilterSection>
            <FilterSection field="published">
              <FilterDate field="published" />
            </FilterSection>
            <FilterSection field="country">
              <FilterTokenInput field="country" placeholder="Add a code" />
            </FilterSection>
            <FilterSection fields={["kev", "internet"]} title="Exposure">
              <FilterSwitch
                field="kev"
                description="In the CISA catalog of exploited vulnerabilities."
              />
              <FilterSwitch field="internet" />
            </FilterSection>
          </FilterPanel>
        </FilterPanelProvider>
      </div>
      <p className="text-sm text-text-secondary">
        URL:{" "}
        <code className="font-mono">{params ? `?${params}` : "(none)"}</code>
      </p>
    </div>
  )
}
```

### On a narrow layout

Below 60rem the panel is a sheet: the trigger opens it and the chips summarize the filters
while it is closed.

```tsx
import * as React from "react"
import { FilterCheckboxList, FilterChips, FilterPanel, FilterPanelInset, FilterPanelProvider, FilterPanelToolbar, FilterPanelTrigger, FilterSection, FilterSwitch, defineFilterFields, useFilters, type FilterValues } from "@zenoxai/ui/patterns/filter-panel"

function FilterPanelNarrow() {
  const fields = React.useMemo(
    () =>
      defineFilterFields([
        {
          id: "status",
          label: "Status",
          kind: "multi",
          options: [
            { value: "open", label: "Open" },
            { value: "investigating", label: "Investigating" },
            { value: "resolved", label: "Resolved" },
          ],
        },
        {
          id: "assigned",
          label: "Assigned to me",
          kind: "boolean",
          trueOnly: true,
        },
      ]),
    []
  )
  const [value, setValue] = React.useState<FilterValues<typeof fields>>({
    status: ["open", "investigating"],
  })
  const filters = useFilters({ fields, value, onValueChange: setValue })
  const alerts = [
    {
      id: "ALR-1043",
      title: "Credential stuffing on vpn.acme.com",
      status: "open",
    },
    {
      id: "ALR-1042",
      title: "Beacon to 185.220.101.47",
      status: "investigating",
    },
    {
      id: "ALR-1039",
      title: "Phishing kit on login-verify-acme.com",
      status: "resolved",
    },
  ].filter((alert) => !value.status || value.status.includes(alert.status))

  return (
    <div className="flex h-64 w-full max-w-xl">
      <FilterPanelProvider filters={filters} total={alerts.length}>
        <FilterPanel>
          <FilterSection field="status">
            <FilterCheckboxList field="status" />
          </FilterSection>
          <FilterSection fields={["assigned"]} title="Assignment">
            <FilterSwitch field="assigned" />
          </FilterSection>
        </FilterPanel>
        <FilterPanelInset>
          <FilterPanelToolbar>
            <FilterPanelTrigger />
          </FilterPanelToolbar>
          <FilterChips />
          <ul className="divide-y divide-border text-sm">
            {alerts.map((alert) => (
              <li key={alert.id} className="flex gap-3 px-3 py-2">
                <span className="font-mono text-text-secondary">
                  {alert.id}
                </span>
                <span className="truncate">{alert.title}</span>
              </li>
            ))}
          </ul>
        </FilterPanelInset>
      </FilterPanelProvider>
    </div>
  )
}
```

### Inside a detail view

`collapsible="none"` for a panel that is always shown, such as the related indicators in a
detail sheet.

```tsx
import * as React from "react"
import { FilterPanel, FilterPanelInset, FilterPanelProvider, FilterRadioList, FilterSection, defineFilterFields, useFilters, type FilterValues } from "@zenoxai/ui/patterns/filter-panel"

function FilterPanelAlwaysOpen() {
  const fields = React.useMemo(
    () =>
      defineFilterFields([
        {
          id: "relation",
          label: "Relation",
          kind: "single",
          options: [
            { value: "resolves", label: "Resolves to" },
            { value: "downloads", label: "Downloads" },
            { value: "contacts", label: "Contacts" },
          ],
        },
      ]),
    []
  )
  const [value, setValue] = React.useState<FilterValues<typeof fields>>({})
  const filters = useFilters({ fields, value, onValueChange: setValue })
  const related = [
    { value: "185.220.101.47:443", relation: "resolves" },
    { value: "update-check.net/payload.bin", relation: "downloads" },
    { value: "91.92.240.18:8080", relation: "contacts" },
    { value: "45.137.21.9:4444", relation: "contacts" },
  ].filter((item) => !value.relation || item.relation === value.relation)

  return (
    <div className="flex h-72 w-full max-w-2xl">
      <FilterPanelProvider filters={filters} collapsible="none">
        <FilterPanel title="Related">
          <FilterSection field="relation">
            <FilterRadioList
              field="relation"
              counts={{ resolves: 1, downloads: 1, contacts: 2 }}
            />
          </FilterSection>
        </FilterPanel>
        <FilterPanelInset>
          <ul className="divide-y divide-border text-sm">
            {related.map((item) => (
              <li key={item.value} className="px-3 py-2 font-mono">
                {item.value}
              </li>
            ))}
          </ul>
        </FilterPanelInset>
      </FilterPanelProvider>
    </div>
  )
}
```

### Toolbar facets

For one to three facets, the checkbox list goes in a Popover, with `filters` passed directly,
and FilterChips under the toolbar. `returnFocusRef` tells FilterChips where focus goes once the
last chip is removed.

```tsx
import * as React from "react"
import { ListFilterIcon } from "lucide-react"
import { Button } from "@zenoxai/ui/components/button"
import { Popover, PopoverContent, PopoverTrigger } from "@zenoxai/ui/components/popover"
import { FilterCheckboxList, FilterChips, defineFilterFields, useFilters, type FilterValues } from "@zenoxai/ui/patterns/filter-panel"

function FilterPanelToolbarFacet() {
  const fields = React.useMemo(
    () =>
      defineFilterFields([
        {
          id: "status",
          label: "Status",
          kind: "multi",
          options: [
            { value: "open", label: "Open" },
            { value: "investigating", label: "Investigating" },
            { value: "resolved", label: "Resolved" },
          ],
        },
      ]),
    []
  )
  const [value, setValue] = React.useState<FilterValues<typeof fields>>({
    status: ["open"],
  })
  const filters = useFilters({ fields, value, onValueChange: setValue })
  const statusButton = React.useRef<HTMLButtonElement>(null)

  return (
    <div className="flex w-full max-w-xl flex-col overflow-hidden rounded-lg border border-border">
      <div className="flex h-12 items-center gap-2 border-b border-border px-2.5">
        <Popover>
          <PopoverTrigger
            render={
              <Button ref={statusButton} variant="outline" size="sm">
                <ListFilterIcon data-icon="inline-start" />
                Status
              </Button>
            }
          />
          <PopoverContent align="start" className="w-56 p-2">
            <FilterCheckboxList
              field="status"
              filters={filters}
              counts={{ open: 12, investigating: 4, resolved: 31 }}
            />
          </PopoverContent>
        </Popover>
      </div>
      <FilterChips filters={filters} returnFocusRef={statusButton} />
      <p className="px-3 py-6 text-sm text-text-secondary">
        {filters.activeCount > 0
          ? `Alerts with status ${filters.formatValue("status")}`
          : "All alerts"}
      </p>
    </div>
  )
}
```

### Counts that load or fail

While counts refresh, the rows stay in place and the counts dim; on the first load, skeleton rows
take their place. When the counts fail, the options stay without counts, with a retry: a group
never disappears with the filters it holds.

```tsx
import * as React from "react"
import { ToggleGroup, ToggleGroupItem } from "@zenoxai/ui/components/toggle-group"
import { FilterCheckboxList, FilterPanel, FilterPanelProvider, FilterSection, defineFilterFields, useFilters, type FilterValues } from "@zenoxai/ui/patterns/filter-panel"

function FilterPanelCountStates() {
  const fields = React.useMemo(
    () =>
      defineFilterFields([
        {
          id: "source",
          label: "Source",
          kind: "multi",
          options: [
            { value: "edr", label: "EDR" },
            { value: "email", label: "Email gateway" },
            { value: "firewall", label: "Firewall" },
          ],
        },
      ]),
    []
  )
  const [value, setValue] = React.useState<FilterValues<typeof fields>>({
    source: ["email"],
  })
  const [state, setState] = React.useState("loaded")
  const filters = useFilters({ fields, value, onValueChange: setValue })

  return (
    <div className="flex w-full flex-col gap-3">
      <ToggleGroup
        variant="segmented"
        size="sm"
        value={[state]}
        onValueChange={(next) => {
          if (next[0]) setState(next[0])
        }}
        aria-label="Count state"
      >
        <ToggleGroupItem value="loaded">Loaded</ToggleGroupItem>
        <ToggleGroupItem value="loading">Refreshing</ToggleGroupItem>
        <ToggleGroupItem value="error">Failed</ToggleGroupItem>
      </ToggleGroup>
      <div className="flex h-56 w-72">
        <FilterPanelProvider filters={filters} collapsible="none">
          <FilterPanel>
            <FilterSection field="source">
              <FilterCheckboxList
                field="source"
                counts={{ edr: 1204, email: 318, firewall: 77 }}
                loading={state === "loading"}
                error={state === "error"}
                onRetry={() => setState("loaded")}
              />
            </FilterSection>
          </FilterPanel>
        </FilterPanelProvider>
      </div>
    </div>
  )
}
```

### Options from the API

`FilterMultiSelect` with `onInputValueChange` shows the options as they arrive, and the field's
`getLabel` names a selected value that the current search did not load, such as one from a link.

```tsx
import * as React from "react"
import { FilterMultiSelect, FilterPanel, FilterPanelProvider, FilterSection, defineFilterFields, useFilters, type FilterValues } from "@zenoxai/ui/patterns/filter-panel"

function FilterPanelAsyncOptions() {
  const analysts = React.useMemo(
    () =>
      [
        "Dana Whitfield",
        "Marcus Oyelaran",
        "Priya Raman",
        "Tomás Lindqvist",
        "Aiko Tanaka",
        "Rafael Souza",
        "Ingrid Haugen",
        "Kwame Mensah",
      ].map((name, index) => ({ value: `u-${index + 1}`, label: name })),
    []
  )
  const fields = React.useMemo(
    () =>
      defineFilterFields([
        {
          id: "assignee",
          label: "Assignee",
          kind: "multi",
          // Names a selected value the current search did not load.
          getLabel: (id) =>
            analysts.find((analyst) => analyst.value === id)?.label,
        },
      ]),
    [analysts]
  )
  const [value, setValue] = React.useState<FilterValues<typeof fields>>({
    assignee: ["u-1"],
  })
  const [options, setOptions] = React.useState(analysts.slice(0, 5))
  const [loading, setLoading] = React.useState(false)
  const filters = useFilters({ fields, value, onValueChange: setValue })

  // Stands in for a request to the users API.
  const search = (query: string) => {
    setLoading(true)
    window.setTimeout(() => {
      const needle = query.trim().toLowerCase()
      setOptions(
        analysts.filter((analyst) =>
          analyst.label.toLowerCase().includes(needle)
        )
      )
      setLoading(false)
    }, 300)
  }

  return (
    <div className="flex h-48 w-72">
      <FilterPanelProvider filters={filters} collapsible="none">
        <FilterPanel>
          <FilterSection field="assignee">
            <FilterMultiSelect
              field="assignee"
              options={options}
              loading={loading}
              onInputValueChange={search}
              placeholder="Search analysts"
            />
          </FilterSection>
        </FilterPanel>
      </FilterPanelProvider>
    </div>
  )
}
```

## Accessibility

- The panel is a region named by its title. Each section's control is a group named by the
  section title: a checkbox list is a `group`, a radio list is the `radiogroup` itself, with one
  tab stop and arrow keys between the options.
- Each option is named by its label; its count and description are its description.
- The trigger is always named "Filters", with `aria-expanded` and `aria-controls`; the number of
  active filters is its description. Closing the panel with focus inside it moves focus to the
  trigger.
- A folded section with a filter says how many values it holds, and unfolds when its field
  becomes active after the first render.
- Removing a chip moves focus to the next chip or to Clear filters. Each remove button names its
  field ("Remove Severity filter").
- DataTable announces `total` once the rows settle, debounced, in its own status region.
- In the sheet, the panel is a dialog named by its title: focus moves into it, Escape and "Show
  results" close it, and focus returns to the trigger. Crossing back past the breakpoint closes
  it.
- A range is a group named by its section, with inputs named Minimum and Maximum. Each date
  button is named by its end and its date ("From Oct 1, 2026"). The free-text input is named by
  its section. Removing a value or clearing a date keeps focus in the control.

## API reference

`import { ... } from "@zenoxai/ui/patterns/filter-panel"`

### FilterCheckboxList

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| counts | `Partial<Record<string, number>>` |  | The number of results per value; leave it out where the API has no counts. |
| error | `string \| boolean` |  | The counts failed to load: the options stay, without counts, with a retry. |
| field (required) | `string` |  | The id of the field. |
| filters | `FiltersState` |  | The filters from useFilters, when there is no FilterPanelProvider around. |
| hideZero | `boolean` | `false` | Hides options whose count is 0. A selected option always shows. |
| labels | `Partial<FilterPanelLabels>` |  | Replaces the texts, outside a FilterPanelProvider or over its labels. |
| loading | `boolean` | `false` | Counts are loading. With no options yet, skeleton rows; otherwise the rows stay in place and the counts dim. |
| maxVisible | `number` | `5` | Rows before "Show N more". Defaults to 5; the list caps only when 3 or more would hide. |
| onRetry | `(() => void)` |  |  |
| options | `readonly FilterOption[]` |  | The options to list. Defaults to the field's `options`. |
| order | `"given" \| "count"` | `given` | `given` (default): the options' own order, for ranked or small sets (severity, priority). `count`: the largest count first, for open sets. |
| searchThreshold | `number` | `8` | Options above which a search box shows. Defaults to 8. |

### FilterMultiSelect

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| emptyLabel | `string` |  | When nothing matches. Defaults to "No matches". |
| field (required) | `string` |  | The id of a `multi` field. |
| filters | `FiltersState` |  | The filters from useFilters, when there is no FilterPanelProvider around. |
| labels | `Partial<FilterPanelLabels>` |  | Replaces the texts, outside a FilterPanelProvider or over its labels. |
| loading | `boolean` | `false` | Options are loading, such as after a keystroke in an async search. |
| onInputValueChange | `((query: string) => void)` |  | Receives the text typed in the input, to load matching options from the API. When given, the list shows `options` as they are, unfiltered. |
| options | `readonly FilterOption[]` |  | The options matching the current input. Defaults to the field's `options`. |
| placeholder | `string` |  | Already-translated placeholder of the input. Defaults to "Search". |

### FilterRadioList

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| allLabel | `string` |  | Replaces the label of that row ("All"), such as "All types". |
| counts | `Partial<Record<string, number>>` |  | The number of results per value; leave it out where the API has no counts. |
| field (required) | `string` |  | The id of the field. |
| filters | `FiltersState` |  | The filters from useFilters, when there is no FilterPanelProvider around. |
| hideZero | `boolean` | `false` | Hides options whose count is 0. A selected option always shows. |
| labels | `Partial<FilterPanelLabels>` |  | Replaces the texts, outside a FilterPanelProvider or over its labels. |
| maxVisible | `number` | `5` | Rows before "Show N more". Defaults to 5; the list caps only when 3 or more would hide. |
| options | `readonly FilterOption[]` |  | The options to list. Defaults to the field's `options`. |
| order | `"given" \| "count"` | `given` | `given` (default): the options' own order, for ranked or small sets (severity, priority). `count`: the largest count first, for open sets. |
| showAll | `boolean` | `true` | Shows a first row that removes the filter. Defaults to true. |

### formatFilterDate

No props of its own.

### parseFilterDate

No props of its own.

### FilterDate

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| allLabel | `string` |  | Replaces the label of that row ("Any time"). |
| field (required) | `string` |  | The id of the field. |
| filters | `FiltersState` |  | The filters from useFilters, when there is no FilterPanelProvider around. |
| labels | `Partial<FilterPanelLabels>` |  | Replaces the texts, outside a FilterPanelProvider or over its labels. |
| showAll | `boolean` | `true` | Shows a first row that removes the filter. Defaults to true. |

### FilterNumberRange

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| field (required) | `string` |  | The id of the field. |
| filters | `FiltersState` |  | The filters from useFilters, when there is no FilterPanelProvider around. |
| format | `NumberFormatOptions` |  | Number format of the inputs, such as a fixed number of decimals. |
| labels | `Partial<FilterPanelLabels>` |  | Replaces the texts, outside a FilterPanelProvider or over its labels. |
| locale | `LocalesArgument` | `en-US` | The locale the inputs parse and show numbers in. Defaults to "en-US"; pass the app's, the same as `formatNumber` in useFilters. |
| show | `"both" \| "min" \| "max"` | `both` | Which ends to offer: `both` (default), only `min` (a floor) or only `max`. |

### FilterSwitch

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| description | `ReactNode` |  | A sentence under the label, linked to the switch as its description. |
| field (required) | `string` |  | The id of the field. |
| filters | `FiltersState` |  | The filters from useFilters, when there is no FilterPanelProvider around. |
| label | `string` |  | Replaces the field's label beside the switch. |
| labels | `Partial<FilterPanelLabels>` |  | Replaces the texts, outside a FilterPanelProvider or over its labels. |

### FilterTokenInput

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| field (required) | `string` |  | The id of the field. |
| filters | `FiltersState` |  | The filters from useFilters, when there is no FilterPanelProvider around. |
| labels | `Partial<FilterPanelLabels>` |  | Replaces the texts, outside a FilterPanelProvider or over its labels. |
| placeholder | `string` |  | Already-translated placeholder of the input. Defaults to "Add a value". |
| suggestions | `readonly string[]` |  | Values to suggest while typing, such as known country codes. |

### FilterChips

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| filters | `FiltersState` |  | The filters from useFilters, when there is no FilterPanelProvider around. |
| labels | `Partial<FilterPanelLabels>` |  | Replaces the texts, outside a FilterPanelProvider or over its labels. |
| returnFocusRef | `RefObject<HTMLElement \| null>` |  | Where focus goes once no chip is left, such as the toolbar button that opens the facets. Defaults to the panel's trigger or its first section. |

### FilterPanel

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| title | `string` |  | Replaces the title from the labels ("Filters"). |

### FilterPanelInset

No props of its own.

### FilterPanelProvider

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| collapsible | `"none" \| "offcanvas"` | `offcanvas` | `offcanvas` (default): the trigger hides and shows the panel. `none`: the panel is always shown and there is no trigger, for a panel inside a detail sheet or a dialog. |
| defaultOpen | `boolean` | `true` | The starting `open` when uncontrolled. Defaults to true. |
| filters (required) | `FiltersState` |  | The filters from useFilters. |
| labels | `Partial<FilterPanelLabels>` |  | Replaces the default English texts. Keep the object stable: define it outside the component or in a useMemo, as with the fields. |
| onOpenChange | `((open: boolean) => void)` |  |  |
| open | `boolean` |  | Whether the panel is shown. Controlled; the app persists it. |
| sheetBelow | `number \| false` | `60` | The provider width, in rem, below which the panel moves into a sheet. Defaults to 60 (960px), which the CSS applies from the first paint; any other value applies once the width is measured. `false` keeps the panel beside the results. |
| total | `number` |  | The number of results, for the "Show 1,204 results" button of the sheet. |

### FilterPanelToolbar

No props of its own.

### FilterPanelTrigger

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| focusableWhenDisabled | `boolean` | `false` | Whether the button should be focusable when disabled. |
| nativeButton | `boolean` | `true` | Whether the component renders a native `<button>` element when replacing it via the `render` prop. Set to `false` if the rendered element is not a button (for example, `<div>`). |
| render | `ReactElement<unknown, string \| JSXElementConstructor<any>> \| ComponentRenderFn<HTMLProps, ButtonState>` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component.  Accepts a `ReactElement` or a function that returns the element to render. |
| size | `"default" \| "xs" \| "sm" \| "lg" \| "icon" \| "icon-xs" \| "icon-sm" \| "icon-lg" \| null` |  | Height: `xs` 24px, `sm` 28px, `default` 32px, `lg` 36px. The `icon-*` sizes are square buttons of the same heights for a single icon. The icon follows the size name: 12px at `xs`, 14px at `sm`, 16px at `default` and `lg`. |
| variant | `"link" \| "default" \| "destructive" \| "outline" \| "secondary" \| "ghost" \| null` |  | Visual weight. `default` is the filled primary action, one per view; `outline` and `secondary` are for the other actions; `ghost` has no fill until hover, for toolbars and icon buttons; `destructive` is for delete and other irreversible actions; `link` looks like inline text. |

### FilterSection

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| defaultOpen | `boolean` | `true` | Open on first render. Defaults to true. |
| description | `ReactNode` |  | One or two sentences under the title: what the counts mean, a unit. |
| field | `string` |  | The id of the field the section filters. |
| fields | `readonly string[]` |  | The ids of several fields under one title, such as a few switches. Give the section a `title`. |
| title | `string` |  | Replaces the field's label as the title. |

### useFilterControl

No props of its own.

### useFilterPanel

No props of its own.

### useFilterSection

No props of its own.

### defineFilterFields

No props of its own.

### filterDateToday

No props of its own.

### filtersFromSearchParams

No props of its own.

### filtersToSearchParams

No props of its own.

### resolveFilterDate

No props of its own.

### useFilters

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| fields (required) | `readonly FilterField<string>[]` |  | The fields, declared once (outside the component or in a useMemo). |
| labels | `Partial<FilterValueLabels>` |  | Texts of chip values: yes and no, ranges, dates, the overflow. Keep the object stable, as with the fields. |
| onValueChange (required) | `(value: FilterValues<TFields>) => void` |  | Receives the next filters; write them to the URL with filtersToSearchParams. |
| value (required) | `FilterValues<TFields>` |  | The current filters, usually read from the URL with filtersFromSearchParams. |

