{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"filter-bar","type":"registry:component","title":"Filter bar","description":"The bar above a list, done like Linear’s: one panel from property to value, chips you change in place, and a sentence that becomes filters.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/text-morph.json?as=dependency","https://hairlineui.com/r/number-roll.json?as=dependency"],"files":[{"path":"registry/filter-bar.tsx","type":"registry:component","content":"// Hairline UI · filter-bar · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { Check, ChevronLeft, ChevronRight, ListFilter, Plus, Sparkles, X } from \"lucide-react\";\nimport { NumberRoll } from \"./number-roll\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * FILTER BAR: the bar above a list, done like Linear's\n *\n *   add       \"Filter\" opens one panel: pick a property, its\n *             values slide in from the side, pick one and it\n *             lands in the bar as a chip\n *   search    typing searches properties and values at once:\n *             \"hig\" offers Priority › High straight away\n *   chip      Status | is | Todo | ×. Click \"is\" and it turns\n *             into \"is not\"; click the value to change it (the\n *             panel opens from the chip, values tick on and off)\n *   describe  with parse(), a sentence becomes filters: \"high\n *             priority bugs from Maya\" → three chips\n *   count     \"37 of 128 issues\" rolls as the filters change\n *\n * Filters are plain data ({ field, op, values | text | number |\n * days }), easy to turn into a query. matchFilters() applies\n * them to rows you already have. F opens the panel from anywhere\n * on the page while nothing is being typed.\n * ───────────────────────────────────────────────────────── */\n\nexport type FilterFieldType = \"option\" | \"text\" | \"number\" | \"date\";\n\nexport interface FilterOption {\n  value: string;\n  label: string;\n  icon?: React.ReactNode;\n}\n\nexport interface FilterField<T = unknown> {\n  id: string;\n  label: string;\n  type: FilterFieldType;\n  icon?: React.ReactNode;\n  /** The values an option field can take. */\n  options?: FilterOption[];\n  /** A number field's unit, e.g. [\"seat\", \"seats\"]. */\n  unit?: [string, string];\n  /** Reads a row's value for matchFilters. Defaults to row[id]. */\n  get?: (row: T) => string | string[] | number | Date | null | undefined;\n}\n\nexport type Filter =\n  | { id: string; field: string; op: \"is\" | \"is not\"; values: string[] }\n  | { id: string; field: string; op: \"contains\" | \"does not contain\"; text: string }\n  | { id: string; field: string; op: \"at least\" | \"at most\" | \"is exactly\"; number: number }\n  | { id: string; field: string; op: \"within\" | \"older than\"; days: number };\n\ntype Without<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never;\n/** A filter before it has an id: what parse() returns. */\nexport type NewFilter = Without<Filter, \"id\">;\n\nexport interface FilterBarProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"defaultValue\" | \"onChange\"> {\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  fields: FilterField<any>[];\n  value?: Filter[];\n  defaultValue?: Filter[];\n  onValueChange?: (filters: Filter[]) => void;\n  /** How many rows match; it rolls beside the bar. */\n  count?: number;\n  /** How many there are in all: \"37 of 128\". */\n  total?: number;\n  /** What a row is called: [\"issue\", \"issues\"]. */\n  noun?: [string, string];\n  /** Turns a sentence into filters (with a model, say). Adds \"Filter by “…”\" to the panel; throw or return none to say it couldn't. */\n  parse?: (text: string) => Promise<NewFilter[]> | NewFilter[];\n  /** A key that opens the panel from anywhere on the page while nothing is being typed. null turns it off. */\n  hotkey?: string | null;\n  locales?: string;\n  /** Accessible name of the bar. */\n  label?: string;\n}\n\ntype Menu = { mode: \"new\" } | { mode: \"edit\"; filter: string };\ntype Phase = \"closed\" | \"opening\" | \"open\" | \"closing\";\ntype Box = { left: number; top: number; width: number; height: number };\ntype Row =\n  | { kind: \"field\"; field: FilterField }\n  | { kind: \"jump\"; field: FilterField; option: FilterOption }\n  | { kind: \"option\"; field: FilterField; option: FilterOption; checked: boolean }\n  | { kind: \"set\"; field: FilterField; filter: NewFilter; label: string }\n  | { kind: \"parse\"; text: string }\n  | { kind: \"hint\"; text: string };\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst UI = \"cubic-bezier(0.23,1,0.32,1)\";\nconst OPEN = 300;\nconst CLOSE = 220;\nconst PANEL_W = 268;\nconst ROW = 32;\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\nconst NO_FILTERS: Filter[] = [];\nconst NOUN: [string, string] = [\"result\", \"results\"];\nconst DAY = 86_400_000;\n\nconst DATE_PRESETS: { op: \"within\" | \"older than\"; days: number; label: string }[] = [\n  { op: \"within\", days: 1, label: \"Last 24 hours\" },\n  { op: \"within\", days: 7, label: \"Last 7 days\" },\n  { op: \"within\", days: 30, label: \"Last 30 days\" },\n  { op: \"within\", days: 90, label: \"Last 3 months\" },\n  { op: \"older than\", days: 30, label: \"More than 30 days ago\" },\n  { op: \"older than\", days: 90, label: \"More than 3 months ago\" },\n];\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\n/** One of two icons in the same place: the other shrinks away through a blur. */\nconst swap = (on: boolean, reduced: boolean): React.CSSProperties => ({\n  opacity: on ? 1 : 0,\n  transform: on ? \"none\" : \"scale(0.6)\",\n  filter: on ? \"none\" : \"blur(3px)\",\n  transition: reduced ? \"none\" : `opacity 200ms ${EASE}, transform 320ms ${EASE}, filter 200ms ${EASE}`,\n});\n\nlet seq = 0;\nconst newId = () => `f${Date.now().toString(36)}${(seq++).toString(36)}`;\n\nconst dayLabel = (d: number) =>\n  d === 1 ? \"1 day\" : d % 365 === 0 ? `${d / 365} ${d === 365 ? \"year\" : \"years\"}` : d >= 60 && d % 30 === 0 ? `${d / 30} months` : `${d} days`;\n\nconst OPS: Record<FilterFieldType, Filter[\"op\"][]> = {\n  option: [\"is\", \"is not\"],\n  text: [\"contains\", \"does not contain\"],\n  number: [\"at least\", \"at most\", \"is exactly\"],\n  date: [\"within\", \"older than\"],\n};\n\n/** The words a chip shows for a filter: \"is any of\", \"Todo +2\". */\nfunction words(f: Filter | NewFilter, field: FilterField | undefined, num: Intl.NumberFormat) {\n  if (\"values\" in f) {\n    // In the field's own order, whatever order they were ticked in.\n    const order = (v: string) => field?.options?.findIndex((o) => o.value === v) ?? 0;\n    const values = [...f.values].sort((a, b) => order(a) - order(b));\n    const labels = values.map((v) => field?.options?.find((o) => o.value === v)?.label ?? v);\n    const op = labels.length > 1 ? (f.op === \"is\" ? \"is any of\" : \"is none of\") : f.op;\n    const value = labels.length === 0 ? \"nothing\" : labels.length === 2 ? `${labels[0]}, ${labels[1]}` : labels.length > 2 ? `${labels[0]} +${labels.length - 1}` : labels[0];\n    return { op, value, icon: field?.options?.find((o) => o.value === values[0])?.icon };\n  }\n  if (\"text\" in f) return { op: f.op === \"contains\" ? \"contains\" : \"doesn't contain\", value: `“${f.text}”`, icon: undefined };\n  if (\"number\" in f) return { op: f.op, value: num.format(f.number), icon: undefined };\n  return { op: f.op, value: dayLabel(f.days), icon: undefined };\n}\n\n/** Keeps the rows every filter allows. Filters on the same field and on different fields must all hold. */\nexport function matchFilters<T>(rows: T[], filters: Filter[], fields: FilterField<T>[], now = Date.now()): T[] {\n  const live = filters.filter((f) => !(\"values\" in f) || f.values.length);\n  if (!live.length) return rows;\n  const byId = new Map(fields.map((f) => [f.id, f]));\n  return rows.filter((row) =>\n    live.every((f) => {\n      const field = byId.get(f.field);\n      if (!field) return true;\n      const raw = field.get ? field.get(row) : ((row as Record<string, unknown>)[field.id] as ReturnType<NonNullable<FilterField[\"get\"]>>);\n      switch (f.op) {\n        case \"is\":\n        case \"is not\": {\n          const vals = Array.isArray(raw) ? raw.map(String) : raw == null ? [] : [String(raw)];\n          const hit = vals.some((v) => f.values.includes(v));\n          return f.op === \"is\" ? hit : !hit;\n        }\n        case \"contains\":\n        case \"does not contain\": {\n          const hit = String(raw ?? \"\").toLowerCase().includes(f.text.toLowerCase());\n          return f.op === \"contains\" ? hit : !hit;\n        }\n        case \"at least\":\n          return typeof raw === \"number\" && raw >= f.number;\n        case \"at most\":\n          return typeof raw === \"number\" && raw <= f.number;\n        case \"is exactly\":\n          return typeof raw === \"number\" && raw === f.number;\n        case \"within\":\n        case \"older than\": {\n          const t = raw instanceof Date ? raw.getTime() : typeof raw === \"string\" || typeof raw === \"number\" ? new Date(raw).getTime() : NaN;\n          if (Number.isNaN(t)) return false;\n          return f.op === \"within\" ? t >= now - f.days * DAY : t < now - f.days * DAY;\n        }\n      }\n    }),\n  );\n}\n\n/** Adds a filter: a value joins an existing chip on the same field and operator, a number or date replaces its twin. */\nfunction merge(list: Filter[], draft: NewFilter, added: string[]): Filter[] {\n  const twin = list.find((f) => f.field === draft.field && f.op === draft.op);\n  if (twin && \"values\" in twin && \"values\" in draft) {\n    const values = [...twin.values, ...draft.values.filter((v) => !twin.values.includes(v))];\n    return list.map((f) => (f === twin ? { ...twin, values } : f));\n  }\n  if (twin && !(\"values\" in draft)) return list.map((f) => (f === twin ? ({ ...draft, id: twin.id } as Filter) : f));\n  const id = newId();\n  added.push(id);\n  return [...list, { ...draft, id } as Filter];\n}\n\n/** Chips that keep their place on screen: positions taken at the click, glides after the new layout, leavers fade where they stood. */\nfunction useChipMotion(list: React.RefObject<HTMLElement | null>, ghosts: React.RefObject<HTMLElement | null>, reduced: boolean) {\n  const before = React.useRef<Map<string, { x: number; y: number; w: number; h: number; el: HTMLElement }> | null>(null);\n  const capture = React.useCallback(() => {\n    const el = list.current;\n    if (!el) return;\n    const o = el.getBoundingClientRect();\n    const m = new Map<string, { x: number; y: number; w: number; h: number; el: HTMLElement }>();\n    el.querySelectorAll<HTMLElement>(\"[data-chip]\").forEach((c) => {\n      const r = c.getBoundingClientRect();\n      m.set(c.dataset.chip!, { x: r.left - o.left + el.scrollLeft, y: r.top - o.top, w: r.width, h: r.height, el: c });\n    });\n    before.current = m;\n  }, [list]);\n\n  const play = React.useCallback(\n    (stagger: string[]) => {\n      const b = before.current;\n      const el = list.current;\n      before.current = null;\n      if (!b || !el || reduced) return;\n      const o = el.getBoundingClientRect();\n      const seen = new Set<string>();\n      el.querySelectorAll<HTMLElement>(\"[data-chip]\").forEach((c) => {\n        const id = c.dataset.chip!;\n        seen.add(id);\n        const was = b.get(id);\n        if (!was) {\n          c.animate(\n            [\n              { opacity: 0, transform: \"translateY(6px)\", filter: \"blur(3px)\" },\n              { opacity: 1, transform: \"none\", filter: \"blur(0px)\" },\n            ],\n            { duration: 320, delay: Math.max(0, stagger.indexOf(id)) * 70, easing: EASE, fill: \"backwards\" },\n          );\n          return;\n        }\n        // A glide still running is cancelled first: the position taken at the click already includes it.\n        c.getAnimations().forEach((a) => a.id === \"chip-glide\" && a.cancel());\n        const r = c.getBoundingClientRect();\n        const dx = was.x - (r.left - o.left + el.scrollLeft);\n        const dy = was.y - (r.top - o.top);\n        if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) return;\n        c.animate([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: \"none\" }], { duration: 340, easing: EASE }).id = \"chip-glide\";\n      });\n      // A chip that went is its own element, detached by React: back where it stood, fading.\n      b.forEach((was, id) => {\n        const layer = ghosts.current;\n        if (seen.has(id) || was.el.isConnected || !layer) return;\n        const g = was.el;\n        g.inert = true;\n        g.setAttribute(\"aria-hidden\", \"true\");\n        g.removeAttribute(\"data-chip\");\n        Object.assign(g.style, { position: \"absolute\", left: `${was.x}px`, top: `${was.y}px`, width: `${was.w}px`, height: `${was.h}px`, margin: \"0\" });\n        layer.appendChild(g);\n        const a = g.animate(\n          [\n            { opacity: 1, transform: \"none\", filter: \"blur(0px)\" },\n            { opacity: 0, transform: \"scale(0.92)\", filter: \"blur(3px)\" },\n          ],\n          { duration: 200, easing: EASE, fill: \"forwards\" },\n        );\n        a.onfinish = a.oncancel = () => g.remove();\n      });\n    },\n    [list, ghosts, reduced],\n  );\n  return React.useMemo(() => ({ capture, play }), [capture, play]);\n}\n\nfunction CheckBox({ on, reduced }: { on: boolean; reduced: boolean }) {\n  return (\n    <span\n      aria-hidden=\"true\"\n      className=\"flex size-3.5 shrink-0 items-center justify-center rounded-[4px] transition-[background-color,box-shadow] duration-200\"\n      style={{ background: on ? \"var(--primary)\" : \"transparent\", boxShadow: on ? \"inset 0 0 0 1px var(--primary)\" : \"inset 0 0 0 1px color-mix(in oklab, var(--foreground) 28%, transparent)\" }}\n    >\n      <svg viewBox=\"0 0 16 16\" className=\"size-3 text-primary-foreground\" fill=\"none\">\n        <path d=\"M3.5 8.5 6.5 11.5 12.5 4.5\" stroke=\"currentColor\" strokeWidth=\"2.2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" pathLength={1} strokeDasharray={1} style={{ strokeDashoffset: on ? 0 : 1, transition: reduced ? \"none\" : `stroke-dashoffset 260ms ${EASE}` }} />\n      </svg>\n    </span>\n  );\n}\n\nexport function FilterBar({\n  fields,\n  value: controlled,\n  defaultValue = NO_FILTERS,\n  onValueChange,\n  count,\n  total,\n  noun = NOUN,\n  parse,\n  hotkey = \"f\",\n  locales,\n  label = \"Filters\",\n  className = \"\",\n  ...props\n}: FilterBarProps) {\n  const reduced = useReducedMotion();\n  const uid = React.useId();\n  const [own, setOwn] = React.useState<Filter[]>(defaultValue);\n  const filters = controlled ?? own;\n  const filtersRef = React.useRef(filters);\n  filtersRef.current = filters;\n\n  const [menu, setMenu] = React.useState<Menu | null>(null);\n  const [phase, setPhase] = React.useState<Phase>(\"closed\");\n  const [step, setStep] = React.useState<string | null>(null);\n  const [query, setQuery] = React.useState(\"\");\n  const [active, setActive] = React.useState(0);\n  const [parsing, setParsing] = React.useState<{ status: \"idle\" | \"pending\" | \"error\"; text?: string }>({ status: \"idle\" });\n  const [box, setBox] = React.useState<Box>({ left: 0, top: 0, width: 0, height: 28 });\n  const [wrapW, setWrapW] = React.useState(PANEL_W);\n  const [panelH, setPanelH] = React.useState(0);\n  const [leaving, setLeaving] = React.useState<{ rows: Row[]; dir: number; key: number } | null>(null);\n  const [slide, setSlide] = React.useState({ dir: 0, key: 0 });\n  const [announce, setAnnounce] = React.useState(\"\");\n\n  const wrapRef = React.useRef<HTMLDivElement>(null);\n  const listRef = React.useRef<HTMLDivElement>(null);\n  const ghostRef = React.useRef<HTMLDivElement>(null);\n  const addRef = React.useRef<HTMLButtonElement>(null);\n  const panelRef = React.useRef<HTMLDivElement>(null);\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const parseRowRef = React.useRef<HTMLDivElement>(null);\n  const anchorRef = React.useRef<HTMLElement | null>(null);\n  const timer = React.useRef(0);\n  const stagger = React.useRef<string[]>([]);\n  const motion = useChipMotion(listRef, ghostRef, reduced);\n\n  const num = React.useMemo(() => new Intl.NumberFormat(locales), [locales]);\n  const fieldOf = React.useCallback((id: string) => fields.find((f) => f.id === id), [fields]);\n  const say = (text: string) => setAnnounce((a) => (a === text ? `${text} ` : text));\n  const sentence = (f: Filter | NewFilter) => {\n    const field = fieldOf(f.field);\n    const w = words(f, field, num);\n    return `${field?.label ?? f.field} ${w.op} ${w.value}`;\n  };\n\n  /** Commits filters; `move` takes the chips' positions first so they glide into the new layout. */\n  const commit = (next: Filter[], move = true, enter: string[] = []) => {\n    if (move) motion.capture();\n    stagger.current = enter;\n    filtersRef.current = next;\n    if (controlled === undefined) setOwn(next);\n    onValueChange?.(next);\n  };\n\n  React.useLayoutEffect(() => {\n    motion.play(stagger.current);\n    stagger.current = [];\n  }, [filters, motion]);\n\n  // ── The panel ──────────────────────────────────────────\n  const open = (m: Menu, anchor: HTMLElement) => {\n    const wrap = wrapRef.current;\n    if (!wrap) return;\n    window.clearTimeout(timer.current);\n    const a = anchor.getBoundingClientRect();\n    const w = wrap.getBoundingClientRect();\n    setBox({ left: a.left - w.left, top: a.top - w.top, width: a.width, height: a.height });\n    setWrapW(w.width);\n    anchorRef.current = anchor;\n    const editing = m.mode === \"edit\" ? filtersRef.current.find((f) => f.id === m.filter) : undefined;\n    setMenu(m);\n    setStep(editing ? editing.field : null);\n    setQuery(editing && \"text\" in editing ? editing.text : editing && \"number\" in editing ? String(editing.number) : \"\");\n    setActive(0);\n    setLeaving(null);\n    setSlide((s) => ({ dir: 0, key: s.key + 1 }));\n    setParsing({ status: \"idle\" });\n    setPhase(reduced ? \"open\" : \"opening\");\n  };\n\n  const close = React.useCallback(\n    (refocus = true) => {\n      window.clearTimeout(timer.current);\n      // A chip whose values were all ticked off goes when the panel closes.\n      const empty = filtersRef.current.filter((f) => \"values\" in f && !f.values.length);\n      if (empty.length) {\n        motion.capture();\n        const next = filtersRef.current.filter((f) => !empty.includes(f));\n        filtersRef.current = next;\n        if (controlled === undefined) setOwn(next);\n        onValueChange?.(next);\n      }\n      if (refocus) {\n        const back = anchorRef.current;\n        (back?.isConnected ? back : addRef.current)?.focus({ preventScroll: true });\n      }\n      if (reduced) {\n        setPhase(\"closed\");\n        setMenu(null);\n        return;\n      }\n      setPhase(\"closing\");\n      timer.current = window.setTimeout(() => {\n        setPhase(\"closed\");\n        setMenu(null);\n      }, CLOSE);\n    },\n    [controlled, onValueChange, reduced, motion],\n  );\n\n  React.useEffect(() => {\n    if (phase !== \"opening\") return;\n    let inner = 0;\n    const outer = requestAnimationFrame(() => {\n      inner = requestAnimationFrame(() => setPhase(\"open\"));\n    });\n    return () => {\n      cancelAnimationFrame(outer);\n      cancelAnimationFrame(inner);\n    };\n  }, [phase]);\n\n  React.useEffect(() => () => window.clearTimeout(timer.current), []);\n\n  React.useEffect(() => {\n    if (menu && phase !== \"closing\") inputRef.current?.focus({ preventScroll: true });\n  }, [menu, phase]);\n\n  React.useLayoutEffect(() => {\n    const panel = panelRef.current;\n    if (!panel) return;\n    const measure = () => setPanelH(panel.offsetHeight);\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(panel);\n    return () => observer.disconnect();\n  }, [menu]);\n\n  // A press outside closes it.\n  React.useEffect(() => {\n    if (!menu || phase === \"closing\") return;\n    const onDown = (e: PointerEvent) => {\n      if (!wrapRef.current?.contains(e.target as Node)) close(false);\n    };\n    document.addEventListener(\"pointerdown\", onDown);\n    return () => document.removeEventListener(\"pointerdown\", onDown);\n  }, [menu, phase, close]);\n\n  // F (or your key) opens it from anywhere, unless something is being typed.\n  React.useEffect(() => {\n    if (!hotkey) return;\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key.toLowerCase() !== hotkey.toLowerCase() || e.metaKey || e.ctrlKey || e.altKey || e.isComposing || menu) return;\n      const t = e.target as HTMLElement | null;\n      if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))) return;\n      const r = wrapRef.current?.getBoundingClientRect();\n      if (!r || r.bottom < 0 || r.top > window.innerHeight || !addRef.current) return;\n      e.preventDefault();\n      open({ mode: \"new\" }, addRef.current);\n    };\n    document.addEventListener(\"keydown\", onKey);\n    return () => document.removeEventListener(\"keydown\", onKey);\n  });\n\n  const editing = menu?.mode === \"edit\" ? filters.find((f) => f.id === menu.filter) : undefined;\n  const stepField = step ? fieldOf(step) : undefined;\n\n  // ── What the panel lists ───────────────────────────────\n  const rows: Row[] = (() => {\n    const q = query.trim().toLowerCase();\n    if (!menu) return [];\n    if (!stepField) {\n      const out: Row[] = fields.filter((f) => !q || f.label.toLowerCase().includes(q)).map((field) => ({ kind: \"field\", field }));\n      if (q)\n        for (const field of fields)\n          for (const option of field.type === \"option\" ? (field.options ?? []) : [])\n            if (option.label.toLowerCase().includes(q) && out.length < 8) out.push({ kind: \"jump\", field, option });\n      if (parse && q.length > 2) out.push({ kind: \"parse\", text: query.trim() });\n      if (!out.length) out.push({ kind: \"hint\", text: \"No properties match\" });\n      return out;\n    }\n    const field = stepField;\n    if (field.type === \"option\") {\n      const ticked = editing && \"values\" in editing ? editing.values : [];\n      const out: Row[] = (field.options ?? []).filter((o) => !q || o.label.toLowerCase().includes(q)).map((option) => ({ kind: \"option\", field, option, checked: ticked.includes(option.value) }));\n      return out.length ? out : [{ kind: \"hint\", text: `No ${field.label.toLowerCase()} matches` }];\n    }\n    if (field.type === \"text\") {\n      const t = query.trim();\n      if (!t) return [{ kind: \"hint\", text: `Type to filter by ${field.label.toLowerCase()}` }];\n      return [\n        { kind: \"set\", field, filter: { field: field.id, op: \"contains\", text: t }, label: `Contains “${t}”` },\n        { kind: \"set\", field, filter: { field: field.id, op: \"does not contain\", text: t }, label: `Doesn't contain “${t}”` },\n      ];\n    }\n    if (field.type === \"number\") {\n      const n = Number(query.replace(/[^\\d.-]/g, \"\"));\n      if (!query.trim() || Number.isNaN(n)) return [{ kind: \"hint\", text: \"Type a number\" }];\n      const unit = field.unit ? ` ${n === 1 ? field.unit[0] : field.unit[1]}` : \"\";\n      return ([\"at least\", \"at most\", \"is exactly\"] as const).map((op) => ({\n        kind: \"set\" as const,\n        field,\n        filter: { field: field.id, op, number: n },\n        label: `${op[0].toUpperCase()}${op.slice(1)} ${num.format(n)}${unit}`,\n      }));\n    }\n    const n = parseInt(query, 10);\n    const typed: Row[] =\n      n > 0\n        ? [\n            { kind: \"set\", field, filter: { field: field.id, op: \"within\", days: n }, label: `Last ${dayLabel(n)}` },\n            { kind: \"set\", field, filter: { field: field.id, op: \"older than\", days: n }, label: `More than ${dayLabel(n)} ago` },\n          ]\n        : [];\n    return [...typed, ...DATE_PRESETS.filter((p) => !q || p.label.toLowerCase().includes(q)).map((p) => ({ kind: \"set\" as const, field, filter: { field: field.id, op: p.op, days: p.days }, label: p.label }))];\n  })();\n  const choosable = rows.filter((r) => r.kind !== \"hint\");\n  const activeIndex = Math.min(active, Math.max(0, choosable.length - 1));\n\n  /** To a property's values (dir 1) or back to the properties (dir -1); the old list slides out the other way. */\n  const goStep = (next: string | null, dir: number) => {\n    if (!reduced) setLeaving({ rows, dir, key: Date.now() });\n    setSlide((s) => ({ dir, key: s.key + 1 }));\n    setStep(next);\n    setQuery(\"\");\n    setActive(0);\n    setParsing({ status: \"idle\" });\n  };\n\n  const addFilter = (draft: NewFilter) => {\n    const added: string[] = [];\n    const next = merge(filtersRef.current, draft, added);\n    commit(next, true, added);\n    say(`Filter added: ${sentence(draft)}.`);\n  };\n\n  const toggleValue = (field: FilterField, option: FilterOption) => {\n    if (editing && \"values\" in editing) {\n      const on = editing.values.includes(option.value);\n      const values = on ? editing.values.filter((v) => v !== option.value) : [...editing.values, option.value];\n      commit(filtersRef.current.map((f) => (f.id === editing.id ? { ...editing, values } : f)), false);\n      say(`${option.label} ${on ? \"removed from\" : \"added to\"} ${field.label}.`);\n      return;\n    }\n    // From \"Filter\": the first tick makes the chip and the panel goes on editing it.\n    const added: string[] = [];\n    const next = merge(filtersRef.current, { field: field.id, op: \"is\", values: [option.value] }, added);\n    commit(next, true, added);\n    const made = next.find((f) => f.field === field.id && f.op === \"is\");\n    if (made) setMenu({ mode: \"edit\", filter: made.id });\n    say(`Filter added: ${field.label} is ${option.label}.`);\n  };\n\n  const runParse = async (text: string) => {\n    if (!parse || parsing.status === \"pending\") return;\n    setParsing({ status: \"pending\", text });\n    try {\n      const made = await parse(text);\n      if (!made?.length) throw new Error(\"Nothing found\");\n      let next = filtersRef.current;\n      const added: string[] = [];\n      for (const d of made) next = merge(next, d, added);\n      setParsing({ status: \"idle\" });\n      commit(next, true, added);\n      say(`Added ${made.map(sentence).join(\", \")}.`);\n      close();\n    } catch {\n      setParsing({ status: \"error\", text });\n      say(`Couldn't turn “${text}” into filters.`);\n      if (!reduced)\n        parseRowRef.current?.animate([{ transform: \"none\" }, { transform: \"translateX(-4px)\" }, { transform: \"translateX(4px)\" }, { transform: \"translateX(-2px)\" }, { transform: \"none\" }], { duration: 380, easing: \"ease-out\" });\n    }\n  };\n\n  const choose = (row: Row, viaBox = false) => {\n    switch (row.kind) {\n      case \"field\":\n        goStep(row.field.id, 1);\n        return;\n      case \"jump\":\n        addFilter({ field: row.field.id, op: \"is\", values: [row.option.value] });\n        close();\n        return;\n      case \"option\":\n        if (editing || viaBox) toggleValue(row.field, row.option);\n        else {\n          addFilter({ field: row.field.id, op: \"is\", values: [row.option.value] });\n          close();\n        }\n        return;\n      case \"set\":\n        if (editing) {\n          const next = { ...row.filter, id: editing.id } as Filter;\n          commit(filtersRef.current.map((f) => (f.id === editing.id ? next : f)), false);\n          say(`Filter changed: ${sentence(next)}.`);\n        } else addFilter(row.filter);\n        close();\n        return;\n      case \"parse\":\n        runParse(row.text);\n        return;\n    }\n  };\n\n  const onInputKey = (e: React.KeyboardEvent<HTMLInputElement>) => {\n    const last = choosable.length - 1;\n    const canBack = menu?.mode === \"new\" && !!step;\n    if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n      e.preventDefault();\n      if (last < 0) return;\n      setActive(e.key === \"ArrowDown\" ? (activeIndex >= last ? 0 : activeIndex + 1) : activeIndex <= 0 ? last : activeIndex - 1);\n    } else if (e.key === \"Enter\") {\n      e.preventDefault();\n      const row = choosable[activeIndex];\n      if (row) choose(row);\n    } else if (e.key === \"Escape\") {\n      e.preventDefault();\n      e.stopPropagation();\n      if (canBack) goStep(null, -1);\n      else close();\n    } else if ((e.key === \"Backspace\" || e.key === \"ArrowLeft\") && !query && canBack) {\n      e.preventDefault();\n      goStep(null, -1);\n    } else if (e.key === \"ArrowRight\" && !query && choosable[activeIndex]?.kind === \"field\") {\n      e.preventDefault();\n      choose(choosable[activeIndex]);\n    }\n  };\n\n  // ── Chips ──────────────────────────────────────────────\n  const flipOp = (f: Filter) => {\n    const field = fieldOf(f.field);\n    const ops = OPS[field?.type ?? \"option\"];\n    const op = ops[(ops.indexOf(f.op) + 1) % ops.length];\n    const next = { ...f, op } as Filter;\n    commit(filtersRef.current.map((x) => (x.id === f.id ? next : x)), false);\n    say(`${sentence(next)}.`);\n  };\n\n  const remove = (f: Filter) => {\n    commit(filtersRef.current.filter((x) => x.id !== f.id));\n    say(`Removed ${fieldOf(f.field)?.label ?? f.field} filter.`);\n    requestAnimationFrame(() => addRef.current?.focus({ preventScroll: true }));\n  };\n\n  const clearAll = () => {\n    commit([]);\n    say(\"Filters cleared.\");\n    requestAnimationFrame(() => addRef.current?.focus({ preventScroll: true }));\n  };\n\n  // ── Render ─────────────────────────────────────────────\n  const mounted = menu !== null;\n  const expanded = phase === \"open\";\n  const width = Math.min(PANEL_W, wrapW);\n  const left = Math.max(0, Math.min(box.left, wrapW - width));\n  const top = box.top + box.height + 6;\n  const listId = `${uid}-list`;\n  const optionId = (i: number) => `${uid}-o${i}`;\n  const filtered = filters.length > 0;\n  const placeholder = editing ? (stepField?.type === \"option\" ? `Find ${stepField.label.toLowerCase()}…` : `${stepField?.label}…`) : stepField ? `${stepField.label}…` : parse ? \"Filter by property, or describe it…\" : \"Filter by…\";\n\n  const renderRows = (list: Row[], live: boolean) => {\n    let n = -1;\n    return list.map((row, i) => {\n      if (row.kind === \"hint\")\n        return (\n          <p key={`hint-${i}`} className=\"flex h-8 items-center px-2 text-[12.5px] text-muted-foreground\">\n            {row.text}\n          </p>\n        );\n      n++;\n      const index = n;\n      const on = live && index === activeIndex;\n      const key = row.kind === \"field\" ? `f-${row.field.id}` : row.kind === \"jump\" ? `j-${row.field.id}-${row.option.value}` : row.kind === \"option\" ? `o-${row.option.value}` : row.kind === \"set\" ? `s-${row.label}` : \"parse\";\n      const pending = row.kind === \"parse\" && parsing.status === \"pending\";\n      const failed = row.kind === \"parse\" && parsing.status === \"error\" && parsing.text === row.text;\n      return (\n        <div\n          key={key}\n          ref={row.kind === \"parse\" && live ? parseRowRef : undefined}\n          id={live ? optionId(index) : undefined}\n          role={live ? \"option\" : undefined}\n          aria-selected={live ? on : undefined}\n          aria-checked={live && row.kind === \"option\" ? row.checked : undefined}\n          onPointerMove={live ? () => index !== activeIndex && setActive(index) : undefined}\n          onPointerDown={(e) => e.preventDefault()}\n          onClick={live ? () => choose(row) : undefined}\n          className={`relative flex h-8 cursor-pointer items-center gap-2 rounded-[8px] px-2 text-[13px] ${on ? \"text-foreground\" : \"text-foreground/90\"}`}\n        >\n          {row.kind === \"option\" && (\n            <span\n              className=\"-m-1.5 flex size-7 items-center justify-center\"\n              onClick={(e) => {\n                if (!live) return;\n                e.stopPropagation();\n                choose(row, true);\n              }}\n            >\n              <CheckBox on={row.checked} reduced={reduced} />\n            </span>\n          )}\n          {row.kind === \"parse\" ? (\n            <span className={`flex size-4 shrink-0 items-center justify-center ${failed ? \"text-red-600 dark:text-red-400\" : \"text-muted-foreground\"}`} aria-hidden=\"true\">\n              {pending ? <span className=\"size-3.5 animate-spin rounded-full border-[1.5px] border-current border-r-transparent\" /> : <Sparkles className=\"size-3.5\" strokeWidth={1.75} />}\n            </span>\n          ) : row.kind === \"field\" ? (\n            <span className=\"flex size-4 shrink-0 items-center justify-center text-muted-foreground [&_svg]:size-3.5\" aria-hidden=\"true\">\n              {row.field.icon ?? <ListFilter strokeWidth={1.75} />}\n            </span>\n          ) : row.kind === \"jump\" || row.kind === \"option\" ? (\n            row.option.icon ? (\n              <span className=\"flex size-4 shrink-0 items-center justify-center text-muted-foreground [&_svg]:size-3.5\" aria-hidden=\"true\">\n                {row.option.icon}\n              </span>\n            ) : null\n          ) : null}\n          {row.kind === \"jump\" && (\n            <span className=\"flex shrink-0 items-center gap-1 text-muted-foreground\">\n              {row.field.label}\n              <ChevronRight className=\"size-3\" strokeWidth={2} aria-hidden=\"true\" />\n            </span>\n          )}\n          <span className={`min-w-0 flex-1 truncate ${failed ? \"text-red-600 dark:text-red-400\" : \"\"}`}>\n            {row.kind === \"field\" ? row.field.label : row.kind === \"jump\" || row.kind === \"option\" ? row.option.label : row.kind === \"set\" ? row.label : (\n              <TextMorph animateWidth={false}>{pending ? \"Working out the filters\" : failed ? \"Couldn't make filters from that\" : `Filter by “${row.text}”`}</TextMorph>\n            )}\n          </span>\n          {row.kind === \"field\" && <ChevronRight className=\"size-3.5 shrink-0 text-muted-foreground\" strokeWidth={1.75} aria-hidden=\"true\" />}\n        </div>\n      );\n    });\n  };\n\n  return (\n    <div\n      ref={wrapRef}\n      className={`@container relative w-full text-foreground ${className}`}\n      onBlur={(e) => {\n        // Focus leaving for another window (a screenshot, a switch of app) keeps it open; leaving the bar closes it.\n        if (!menu || phase === \"closing\" || !document.hasFocus() || wrapRef.current?.contains(e.relatedTarget as Node)) return;\n        close(false);\n      }}\n      {...props}\n    >\n      <div className=\"flex items-start gap-2\">\n        <div\n          ref={listRef}\n          role=\"group\"\n          aria-label={label}\n          className=\"relative -m-1 flex min-w-0 flex-1 flex-wrap items-center gap-1.5 p-1 @max-[30rem]:flex-nowrap @max-[30rem]:overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\"\n        >\n          {filters.map((f) => {\n            const field = fieldOf(f.field);\n            const w = words(f, field, num);\n            const isOpen = mounted && editing?.id === f.id && phase !== \"closing\";\n            return (\n              <div key={f.id} data-chip={f.id} role=\"group\" aria-label={sentence(f)} className=\"inline-flex h-7 shrink-0 items-center rounded-full bg-background pl-2.5 pr-0.5 text-[12.5px] shadow-[inset_0_0_0_1px_var(--border)]\">\n                <span className=\"flex items-center gap-1.5 whitespace-nowrap pr-1.5 text-muted-foreground [&_svg]:size-3.5\">\n                  {field?.icon && <span aria-hidden=\"true\">{field.icon}</span>}\n                  {field?.label ?? f.field}\n                </span>\n                <span aria-hidden=\"true\" className=\"h-3.5 w-px bg-border\" />\n                <button\n                  type=\"button\"\n                  onClick={() => flipOp(f)}\n                  aria-label={`${field?.label}: “${w.op}”. Change it`}\n                  title=\"Change\"\n                  className={`mx-0.5 inline-flex h-6 cursor-pointer items-center whitespace-nowrap rounded-full px-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground ${FOCUS}`}\n                >\n                  <TextMorph>{w.op}</TextMorph>\n                </button>\n                <span aria-hidden=\"true\" className=\"h-3.5 w-px bg-border\" />\n                <button\n                  type=\"button\"\n                  aria-haspopup=\"listbox\"\n                  aria-expanded={isOpen}\n                  aria-label={`${field?.label} ${w.op} ${w.value}. Change the value`}\n                  onClick={(e) => (isOpen ? close() : open({ mode: \"edit\", filter: f.id }, e.currentTarget))}\n                  className={`mx-0.5 inline-flex h-6 cursor-pointer items-center gap-1.5 whitespace-nowrap rounded-full px-1.5 font-medium transition-colors hover:bg-accent ${isOpen ? \"bg-accent\" : \"\"} ${FOCUS}`}\n                >\n                  {w.icon && (\n                    <span aria-hidden=\"true\" className=\"flex text-muted-foreground [&_svg]:size-3.5\">\n                      {w.icon}\n                    </span>\n                  )}\n                  <TextMorph>{w.value}</TextMorph>\n                </button>\n                <button\n                  type=\"button\"\n                  onClick={() => remove(f)}\n                  aria-label={`Remove ${field?.label ?? f.field} filter`}\n                  className={`flex size-6 cursor-pointer items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground ${FOCUS}`}\n                >\n                  <X className=\"size-3.5\" strokeWidth={1.75} />\n                </button>\n              </div>\n            );\n          })}\n\n          {/* \"Filter\" says its name until there are filters; then it's the + at the end of them. */}\n          <button\n            ref={addRef}\n            data-chip=\"__add\"\n            type=\"button\"\n            aria-haspopup=\"listbox\"\n            aria-expanded={mounted && menu?.mode === \"new\" && phase !== \"closing\"}\n            aria-keyshortcuts={hotkey ?? undefined}\n            aria-label={filtered ? \"Add filter\" : undefined}\n            onClick={(e) => (mounted && menu?.mode === \"new\" ? close() : open({ mode: \"new\" }, e.currentTarget))}\n            className={`inline-flex h-7 shrink-0 cursor-pointer items-center rounded-full px-2 text-[12.5px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground @max-[30rem]:order-first ${mounted && menu?.mode === \"new\" ? \"bg-accent text-foreground\" : \"\"} ${filtered ? \"@max-[30rem]:shadow-[inset_0_0_0_1px_var(--border)]\" : \"shadow-[inset_0_0_0_1px_var(--border)]\"} ${FOCUS}`}\n          >\n            <span className=\"relative flex size-3.5 items-center justify-center\" aria-hidden=\"true\">\n              <ListFilter className=\"absolute size-3.5\" strokeWidth={1.75} style={swap(!filtered, reduced)} />\n              <Plus className=\"absolute size-3.5\" strokeWidth={1.75} style={swap(filtered, reduced)} />\n            </span>\n            <span className=\"grid\" style={{ gridTemplateColumns: filtered ? \"0fr\" : \"1fr\", opacity: filtered ? 0 : 1, transition: reduced ? \"none\" : `grid-template-columns 320ms ${EASE}, opacity 200ms ${EASE}` }}>\n              <span className=\"min-w-0 overflow-hidden whitespace-nowrap pl-1.5\">Filter</span>\n            </span>\n          </button>\n\n          {filtered && (\n            <button\n              data-chip=\"__clear\"\n              type=\"button\"\n              onClick={clearAll}\n              className={`inline-flex h-7 shrink-0 cursor-pointer items-center rounded-full px-2 text-[12.5px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground ${FOCUS}`}\n            >\n              Clear\n            </button>\n          )}\n\n          {/* Chips that went: their own elements, fading where they stood. */}\n          <div ref={ghostRef} aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0\" />\n        </div>\n\n        {count !== undefined && (\n          <span className=\"flex h-7 shrink-0 items-center whitespace-nowrap text-[12px] tabular-nums text-muted-foreground\">\n            <NumberRoll value={count} locales={locales} />\n            <span className=\"grid\" style={{ gridTemplateColumns: filtered && total !== undefined ? \"1fr\" : \"0fr\", opacity: filtered && total !== undefined ? 1 : 0, transition: reduced ? \"none\" : `grid-template-columns 320ms ${EASE}, opacity 200ms ${EASE}` }}>\n              <span className=\"min-w-0 overflow-hidden whitespace-nowrap\">&nbsp;of {total !== undefined ? num.format(total) : \"\"}</span>\n            </span>\n            &nbsp;{count === 1 ? noun[0] : noun[1]}\n          </span>\n        )}\n      </div>\n\n      {/* The panel grows out from under whatever opened it, and folds back there. */}\n      {mounted && (\n        <div\n          className=\"absolute z-50 overflow-hidden bg-popover text-popover-foreground\"\n          style={{\n            top,\n            left: expanded ? left : box.left,\n            width: expanded ? width : box.width,\n            height: expanded ? panelH : box.height,\n            borderRadius: expanded ? 14 : box.height / 2,\n            opacity: expanded ? 1 : 0,\n            boxShadow: \"0 0 0 1px var(--border)\",\n            transition: reduced\n              ? \"none\"\n              : phase === \"closing\"\n                ? `width ${CLOSE}ms ${UI}, height ${CLOSE}ms ${UI}, left ${CLOSE}ms ${UI}, border-radius ${CLOSE}ms ${UI}, opacity ${CLOSE}ms ${UI}`\n                : `width ${OPEN}ms ${UI}, height ${OPEN}ms ${UI}, left ${OPEN}ms ${UI}, border-radius ${OPEN}ms ${UI}, opacity 160ms ${UI}`,\n          }}\n        >\n          <div ref={panelRef} inert={!expanded} className=\"absolute left-0 top-0\" style={{ width, opacity: expanded ? 1 : 0, transition: reduced ? \"none\" : expanded ? `opacity 180ms ${EASE} 60ms` : \"opacity 90ms ease-out\" }}>\n            <div className=\"flex h-10 items-center gap-1 border-b border-border pl-1.5 pr-2\">\n              {menu?.mode === \"new\" && step ? (\n                <button\n                  type=\"button\"\n                  aria-label=\"Back to properties\"\n                  onPointerDown={(e) => e.preventDefault()}\n                  onClick={() => goStep(null, -1)}\n                  className=\"flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-full text-muted-foreground hover:bg-accent hover:text-foreground\"\n                >\n                  <ChevronLeft className=\"size-4\" strokeWidth={1.75} />\n                </button>\n              ) : (\n                <span className=\"flex size-7 shrink-0 items-center justify-center text-muted-foreground [&_svg]:size-3.5\" aria-hidden=\"true\">\n                  {stepField?.icon ?? <ListFilter strokeWidth={1.75} />}\n                </span>\n              )}\n              <input\n                ref={inputRef}\n                role=\"combobox\"\n                aria-expanded={expanded}\n                aria-controls={listId}\n                aria-autocomplete=\"list\"\n                aria-activedescendant={choosable.length ? optionId(activeIndex) : undefined}\n                aria-label={placeholder}\n                value={query}\n                onChange={(e) => {\n                  setQuery(e.target.value);\n                  setActive(0);\n                  if (parsing.status === \"error\") setParsing({ status: \"idle\" });\n                }}\n                onKeyDown={onInputKey}\n                placeholder={placeholder}\n                inputMode={stepField?.type === \"number\" || stepField?.type === \"date\" ? \"decimal\" : undefined}\n                className=\"h-9 min-w-0 flex-1 bg-transparent text-[13px] outline-none placeholder:text-muted-foreground [@media(pointer:coarse)]:text-[16px]\"\n              />\n            </div>\n            <div className=\"relative max-h-[296px] overflow-y-auto overflow-x-clip p-1\">\n              <div\n                key={slide.key}\n                id={listId}\n                role=\"listbox\"\n                aria-label={stepField ? stepField.label : \"Properties\"}\n                aria-multiselectable={stepField?.type === \"option\" ? true : undefined}\n                className=\"relative\"\n                ref={(el) => {\n                  if (!el || !slide.dir || el.dataset.in || reduced) return;\n                  el.dataset.in = \"1\";\n                  el.animate(\n                    [\n                      { transform: `translateX(${slide.dir * 16}px)`, opacity: 0 },\n                      { transform: \"none\", opacity: 1 },\n                    ],\n                    { duration: 240, easing: EASE, fill: \"backwards\" },\n                  );\n                }}\n              >\n                {/* The highlight glides between rows; rows are a fixed height, so its place needs no measuring. */}\n                <span\n                  aria-hidden=\"true\"\n                  className=\"pointer-events-none absolute inset-x-0 top-0 h-8 rounded-[8px] bg-accent\"\n                  style={{ transform: `translateY(${activeIndex * ROW}px)`, opacity: choosable.length ? 1 : 0, transition: reduced ? \"none\" : `transform 160ms ${UI}` }}\n                />\n                {renderRows(rows, true)}\n              </div>\n              {leaving && (\n                <div\n                  key={leaving.key}\n                  aria-hidden=\"true\"\n                  className=\"pointer-events-none absolute inset-x-1 top-1\"\n                  ref={(el) => {\n                    if (!el || el.dataset.out) return;\n                    el.dataset.out = \"1\";\n                    el.animate(\n                      [\n                        { transform: \"none\", opacity: 1 },\n                        { transform: `translateX(${-leaving.dir * 16}px)`, opacity: 0 },\n                      ],\n                      { duration: 160, easing: EASE, fill: \"forwards\" },\n                    ).onfinish = () => setLeaving((l) => (l?.key === leaving.key ? null : l));\n                  }}\n                >\n                  {renderRows(leaving.rows, false)}\n                </div>\n              )}\n            </div>\n            {editing && stepField?.type === \"option\" && (\n              <div className=\"flex h-9 items-center justify-between border-t border-border pl-3 pr-1.5 text-[12px] text-muted-foreground\">\n                <span className=\"tabular-nums\">\n                  <NumberRoll value={\"values\" in editing ? editing.values.length : 0} /> selected\n                </span>\n                <button\n                  type=\"button\"\n                  onPointerDown={(e) => e.preventDefault()}\n                  onClick={() => close()}\n                  className=\"inline-flex h-7 cursor-pointer items-center gap-1 rounded-full px-2.5 font-medium text-foreground hover:bg-accent\"\n                >\n                  <Check className=\"size-3.5\" strokeWidth={2} aria-hidden=\"true\" />\n                  Done\n                </button>\n              </div>\n            )}\n          </div>\n        </div>\n      )}\n\n      <p className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n        {announce}\n      </p>\n    </div>\n  );\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/filter-bar. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}