component — display

Filter Bar

The filter model with no table under it: one pill per field above a list, a gallery or a card grid, collapsing to a counted button and a sheet below md. An inactive pill is an outline chip naming its field; an active one fills and states its own value, so the row is both the control and the summary. Fields that do not fit wait behind a + Filter menu, and a Reset appears once anything is set. It ships the two parts the table family already had — FilterPanel, a Conform form over all five variants (text, number, date, boolean, enum) that commits either through a Clear/Apply pair or live on every change, and FilterChips, the removable active-filter strip — which table-controls re-exports as TableFilterPanel and TableFilterChips. Controlled: it takes the values and reports the next set, and filterRows and facetCounts in lib/data-table are the two lines that turn them into rows.

  • filter
  • facet
  • search
  • list
  • sheet
  • chips
  • conform
  • interactive

04 examples

A list with filters

One pill per field above a card grid, with faceted counts and a search box in the bar's own start slot.

10 of 10 kiosks

Atrium NorthAtrium · 249 €live
Atrium SouthAtrium · 249 €live
Lobby DeskLobby · 189 €draft
Lobby WindowLobby · 189 €offline
Garage EntryGarage · 129 €live
Garage ExitGarage · 129 €offline
Rooftop BarRooftop · 399 €draft
Rooftop TerraceRooftop · 399 €live
Foyer EastFoyer · 209 €live
Foyer WestFoyer · 209 €draft
Four pills, then + Filter for the two that did not fit. An inactive pill is an outline chip naming its field; an active one fills and states its own value, so the row is both the control and the summary — there is no second chip strip to keep in sync. Narrow the window past 768px and the same component redraws itself as the sheet below.

Collapsed to a sheet

The same component below md, or forced with layout="sheet": one counted button, a drawer, and chips for what is active.

10 of 10 kiosks

Atrium NorthAtrium · 249 €live
Atrium SouthAtrium · 249 €live
Lobby DeskLobby · 189 €draft
Lobby WindowLobby · 189 €offline
Garage EntryGarage · 129 €live
Garage ExitGarage · 129 €offline
Rooftop BarRooftop · 399 €draft
Rooftop TerraceRooftop · 399 €live
Foyer EastFoyer · 209 €live
Foyer WestFoyer · 209 €draft
The same fields, the same values, one button. Every panel inside applies on change — no per-field Clear/Apply pair, because six of them would be six equally loud primary buttons and none of them the one that dismisses the drawer. The footer is the single commit: Reset all, and Show N results, which is the only thing in a drawer that says what the drawer did. What is active stays on the page as chips.

The panel, and when it commits

FilterPanel on its own, in both of its commit modes.

apply="submit"

Applied: live

apply="live"

lowest 129
highest 399

Applied: ≥ 200

One form, two commit modes. submit is a real submit behind a Clear/Apply pair — one commit per press, which is what keeps a server-driven query to one round-trip per change. live commits every change and draws no footer. Put 300 in From and 100 in To and the range is refused with the schema's own message rather than applied: a query that would have returned nothing never runs.

Active-filter chips

FilterChips and describeFilter, without a bar around them.

Status · 2 selectedPrice · ≥ 200
The strip the sheet leaves on the page. describeFilter is what turns a value into words, and the variant decides which words: two selected enum values and a two-ended range are both arrays of length two, so reading one as the other is how a chip ends up saying “Paid – Pending”.

source

filter-bar.tsx

Copy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.

"use client"

import { useForm } from "@conform-to/react"
import type { Submission } from "@conform-to/react"
import { parseWithValibot } from "@conform-to/valibot"
import { ChevronDown, Loader2, Plus, RotateCcw, SlidersHorizontal, X } from "lucide-react"
import { type ReactNode, useEffect, useId, useRef, useState } from "react"
import * as v from "valibot"
import { Badge } from "@/components/badge"
import { Button } from "@/components/button"
import { Checkbox } from "@/components/checkbox"
import { ConformCheckboxGroup } from "@/components/conform-checkbox-group"
import { ConformDateField } from "@/components/conform-date-field"
import { ConformField } from "@/components/conform-field"
import { ConformNumberField } from "@/components/conform-number-field"
import { ConformSearchField } from "@/components/conform-search-field"
import { ConformSelect } from "@/components/conform-select"
import { FieldError } from "@/components/field"
import { FormattedNumber, useFormatNumber } from "@/components/formatted-number"
import { Menu, MenuContent, MenuItem, MenuTrigger } from "@/components/menu"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/popover"
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/select"
import { Separator } from "@/components/separator"
import {
  SheetBody,
  SheetContent,
  SheetFooter,
  SheetHeader,
  SheetTitle,
} from "@/components/sheet"
import {
  type DataTableFilterOption,
  type DataTableFilterVariant,
  defaultOperator,
  type FilterField,
  type FilterOperator,
  filterOperators,
  type FilterValues,
  isFilterSet,
  operatorLabel,
} from "@/lib/data-table"
import { cn } from "@/lib/utils"

/**
 * Filter Bar — quebi design system
 *
 * The filter model with no table under it. `matchesFilter` in `@/lib/data-table`
 * has always taken a value and a filter and known nothing about columns, which
 * is what lets the same five variants — text, number, date, boolean, enum —
 * narrow a card grid, a gallery or any other list that has no header row to
 * hang a filter popover from. What lives here is the chrome for that: the panel
 * itself, the active-filter chips, and the bar that arranges them.
 *
 * **One responsive component, not two.** A row of chip triggers and a filter
 * sheet are the same component at two widths, and the difference between them
 * is a question about the surface rather than about the filters:
 *
 * - **Wide** — one pill per field, inline above the content. Inactive is an
 *   outline chip reading `Status`; active fills and states its own value,
 *   `Status: live`, so the row is both the control and the summary and there is
 *   no second chip strip to keep in sync. Fields that do not fit wait behind a
 *   `+ Filter` menu, and a `Reset` appears once anything is set.
 * - **Narrow** — the whole set collapses to one counted `Filters (2)` button
 *   opening a `Sheet`, with `FilterChips` left on the page to say what is
 *   active. Nothing about the fields changes; only where they are drawn.
 *
 * `FilterBuilder` is the third surface over the same model, and the one this
 * bar cannot be: a list of `Where [Field] [operator] [Value]` rows. A pill is
 * one control per field, so `values` is a map keyed by field and that is all it
 * can ever hold — "is not", and two conditions on one field, need the condition
 * list instead (task #193). The two share this panel; only the operator differs,
 * and here it is always the variant's default.
 *
 * `FilterPanel` and `FilterChips` are the table's own filter chrome, renamed:
 * `table-controls` re-exports them as `TableFilterPanel` / `TableFilterChips`,
 * so `TableShell`, `DataTable` and `ServerTable` keep the names they had. A
 * column filter and a list filter were never two things — only the thing being
 * filtered was.
 *
 * The panel's own operator select (`editOperator`) is what lets a column header
 * author `is not` rather than only carry it (task #201). It is a prop and not
 * the default because three of the four surfaces over this panel already state
 * the operator somewhere else — a builder row's select, a pill's field-keyed
 * map, a rail's one-control-per-field — and a second control for the same term
 * is a control that can disagree with the first.
 */

/* -------------------------------------------------------------------------- */
/*                              the filter panel                              */
/* -------------------------------------------------------------------------- */

/**
 * A client-only form, which is what a filter panel is.
 *
 * Conform binds to a form element and this one has no route action behind it —
 * a filter applies in the browser, or turns into a query the caller sends. That
 * is the documented exception to the raw-element ban, and `table-controls`
 * carries the same note over its own copy for the rest of the table chrome.
 */
function FilterForm({
  id,
  onSubmit,
  className,
  children,
}: {
  id: string
  onSubmit: React.FormEventHandler<HTMLFormElement>
  className?: string
  children: ReactNode
}) {
  return (
    // biome-ignore lint/correctness/noRestrictedElements: documented exception — a form with no route action behind it. A filter panel applies in the browser and posts nowhere; React Router's <Form> would need an action that does not exist. https://ui-lib.quebi.de/rules/no-raw-interactive-elements
    <form id={id} onSubmit={onSubmit} className={className} noValidate>
      {children}
    </form>
  )
}

// A control the current variant does not render submits nothing at all, so
// every entry is optional and the array defaults are thunks — valibot only
// applies a non-function default to a *present* undefined, which a form field
// that was never rendered is not.
const panelSchemas: Record<DataTableFilterVariant, v.GenericSchema> = {
  text: v.object({ text: v.optional(v.string()) }),
  boolean: v.object({ bool: v.optional(v.string()) }),
  enum: v.object({
    search: v.optional(v.string()),
    values: v.optional(v.array(v.string()), () => []),
  }),
  number: v.pipe(
    v.object({
      min: v.optional(v.union([v.number(), v.literal("")])),
      max: v.optional(v.union([v.number(), v.literal("")])),
    }),
    v.forward(
      v.check(
        ({ min, max }) => min === "" || max === "" || min == null || max == null || min <= max,
        "The lower bound must not be above the upper one",
      ),
      ["max"],
    ),
  ),
  date: v.pipe(
    v.object({
      from: v.optional(v.union([v.string(), v.date()])),
      to: v.optional(v.union([v.string(), v.date()])),
    }),
    v.forward(
      v.check(
        ({ from, to }) => !from || !to || String(from) <= String(to),
        "The end date must not be before the start date",
      ),
      ["to"],
    ),
  ),
}

/**
 * The form behind every variant. One shape rather than five keeps the field
 * metadata typed — `useForm` infers its fields from this, and a per-variant
 * union would infer `any` and hand every conform-* control an untyped field.
 */
interface PanelValues {
  text: string
  bool: string
  values: string[]
  search: string
  min: number | string
  max: number | string
  from: string
  to: string
}

const asIsoDate = (value: unknown): string | null => {
  if (!value) return null
  if (value instanceof Date) return value.toISOString().slice(0, 10)
  return String(value).slice(0, 10)
}

/** One parsed form, read back as the value `matchesFilter` expects for a variant. */
function filterValueFrom(variant: DataTableFilterVariant, parsed: Record<string, unknown>): unknown {
  switch (variant) {
    case "text":
      return String(parsed.text ?? "")
    case "boolean":
      return String(parsed.bool ?? "")
    case "enum":
      return (parsed.values as string[]) ?? []
    case "number":
      return [
        parsed.min === "" || parsed.min == null ? null : Number(parsed.min),
        parsed.max === "" || parsed.max == null ? null : Number(parsed.max),
      ]
    default:
      return [asIsoDate(parsed.from), asIsoDate(parsed.to)]
  }
}

export interface FilterPanelProps {
  fieldId: string
  label: string
  variant: DataTableFilterVariant
  /** Current applied value, in the shape `matchesFilter` expects for `variant`. */
  value: unknown
  /**
   * The question the value answers — where the panel starts, and what it
   * reports back when nothing has changed it.
   *
   * Left alone (`editOperator` unset) the panel does not pick it and does not
   * change it: it collects a value and says the question above the box, so
   * `Name contains` never sits over a control whose result the row beside it
   * reads as `does not contain`. That is the right shape wherever something
   * else already owns the operator — a `FilterBuilder` row draws its own
   * select, a `FilterBar` pill is a field-keyed map with nowhere to put one.
   */
  operator?: FilterOperator
  /**
   * Draw an Operator select above the value control, so this panel can author
   * the question as well as answer it.
   *
   * Off by default: a host that already states the operator elsewhere would
   * otherwise show it twice, in two controls that can disagree. Drawn only
   * where there is a choice to make — `filterOperators(variant)` is one entry
   * long for `boolean`, whose `Yes / No / Any` already says everything `is not`
   * would, so the select is omitted rather than rendered with nothing to pick.
   *
   * The pending operator is held like the pending value: in `submit` mode it
   * rides out with Apply, as the second argument to `onApply`, so a change of
   * question and a change of value are still **one** commit and dismissing the
   * popover still discards both. In `live` mode it commits on change, like
   * every other control there.
   */
  editOperator?: boolean
  /**
   * The filter, committed: the value, and the operator it should be read under.
   *
   * The operator is passed whether or not this panel could edit one — it is
   * the one the panel was working under, which for a host that passed neither
   * `operator` nor `editOperator` is the variant's default, i.e. exactly what
   * such a host has always applied. A caller with its own operator is free to
   * ignore the second argument.
   */
  onApply: (value: unknown, operator: FilterOperator) => void
  onClear: () => void
  /**
   * Dismiss whatever is hosting the panel, once Apply or Clear has been acted
   * on. The panel asks; the host decides what dismissing means — a popover
   * closes, a sheet slides away, a faceted rail does nothing and leaves this
   * undefined. Reading the overlay state from context instead would be silent
   * in exactly the hosts that are not overlays. Never called in `live` mode,
   * which has no end to be the end of.
   */
  onClose?: () => void
  /** Enum choices — faceted unique values client-side, a query result server-side. */
  options?: DataTableFilterOption[]
  /** Faceted min/max, used to label a number range. */
  bounds?: [number, number]
  isLoadingOptions?: boolean
  onSearchOptions?: (search: string) => void
  onLoadMoreOptions?: () => void
  /**
   * When the value is committed.
   *
   * `submit` (the default) is the Clear / Apply pair: one commit per press,
   * which is what keeps a server-driven table to one round-trip per change, and
   * dismissing the popover discards the pending edit. `live` commits every
   * change and draws no footer — right for a rail, and for the filter sheet,
   * where five Apply buttons would be five equally loud primary actions and
   * none of them the one that dismisses the drawer.
   */
  apply?: "submit" | "live"
  className?: string
}

/**
 * One field's filter, as a real Conform form with a real valibot schema.
 *
 * Every variant validates before it can be applied — a number range whose lower
 * bound is above its upper one is a form error rather than a query that returns
 * nothing and looks like an empty list.
 *
 * Applying is also the end of the interaction, so the panel calls `onClose`
 * once it has — a host that is an overlay closes, and the result of the filter
 * is visible instead of hidden behind the panel that asked for it. Clear ends
 * it the same way. The panel does not reach for react-aria's overlay state
 * itself: it is meant to be hosted outside an overlay too, and there that
 * reach would be a silent no-op rather than a prop a host can decline.
 *
 * There is no heading: the panel is mounted by something that has already named
 * the field — a pill reading `Status`, a column header, a row in a sheet — and
 * repeating the name inside covers the first option with a word the user just
 * read.
 */
export function FilterPanel({
  fieldId,
  label,
  variant,
  value,
  operator,
  editOperator,
  onApply,
  onClear,
  onClose,
  options = [],
  bounds,
  isLoadingOptions,
  onSearchOptions,
  onLoadMoreOptions,
  apply = "submit",
  className,
}: FilterPanelProps) {
  const isLive = apply === "live"
  /*
   * The pending operator, held exactly as long as the pending value is: it is
   * state rather than a prop echo, and nothing syncs it back down. A host that
   * changes `operator` from outside while the panel is open (a preset applied
   * behind it) loses to the draft, which is what a half-typed value does too —
   * the panel is one transaction, and Apply or dismissal is the end of it.
   */
  const operatorChoices = filterOperators(variant)
  const [pendingOperator, setPendingOperator] = useState<FilterOperator>()
  const showOperator = Boolean(editOperator) && operatorChoices.length > 1
  const currentOperator = pendingOperator ?? operator ?? defaultOperator(variant)
  const range: [unknown, unknown] = Array.isArray(value)
    ? (value as [unknown, unknown])
    : [null, null]
  // An option the other filters have left nothing of is drawn, disabled, at 0:
  // the choice is visible and so is the reason it is unavailable, and picking
  // it could only empty the result. Except when it is the value already applied
  // — this panel is the only place that filter can be taken off again, so a
  // selected option stays checkable however few rows are left under it.
  const appliedValues = new Set(
    variant === "enum" && Array.isArray(value) ? (value as unknown[]).map(String) : [],
  )
  const numberBound = (bound: unknown) => (bound == null ? "" : Number(bound))
  const defaultValue: PanelValues = {
    text: variant === "text" ? ((value as string) ?? "") : "",
    bool: variant === "boolean" ? ((value as string) ?? "") : "",
    values: variant === "enum" ? ((value as string[]) ?? []) : [],
    search: "",
    min: variant === "number" ? numberBound(range[0]) : "",
    max: variant === "number" ? numberBound(range[1]) : "",
    from: variant === "date" ? (asIsoDate(range[0]) ?? "") : "",
    to: variant === "date" ? (asIsoDate(range[1]) ?? "") : "",
  }
  const [form, fields] = useForm<PanelValues>({
    id: `${useId()}-filter-${fieldId}`,
    defaultValue,
    // The schema is chosen at runtime from the field's variant, so its parsed
    // type is not statically the form's; `filterValueFrom` narrows it by hand.
    onValidate: ({ formData }) =>
      parseWithValibot(formData, {
        schema: panelSchemas[variant],
      }) as unknown as Submission<PanelValues>,
    onSubmit: (event, { submission }) => {
      event.preventDefault()
      if (submission?.status !== "success") return
      onApply(
        filterValueFrom(variant, submission.value as unknown as Record<string, unknown>),
        currentOperator,
      )
      // Only a submission that got as far as applying dismisses the host: a
      // validation error keeps the panel up, with the message on the field.
      onClose?.()
    },
  })

  /*
   * Live mode commits from the controls rather than from a submit, and that is
   * not a shortcut. Conform focuses the first field carrying an error whenever
   * it reports a failed submission, so submitting on every keystroke would pull
   * the caret out of `From` and into `To` the moment a range went briefly
   * inverted — mid-edit, which is when every range is inverted. So the draft is
   * kept here, parsed by the same schema, and applied only when it passes; a
   * range that does not passes its message to the line below instead.
   */
  const draft = useRef(defaultValue)
  const [liveError, setLiveError] = useState<string>()
  const commit = (patch: Partial<PanelValues>, nextOperator?: FilterOperator) => {
    const next = { ...draft.current, ...patch }
    draft.current = next
    const result = v.safeParse(panelSchemas[variant], next)
    if (!result.success) {
      setLiveError(result.issues[0]?.message)
      return
    }
    setLiveError(undefined)
    // The operator is passed explicitly rather than read back off state: this
    // runs in the same tick as the `setPendingOperator` that caused it.
    onApply(
      filterValueFrom(variant, result.output as Record<string, unknown>),
      nextOperator ?? currentOperator,
    )
  }
  const live = <K extends keyof PanelValues>(key: K) =>
    isLive ? (next: PanelValues[K]) => commit({ [key]: next } as Partial<PanelValues>) : undefined
  const liveNumber = (key: "min" | "max") =>
    isLive ? (next: number) => commit({ [key]: Number.isNaN(next) ? "" : next }) : undefined

  const onListScroll = (event: React.UIEvent<HTMLDivElement>) => {
    const el = event.currentTarget
    if (el.scrollHeight - el.scrollTop - el.clientHeight < 60) onLoadMoreOptions?.()
  }

  return (
    <FilterForm id={form.id} onSubmit={form.onSubmit} className={cn("flex flex-col gap-3 p-3", className)}>
      {/* Above the value, because it is the question the value answers and a
          reader meets them in that order — and because every variant's value
          control is a different height, so a select underneath would sit at a
          different place in each panel. */}
      {showOperator && (
        <Select
          aria-label={`${label} operator`}
          selectedKey={currentOperator}
          onSelectionChange={(key) => {
            const next = String(key) as FilterOperator
            setPendingOperator(next)
            if (isLive) commit({}, next)
          }}
        >
          <SelectTrigger />
          <SelectContent items={operatorChoices}>
            {(item) => <SelectItem id={item.id}>{item.label}</SelectItem>}
          </SelectContent>
        </Select>
      )}

      {variant === "text" && (
        <ConformField
          field={fields.text}
          /* The operator is said once. With a select above it saying `does not
             contain`, a label reading `Customer contains` is the same claim
             twice and one of them is stale. */
          label={showOperator ? label : `${label} ${operatorLabel(currentOperator)}`}
          placeholder="Type to match…"
          onChange={live("text")}
        />
      )}

      {variant === "boolean" && (
        <ConformSelect
          field={fields.bool}
          label={label}
          aria-label={label}
          onSelectionChange={isLive ? (key) => commit({ bool: String(key ?? "") }) : undefined}
        >
          <SelectItem id="">Any</SelectItem>
          <SelectItem id="true">Yes</SelectItem>
          <SelectItem id="false">No</SelectItem>
        </ConformSelect>
      )}

      {variant === "number" && (
        // Two bounds side by side while there is room for them, stacked when
        // there is not — and both halves of that are about the same ~74px.
        //
        // The stepper pair is a fixed width that a `w-full min-w-0` input
        // gives up its own width to rather than overflow, so in a ~210px-wide
        // host (this panel inside a `sm:max-w-80` sheet) the two inputs
        // measured 26px each and neither the value nor the placeholder was
        // legible (task #189). A filter bound is typed, not nudged, and ↑ / ↓
        // still step — so the pair is hidden here rather than shrunk, which is
        // exactly the width the two inputs were missing.
        //
        // The container query is the floor under that: below 16rem even a
        // stepper-less pair is too narrow to read, so the row becomes a column
        // instead of slivering again. It is a *container* query and not a
        // breakpoint because this panel's width is its host's — a 320px pill
        // popover, a sheet, a filter rail — and the viewport does not predict
        // which.
        <div className="@container">
          <div className="flex flex-col gap-2 @3xs:flex-row @3xs:items-end">
            <ConformNumberField
              field={fields.min}
              label="From"
              hideStepper
              description={bounds ? `lowest ${bounds[0]}` : undefined}
              onChange={liveNumber("min")}
            />
            <ConformNumberField
              field={fields.max}
              label="To"
              hideStepper
              description={bounds ? `highest ${bounds[1]}` : undefined}
              onChange={liveNumber("max")}
            />
          </div>
        </div>
      )}

      {variant === "date" && (
        <div className="flex flex-col gap-2">
          <ConformDateField
            field={fields.from}
            label="From"
            onChange={isLive ? (date) => commit({ from: date ? date.toString() : "" }) : undefined}
          />
          <ConformDateField
            field={fields.to}
            label="To"
            onChange={isLive ? (date) => commit({ to: date ? date.toString() : "" }) : undefined}
          />
        </div>
      )}

      {variant === "enum" && (
        <div className="flex flex-col gap-2">
          {onSearchOptions && (
            <ConformSearchField
              field={fields.search}
              aria-label={`Search ${label} values`}
              placeholder="Search values…"
              onChange={onSearchOptions}
            />
          )}
          {/* The scroll listener is what drives "load more on scroll". It adds
              no interaction of its own: every option inside is a real checkbox,
              and the list is fully reachable by keyboard without scrolling. */}
          <div className="quebi-scrollbar max-h-56 overflow-y-auto" onScroll={onListScroll}>
            <ConformCheckboxGroup
              field={fields.values}
              aria-label={`${label} values`}
              onChange={live("values")}
            >
              {options.map((option) => (
                <Checkbox
                  key={option.value}
                  value={option.value}
                  isDisabled={option.count === 0 && !appliedValues.has(option.value)}
                >
                  <span className="flex w-full items-center justify-between gap-2">
                    <span>{option.label ?? option.value}</span>
                    {option.count != null && (
                      <span className="text-quebi-fg-subtle text-xs tabular-nums">
                        <FormattedNumber value={option.count} />
                      </span>
                    )}
                  </span>
                </Checkbox>
              ))}
            </ConformCheckboxGroup>
            {options.length === 0 && (
              <div className="flex items-center justify-center gap-2 py-6 text-quebi-fg-subtle text-sm">
                {isLoadingOptions ? (
                  <>
                    <Loader2 className="size-4 animate-spin" aria-hidden="true" />
                    Loading…
                  </>
                ) : (
                  "No values"
                )}
              </div>
            )}
          </div>
        </div>
      )}

      {/* Live mode has no submit to report through, so the schema's own message
          is rendered here. Outside a react-aria field `FieldError` falls back to
          rendering the text itself, which is the branch this takes. */}
      {isLive && liveError && <FieldError>{liveError}</FieldError>}

      {!isLive && (
        <div className="flex items-center gap-2 border-quebi-hairline border-t pt-3">
          <Button
            intent="ghost"
            size="xs"
            className="flex-1"
            onPress={() => {
              onClear()
              onClose?.()
            }}
          >
            Clear
          </Button>
          <Button type="submit" intent="primary" size="xs" className="flex-1">
            Apply
          </Button>
        </div>
      )}
    </FilterForm>
  )
}

/* -------------------------------------------------------------------------- */
/*                                 the chips                                  */
/* -------------------------------------------------------------------------- */

/** One active filter, already turned into words by `describeFilter`. */
export interface FilterChip {
  id: string
  label: string
  text: string
}

export interface FilterChipsProps {
  filters: FilterChip[]
  onClear: (id: string) => void
  onClearAll: () => void
  /** Saved presets: a named set of filters the user can re-apply. */
  presets?: { id: string; label: string }[]
  onApplyPreset?: (id: string) => void
  onSavePreset?: () => void
}

/** Every active filter, named and removable, with the clear-all beside them. */
export function FilterChips({
  filters,
  onClear,
  onClearAll,
  presets,
  onApplyPreset,
  onSavePreset,
}: FilterChipsProps) {
  if (filters.length === 0 && !presets?.length) return null
  return (
    <div className="flex min-h-7 flex-wrap items-center gap-1.5 print:hidden">
      {filters.map((filter) => (
        <span
          key={filter.id}
          className="inline-flex items-center gap-x-1 rounded-full bg-quebi-raised py-1 pe-1 ps-3 text-quebi-fg text-quebi-tag"
        >
          <span>
            {filter.label}
            <span className="text-quebi-fg-muted"> · {filter.text}</span>
          </span>
          {/*
            The variants are named, not fought with a className (task #187).
            A `Button` given only a `className` still takes `buttonStyles`'
            defaults — `intent="primary"` and `size="md"` — and `size-4` merges
            away only the *size*: `px-5 py-2.5` is a different group, so it
            survived and left a 16px box with 20px of padding a side. The
            content box collapsed to 0 and the × vanished inside a solid
            blob. `ghost` + a square size is the shape this actually wants; the
            className is then only what is particular to a chip — its 16px box,
            and its own hover ground. `isCircle` is a variant
            for the reason button.tsx gives: `rounded-full` in a className loses
            to `rounded-none` on sheet order. The ring loses its offset
            because a 3px halo in the page colour around a 16px button inside a
            pill paints over the chip's own ground.
          */}
          <Button
            intent="ghost"
            size="sq-xs"
            isCircle
            aria-label={`Clear ${filter.label} filter`}
            onPress={() => onClear(filter.id)}
            className="size-4 shrink-0 text-quebi-fg-subtle hover:bg-quebi-pressed hover:text-quebi-fg focus-visible:ring-offset-0"
          >
            <X className="size-3" strokeWidth={2.5} aria-hidden="true" />
          </Button>
        </span>
      ))}
      {filters.length > 1 && (
        <Button intent="ghost" size="xs" onPress={onClearAll}>
          Clear all
        </Button>
      )}
      {presets && presets.length > 0 && onApplyPreset && (
        <Menu>
          <MenuTrigger aria-label="Saved filters">
            <Badge intent="neutral">Presets</Badge>
          </MenuTrigger>
          <MenuContent onAction={(key) => onApplyPreset(String(key))}>
            {presets.map((preset) => (
              <MenuItem key={preset.id} id={preset.id}>
                {preset.label}
              </MenuItem>
            ))}
          </MenuContent>
        </Menu>
      )}
      {onSavePreset && filters.length > 0 && (
        <Button intent="ghost" size="xs" onPress={onSavePreset}>
          Save as preset
        </Button>
      )}
    </div>
  )
}

/**
 * A filter, in a chip's worth of words. The variant decides, not the shape:
 * two selected enum values and a two-ended range are both arrays of length
 * two, and reading one as the other is how a chip ends up saying
 * "Paid – Pending".
 *
 * It lives here, beside `FilterChips`, rather than with either table mode: the
 * text is a property of the filter *variant*, which every surface shares.
 */
export function describeFilter(
  variant: DataTableFilterVariant | undefined,
  value: unknown,
  operator?: FilterOperator,
): string {
  const text = describeFilterValue(variant, value)
  // The default operator is the one the variant has always implied, and naming
  // it turns every chip that was ever drawn into "Status is Paid". Only a
  // question the reader would otherwise get wrong is worth the words — which is
  // exactly the negated and narrowed ones.
  return operator == null || operator === defaultOperator(variant)
    ? text
    : `${operatorLabel(operator)} ${text}`
}

function describeFilterValue(variant: DataTableFilterVariant | undefined, value: unknown): string {
  if (variant === "number" || variant === "date") {
    const [from, to] = Array.isArray(value) ? value : [null, null]
    if (from != null && from !== "" && to != null && to !== "") return `${from} – ${to}`
    if (from != null && from !== "") return `≥ ${from}`
    if (to != null && to !== "") return `≤ ${to}`
    return "any"
  }
  if (Array.isArray(value)) {
    return value.length === 1 ? String(value[0]) : `${value.length} selected`
  }
  if (variant === "boolean") return value === "true" ? "Yes" : value === "false" ? "No" : "any"
  return String(value)
}

/* -------------------------------------------------------------------------- */
/*                                  the bar                                   */
/* -------------------------------------------------------------------------- */

/**
 * The width below which the bar is drawn as a sheet. `md`, the same breakpoint
 * Navbar, Sidebar, Chart and DatePicker each collapse at — a filter row is a
 * toolbar, and a toolbar that wraps to three lines has stopped being one.
 */
const COLLAPSE_BREAKPOINT = 768

/**
 * One height for every control in the bar — the pills, the `+ Filter` menu, the
 * `Reset`, the sheet's trigger, and whatever the caller puts in the start slot.
 *
 * It is the same 38px `CHROME_SIZE` pins the table toolbar to, written out
 * again rather than imported: `table-controls` imports this module, so reaching
 * back for the constant would close the loop the split exists to avoid. A row
 * of 30px chips beside a 42px search field is the defect that constant was
 * introduced for, and it does not become a different defect here.
 */
const BAR_SIZE = "sm" as const

/**
 * Inlined use-mobile hook: tracks whether the viewport is below `md`.
 *
 * `undefined` until the effect runs, which is deliberate: the server and the
 * first client render agree on the wide shape, so nothing mismatches on a
 * prerendered page, and the sheet takes over a tick later where it applies.
 */
const useIsNarrow = () => {
  const [isNarrow, setIsNarrow] = useState<boolean | undefined>(undefined)

  useEffect(() => {
    const mql = window.matchMedia(`(max-width: ${COLLAPSE_BREAKPOINT - 1}px)`)
    const onChange = () => setIsNarrow(window.innerWidth < COLLAPSE_BREAKPOINT)
    mql.addEventListener("change", onChange)
    setIsNarrow(window.innerWidth < COLLAPSE_BREAKPOINT)
    return () => mql.removeEventListener("change", onChange)
  }, [])

  return isNarrow
}

export interface FilterBarProps {
  fields: FilterField[]
  /** Every field's current filter, by field id. Controlled — the bar holds none. */
  values: FilterValues
  onChange: (values: FilterValues) => void
  /**
   * Rendered at the start of the bar, before the pills: a search field, almost
   * always. It stays on the page in both shapes — a search box is the one
   * control nobody wants to open a drawer to reach.
   */
  children?: ReactNode
  /**
   * How many pills the bar starts with. The rest wait behind `+ Filter`, which
   * is the answer to "many fields" on a wide screen; the sheet is the answer to
   * a narrow one. Fields carrying a value are always shown, whatever this says.
   */
  inlineLimit?: number
  /**
   * Which shape to draw. `auto` is the component — it picks by viewport width.
   * The other two are for a page that already knows: a dense admin toolbar that
   * should never collapse, a mobile-only surface that should never expand.
   */
  layout?: "auto" | "bar" | "sheet"
  /**
   * How many rows the current values select. Labels the sheet's primary button
   * — "Show 12 results" is the only thing in a drawer that says what the drawer
   * did, because the results are behind it.
   */
  resultCount?: number
  isLoadingOptions?: boolean
  onSearchOptions?: (fieldId: string, search: string) => void
  onLoadMoreOptions?: (fieldId: string) => void
  /**
   * Names the sheet — its dialog label and the heading inside it. The bar
   * itself takes no group role and no name: every pill is a button that says
   * its own field, and a `role="group"` around them would announce a wrapper
   * before each of the things it wraps without adding a fact.
   */
  "aria-label"?: string
  className?: string
}

/**
 * FilterBar — one pill per field, collapsing to a sheet.
 *
 * Controlled: it takes `values` and reports the next set through `onChange`,
 * and filtering the rows stays with the caller — `filterRows` and
 * `facetCounts` in `@/lib/data-table` are the two lines that usually is.
 */
export function FilterBar({
  fields,
  values,
  onChange,
  children,
  inlineLimit = 4,
  layout = "auto",
  resultCount,
  isLoadingOptions,
  onSearchOptions,
  onLoadMoreOptions,
  "aria-label": ariaLabel = "Filters",
  className,
}: FilterBarProps) {
  const formatCount = useFormatNumber()
  const [added, setAdded] = useState<string[]>([])
  const [openField, setOpenField] = useState<string | null>(null)
  const [isSheetOpen, setSheetOpen] = useState(false)
  /*
   * The panels in the sheet stay mounted, and a conform-* control binds through
   * `defaultValue` — so a value cleared from outside the panel (Reset all, a
   * chip's ×) never reaches the control that is still showing it. Remounting is
   * the fix, and this token is what says the change was not the panel's own.
   */
  const [resetToken, setResetToken] = useState(0)
  const isNarrow = useIsNarrow()
  const asSheet = layout === "sheet" || (layout === "auto" && isNarrow === true)

  const without = (fieldId: string): FilterValues => {
    const { [fieldId]: _dropped, ...rest } = values
    return rest
  }
  /* Emptying a field from inside its own panel is not an external change: it
     must not bump the token, or unchecking the last enum option in the sheet
     would remount the group the pointer is still in. */
  const set = (fieldId: string, value: unknown) =>
    onChange(isFilterSet(value) ? { ...values, [fieldId]: value } : without(fieldId))
  const clear = (fieldId: string) => {
    setResetToken((token) => token + 1)
    onChange(without(fieldId))
  }
  const reset = () => {
    setAdded([])
    setResetToken((token) => token + 1)
    onChange({})
  }

  const active = fields.filter((field) => isFilterSet(values[field.id]))
  const shown = fields.filter(
    (field, index) =>
      index < inlineLimit || added.includes(field.id) || isFilterSet(values[field.id]),
  )
  const addable = fields.filter((field) => !shown.includes(field))

  const panelFor = (field: FilterField, apply: "submit" | "live") => (
    <FilterPanel
      fieldId={field.id}
      label={field.label}
      variant={field.variant}
      value={values[field.id]}
      options={field.options}
      bounds={field.bounds}
      apply={apply}
      isLoadingOptions={isLoadingOptions}
      onSearchOptions={onSearchOptions && ((search) => onSearchOptions(field.id, search))}
      onLoadMoreOptions={onLoadMoreOptions && (() => onLoadMoreOptions(field.id))}
      onApply={(value) => set(field.id, value)}
      onClear={() => clear(field.id)}
      /* The popover that hosts a pill's panel closes when the panel has
         applied — otherwise it sits over the results it just produced, with
         the page scroll-locked behind it (task #188). The sheet declines: its
         panels commit live and it has one dismissal, in the footer. */
      onClose={apply === "submit" ? () => setOpenField(null) : undefined}
    />
  )

  const chips = active.map((field) => ({
    id: field.id,
    label: field.label,
    text: describeFilter(field.variant, values[field.id]),
  }))

  if (asSheet) {
    return (
      <div className={cn("flex flex-col gap-2", className)}>
        <div className="flex flex-wrap items-center gap-2">
          {children}
          <Button intent="outline" size={BAR_SIZE} onPress={() => setSheetOpen(true)}>
            <SlidersHorizontal data-slot="icon" aria-hidden="true" />
            {/* One text node, not "Filters (" + a number + ")": three of them
                make the accessible name "Filters ( 2 )", because the name is
                joined from the elements it is spread across. */}
            {active.length > 0 ? `Filters (${formatCount(active.length)})` : "Filters"}
          </Button>
        </div>
        <FilterChips filters={chips} onClear={clear} onClearAll={reset} />
        <SheetContent
          isOpen={isSheetOpen}
          onOpenChange={setSheetOpen}
          side="right"
          aria-label={ariaLabel}
        >
          <SheetHeader>
            <SheetTitle>{ariaLabel}</SheetTitle>
          </SheetHeader>
          {/* No card around each field and no Clear/Apply pair inside it. Five
              panels' worth of chrome was 1336px of content in a 734px drawer —
              two and a half fields on screen — and five equally loud primary
              buttons, none of which dismissed the drawer the results were
              already updating behind. The footer below is the one commit. */}
          <SheetBody className="flex flex-col gap-4">
            {fields.map((field, index) => (
              <div
                key={`${field.id}-${resetToken}`}
                className={cn(
                  "flex flex-col gap-2",
                  index > 0 && "border-quebi-hairline border-t pt-4",
                )}
              >
                <span className="quebi-eyebrow">{field.label}</span>
                {panelFor(field, "live")}
              </div>
            ))}
          </SheetBody>
          <SheetFooter>
            <Button intent="ghost" onPress={reset}>
              <RotateCcw data-slot="icon" aria-hidden="true" />
              Reset all
            </Button>
            <Button intent="primary" onPress={() => setSheetOpen(false)}>
              {resultCount == null ? (
                "Show results"
              ) : (
                <>
                  Show <FormattedNumber value={resultCount} />{" "}
                  {resultCount === 1 ? "result" : "results"}
                </>
              )}
            </Button>
          </SheetFooter>
        </SheetContent>
      </div>
    )
  }

  return (
    <div className={cn("flex flex-wrap items-center gap-2 print:hidden", className)}>
      {children}
      {children && shown.length > 0 && (
        <Separator orientation="vertical" className="h-6" />
      )}
      {shown.map((field) => {
        const on = isFilterSet(values[field.id])
        return (
          <Popover
            key={field.id}
            isOpen={openField === field.id}
            onOpenChange={(isOpen) => setOpenField(isOpen ? field.id : null)}
          >
            <PopoverTrigger
              size={BAR_SIZE}
              intent={on ? "primary" : "outline"}
              isCircle
              className="font-medium"
            >
              {/* The colon is glued to the label rather than living in the value
                  span beside it: the trigger is a flex row with `gap-2`, so a
                  leading ": " in its own element renders as "Status : live". */}
              <span className="truncate">
                {field.label}
                {on && (
                  <>
                    {": "}
                    <span className="font-normal opacity-80">
                      {describeFilter(field.variant, values[field.id])}
                    </span>
                  </>
                )}
              </span>
              <ChevronDown data-slot="icon" aria-hidden="true" />
            </PopoverTrigger>
            {/* Anchored to the trigger's start edge, not centred on it. A pill
                is as wide as its own text, so a centred panel hangs left of the
                field it belongs to — and it slides sideways the moment Apply
                widens `Status` into `Status: live`. */}
            <PopoverContent placement="bottom start" className="w-80">
              {panelFor(field, "submit")}
            </PopoverContent>
          </Popover>
        )
      })}
      {addable.length > 0 && (
        <Menu>
          <Button aria-label="Add a filter" size={BAR_SIZE} intent="ghost" isCircle>
            <Plus data-slot="icon" aria-hidden="true" />
            Filter
          </Button>
          <MenuContent onAction={(key) => setAdded((current) => [...current, String(key)])}>
            {addable.map((field) => (
              <MenuItem key={field.id} id={field.id}>
                {field.label}
              </MenuItem>
            ))}
          </MenuContent>
        </Menu>
      )}
      {active.length > 0 && (
        <Button intent="ghost" size={BAR_SIZE} className="ms-auto" onPress={reset}>
          <RotateCcw data-slot="icon" aria-hidden="true" />
          Reset
        </Button>
      )}
    </div>
  )
}

export type { FilterField, FilterValues }