{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"filter-menu","type":"registry:ui","title":"Filter Menu","description":"Grouped filters with removable chips, collapsible checkbox groups, Clear all and full keyboard control","dependencies":[],"files":[{"path":"registry/filter-menu/filter-menu.tsx","type":"registry:ui","content":"\"use client\";\n\nimport { useId, useLayoutEffect, useRef, useState, type KeyboardEvent, type RefObject } from \"react\";\n\n/*\n * Filter Menu – grouped filters with chips, in a single self-contained file.\n * Active filters sit on top as removable chips; below, collapsible groups list their options as checkboxes\n * with a count of what's picked. Arrow keys move through groups and options, Enter or Space picks,\n * ← and → fold a group. Heights glide when groups open or chips come and go.\n * The styles are injected below, so no extra files are needed.\n */\n\nexport type FilterOption = { id: string; label: string };\n\nexport type FilterGroup = {\n  id: string;\n  label: string;\n  options: FilterOption[];\n  /** Put in front of the option on its chip, e.g. \"Score: \" → \"Score: 80–90\" */\n  chipPrefix?: string;\n};\n\n/** Picked option ids per group id */\nexport type FilterValue = Record<string, string[]>;\n\nexport type FilterMenuProps = {\n  groups: FilterGroup[];\n  /** Controlled value */\n  value?: FilterValue;\n  /** Starting value when uncontrolled */\n  defaultValue?: FilterValue;\n  onChange?: (value: FilterValue) => void;\n  /** Groups that start open – by default the first one */\n  defaultOpen?: string[];\n  title?: string;\n  /** Show the keyboard hints at the bottom */\n  hints?: boolean;\n  className?: string;\n};\n\ntype Row = { kind: \"group\"; group: FilterGroup; gi: number } | { kind: \"option\"; group: FilterGroup; option: FilterOption; gi: number; oi: number };\n\nexport function FilterMenu({\n  groups: rawGroups,\n  value,\n  defaultValue = {},\n  onChange,\n  defaultOpen,\n  title = \"Filters\",\n  hints = true,\n  className,\n}: FilterMenuProps) {\n  // Gaps, groups without an options list and options without a label are skipped; ids stay as given\n  const groups = (Array.isArray(rawGroups) ? rawGroups : [])\n    .filter((g): g is FilterGroup => !!g && typeof g === \"object\")\n    .map((g) => ({\n      ...g,\n      label: String(g.label ?? \"\"),\n      options: (Array.isArray(g.options) ? g.options : []).filter((o): o is FilterOption => !!o && typeof o === \"object\").map((o) => ({ ...o, label: String(o.label ?? \"\") })),\n    }));\n  const uid = useId();\n  const [inner, setInner] = useState<FilterValue>(defaultValue);\n  const picked = value ?? inner;\n  const [open, setOpen] = useState<Set<string>>(() => new Set(defaultOpen ?? groups.slice(0, 1).map((g) => g.id)));\n  const [focus, setFocus] = useState<string | null>(null);\n  const listRef = useRef<HTMLDivElement>(null);\n  const chipsInner = useRef<HTMLDivElement>(null);\n  const chipsOuter = useSmoothHeight(chipsInner);\n\n  const commit = (next: FilterValue) => {\n    if (value === undefined) setInner(next);\n    onChange?.(next);\n  };\n  // Only lists count – a string or a stray value in `value` picks nothing\n  const pickedIn = (g: string) => (Array.isArray(picked[g]) ? picked[g] : []);\n  const isOn = (g: string, o: string) => pickedIn(g).includes(o);\n  const toggle = (g: string, o: string) => {\n    const cur = pickedIn(g);\n    commit({ ...picked, [g]: cur.includes(o) ? cur.filter((x) => x !== o) : [...cur, o] });\n  };\n  const toggleGroup = (g: string) =>\n    setOpen((s) => {\n      const next = new Set(s);\n      if (next.has(g)) next.delete(g);\n      else next.add(g);\n      return next;\n    });\n\n  // Chips in the order of the groups and their options – one per picked id, even if an id repeats\n  const seen = new Set<string>();\n  const chips = groups.flatMap((g) =>\n    g.options\n      .filter((o) => isOn(g.id, o.id) && !seen.has(`${g.id}\\u0000${o.id}`) && !!seen.add(`${g.id}\\u0000${o.id}`))\n      .map((o) => ({ group: g, option: o, label: `${g.chipPrefix ?? \"\"}${o.label}` })),\n  );\n\n  // Everything the arrow keys can reach, top to bottom\n  // Keys come from positions, so groups or options that share an id stay reachable\n  const rows: Row[] = groups.flatMap((g, gi) => [\n    { kind: \"group\", group: g, gi } as Row,\n    ...(open.has(g.id) ? g.options.map((o, oi) => ({ kind: \"option\", group: g, option: o, gi, oi }) as Row) : []),\n  ]);\n  const keyOf = (r: Row) => (r.kind === \"group\" ? `g:${r.gi}` : `o:${r.gi}:${r.oi}`);\n\n  const moveTo = (key: string) => {\n    setFocus(key);\n    listRef.current?.querySelector<HTMLElement>(`[data-key=\"${CSS.escape(key)}\"]`)?.focus();\n  };\n\n  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n    const i = rows.findIndex((r) => keyOf(r) === focus);\n    const row = rows[i];\n    if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n      e.preventDefault();\n      const next = rows[Math.max(0, Math.min(rows.length - 1, i + (e.key === \"ArrowDown\" ? 1 : -1)))] ?? rows[0];\n      moveTo(keyOf(next));\n    } else if (e.key === \"Home\" || e.key === \"End\") {\n      e.preventDefault();\n      moveTo(keyOf(rows[e.key === \"Home\" ? 0 : rows.length - 1]));\n    } else if (row && (e.key === \"ArrowRight\" || e.key === \"ArrowLeft\")) {\n      e.preventDefault();\n      const g = row.group.id;\n      if (e.key === \"ArrowRight\" && !open.has(g)) toggleGroup(g);\n      if (e.key === \"ArrowLeft\") {\n        if (open.has(g)) toggleGroup(g);\n        moveTo(`g:${row.gi}`);\n      }\n    } else if (row && (e.key === \"Enter\" || e.key === \" \")) {\n      e.preventDefault();\n      if (row.kind === \"group\") toggleGroup(row.group.id);\n      else toggle(row.group.id, row.option.id);\n    }\n  };\n\n  return (\n    <div className={[\"ui-fm\", className].filter(Boolean).join(\" \")}>\n      <style href=\"ui-filter-menu\" precedence=\"default\">\n        {css}\n      </style>\n\n      <div className=\"ui-fm__card\">\n        <div className=\"ui-fm__head\">\n          <span className=\"ui-fm__title\" dir=\"auto\" title={title}>\n          {title}\n        </span>\n          <button type=\"button\" className=\"ui-fm__clear\" disabled={chips.length === 0} onClick={() => commit({})}>\n            Clear all\n          </button>\n        </div>\n\n        {/* Active filters – the box glides to its new height as chips come and go */}\n        <div className=\"ui-fm__chips-outer\" ref={chipsOuter}>\n          <div className=\"ui-fm__chips\" ref={chipsInner} aria-live=\"polite\">\n            {chips.map((c, i) => (\n              <span key={i} className=\"ui-fm__chip\">\n                <span className=\"ui-fm__chip-label\" dir=\"auto\" title={c.label}>\n                  {c.label}\n                </span>\n                <button type=\"button\" aria-label={`Remove ${c.label}`} onClick={() => toggle(c.group.id, c.option.id)}>\n                  <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n                    <path d=\"M6.5 6.5l11 11M17.5 6.5l-11 11\" />\n                  </svg>\n                </button>\n              </span>\n            ))}\n          </div>\n        </div>\n\n        <div className=\"ui-fm__groups\" ref={listRef} onKeyDown={onKeyDown}>\n          {groups.map((g, gi) => {\n            const isOpen = open.has(g.id);\n            const count = new Set(pickedIn(g.id).filter((id) => g.options.some((o) => o.id === id))).size;\n            const gKey = `g:${gi}`;\n            return (\n              <section key={`${g.id}#${gi}`} className=\"ui-fm__group\" data-open={isOpen ? \"\" : undefined}>\n                <button\n                  type=\"button\"\n                  className=\"ui-fm__group-head\"\n                  data-key={gKey}\n                  tabIndex={focus === gKey || (focus === null && gi === 0) ? 0 : -1}\n                  aria-expanded={isOpen}\n                  aria-controls={`${uid}-${gi}`}\n                  onFocus={() => setFocus(gKey)}\n                  onClick={() => toggleGroup(g.id)}\n                >\n                  <svg className=\"ui-fm__chevron\" viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n                    <path d=\"M6.5 9.5 12 15l5.5-5.5\" />\n                  </svg>\n                  <span className=\"ui-fm__group-label\" dir=\"auto\" title={g.label}>\n                  {g.label}\n                </span>\n                  <span className=\"ui-fm__group-count\">{g.options.length.toLocaleString(\"en-US\")}</span>\n                  <span className=\"ui-fm__group-picked\" aria-live=\"polite\">\n                    {count > 0 ? `${count} filter${count === 1 ? \"\" : \"s\"}` : \"\"}\n                  </span>\n                </button>\n                {/* Opens and closes by animating its grid row from 0 to the content height */}\n                <div className=\"ui-fm__panel\" id={`${uid}-${gi}`} role=\"group\" aria-label={g.label}>\n                  <div className=\"ui-fm__panel-inner\">\n                    {g.options.map((o, oi) => {\n                      const key = `o:${gi}:${oi}`;\n                      const on = isOn(g.id, o.id);\n                      return (\n                        <button\n                          key={`${o.id}#${oi}`}\n                          type=\"button\"\n                          role=\"checkbox\"\n                          aria-checked={on}\n                          className=\"ui-fm__option\"\n                          data-key={key}\n                          tabIndex={isOpen && focus === key ? 0 : -1}\n                          onFocus={() => setFocus(key)}\n                          onClick={() => toggle(g.id, o.id)}\n                        >\n                          <span className=\"ui-fm__box\" aria-hidden=\"true\">\n                            <svg viewBox=\"0 0 24 24\">\n                              <path d=\"m6 12.5 4 4 8-9\" />\n                            </svg>\n                          </span>\n                          <span className=\"ui-fm__option-label\" dir=\"auto\" title={o.label}>\n                            {o.label}\n                          </span>\n                        </button>\n                      );\n                    })}\n                  </div>\n                </div>\n              </section>\n            );\n          })}\n        </div>\n      </div>\n\n      {hints && (\n        <div className=\"ui-fm__hints\" aria-hidden=\"true\">\n          <span className=\"ui-fm__hint\">\n            <kbd>\n              <svg viewBox=\"0 0 24 24\">\n                <path d=\"M12 5v14m0 0-5-5m5 5 5-5\" />\n              </svg>\n            </kbd>\n            <kbd>\n              <svg viewBox=\"0 0 24 24\">\n                <path d=\"M12 19V5m0 0-5 5m5-5 5 5\" />\n              </svg>\n            </kbd>\n            To navigate\n          </span>\n          <span className=\"ui-fm__hint\">\n            <kbd>\n              <svg viewBox=\"0 0 24 24\">\n                <path d=\"M18 6v6.5a2 2 0 0 1-2 2H7m0 0 3.5-3.5M7 14.5 10.5 18\" />\n              </svg>\n            </kbd>\n            To select\n          </span>\n        </div>\n      )}\n    </div>\n  );\n}\n\n/** The outer box follows the inner box's height, with a transition after the first frame */\nfunction useSmoothHeight(inner: RefObject<HTMLElement | null>) {\n  const outer = useRef<HTMLDivElement>(null);\n  useLayoutEffect(() => {\n    const o = outer.current;\n    const i = inner.current;\n    if (!o || !i) return;\n    const sync = () => {\n      o.style.height = `${i.offsetHeight}px`;\n    };\n    sync();\n    const ro = new ResizeObserver(sync);\n    ro.observe(i);\n    const frame = requestAnimationFrame(() => o.setAttribute(\"data-smooth\", \"\"));\n    return () => {\n      ro.disconnect();\n      cancelAnimationFrame(frame);\n    };\n  }, [inner]);\n  return outer;\n}\n\n/* ---------- Styles ---------- */\n\nconst css = /* css */ `\n.ui-fm {\n  --ui-fm-tray: var(--aui-subtle, #f0f0f0);\n  --ui-fm-card: var(--aui-surface, #ffffff);\n  --ui-fm-card-line: var(--aui-border, #e5e5e5);\n  --ui-fm-ink: var(--aui-ink, #171717);\n  --ui-fm-text: var(--aui-ink, #404040);\n  --ui-fm-muted: var(--aui-muted, #8f8f8f);\n  --ui-fm-chip: var(--aui-subtle, #f3f3f3);\n  --ui-fm-chip-hover: #ebebeb;\n  --ui-fm-box: #f0f0f0;\n  --ui-fm-box-line: var(--aui-border-strong, #e3e3e3);\n  --ui-fm-check: var(--aui-on-primary, #ffffff);\n  --ui-fm-row-hover: var(--aui-subtle, #f7f7f7);\n  --ui-fm-kbd: #ffffff;\n  --ui-fm-focus: var(--aui-focus, rgba(0, 96, 226, 0.7));\n  --ui-fm-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);\n  --ui-fm-ease: var(--aui-ease, cubic-bezier(0.22, 1, 0.36, 1));\n  width: 100%;\n  max-width: 420px;\n  padding: 6px;\n  border-radius: calc(var(--aui-radius-lg, 16px) + 8px);\n  background: var(--ui-fm-tray);\n  font-family: var(--aui-font, \"Inter\", ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif);\n  color: var(--ui-fm-ink);\n  -webkit-font-smoothing: antialiased;\n}\n.ui-fm *, .ui-fm *::before, .ui-fm *::after { box-sizing: border-box; }\n.ui-fm button { font: inherit; color: inherit; margin: 0; cursor: pointer; -webkit-tap-highlight-color: transparent; }\n.ui-fm button:focus-visible { outline: 2px solid var(--ui-fm-focus); outline-offset: 2px; }\n\n.ui-fm__card {\n  padding: 16px 16px 10px;\n  border: 1px solid var(--ui-fm-card-line);\n  border-radius: calc(var(--aui-radius-lg, 16px) + 4px);\n  background: var(--ui-fm-card);\n  box-shadow: var(--ui-fm-shadow);\n}\n\n/* ---------- Header ---------- */\n\n.ui-fm__head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 0 2px; }\n.ui-fm__title { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; text-align: start; font-size: 15px; font-weight: 600; letter-spacing: -0.01em; }\n.ui-fm__clear {\n  flex: none;\n  padding: 4px 6px;\n  margin-right: -6px !important;\n  border: 0;\n  border-radius: calc(var(--aui-radius-sm, 7px) + 1px);\n  background: none;\n  font-size: 13px;\n  color: var(--ui-fm-muted) !important;\n  transition: color 160ms ease, background-color 160ms ease, opacity 200ms ease;\n}\n.ui-fm__clear:hover:not(:disabled) { color: var(--ui-fm-ink) !important; background: var(--ui-fm-row-hover); }\n.ui-fm__clear:disabled { opacity: 0.45; cursor: default; }\n\n/* ---------- Chips ---------- */\n\n.ui-fm__chips-outer { overflow: hidden; }\n.ui-fm__chips-outer[data-smooth] { transition: height 320ms var(--ui-fm-ease); }\n.ui-fm__chips { display: flex; flex-wrap: wrap; gap: 6px; padding-top: 12px; }\n.ui-fm__chips:empty { padding-top: 0; }\n.ui-fm__chip {\n  display: inline-flex;\n  min-width: 0;\n  max-width: 100%;\n  align-items: center;\n  gap: 2px;\n  height: 30px;\n  padding: 0 4px 0 11px;\n  border-radius: calc(var(--aui-radius-sm, 7px) + 3px);\n  background: var(--ui-fm-chip);\n  font-size: 13px;\n  color: var(--ui-fm-text);\n  white-space: nowrap;\n  animation: ui-fm-chip-in 260ms var(--ui-fm-ease) both;\n}\n.ui-fm__chip-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; }\n.ui-fm__chip button {\n  display: grid;\n  flex: none;\n  place-items: center;\n  width: 22px;\n  height: 22px;\n  padding: 0;\n  border: 0;\n  border-radius: calc(var(--aui-radius-sm, 7px) - 1px);\n  background: none;\n  color: var(--ui-fm-muted);\n  transition: background-color 160ms ease, color 160ms ease;\n}\n.ui-fm__chip button:hover { background: var(--ui-fm-chip-hover); color: var(--ui-fm-ink); }\n.ui-fm__chip svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; }\n\n/* ---------- Groups ---------- */\n\n.ui-fm__groups { display: grid; grid-template-columns: minmax(0, 1fr); padding-top: 6px; }\n.ui-fm__group-head {\n  display: flex;\n  align-items: center;\n  gap: 7px;\n  width: calc(100% + 12px);\n  height: 36px;\n  margin: 2px -6px 0 !important;\n  padding: 0 8px 0 6px;\n  border: 0;\n  border-radius: calc(var(--aui-radius-sm, 7px) + 3px);\n  background: none;\n  text-align: left;\n  transition: background-color 160ms ease;\n}\n.ui-fm__group-head:hover { background: var(--ui-fm-row-hover); }\n.ui-fm__chevron {\n  width: 16px;\n  height: 16px;\n  flex: none;\n  fill: none;\n  stroke: var(--ui-fm-text);\n  stroke-width: 1.8;\n  stroke-linecap: round;\n  stroke-linejoin: round;\n  transform: rotate(-90deg);\n  transition: transform 260ms var(--ui-fm-ease);\n}\n.ui-fm__group[data-open] .ui-fm__chevron { transform: none; }\n.ui-fm__group-label { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; text-align: start; font-size: 14px; font-weight: 500; }\n.ui-fm__group-count { flex: none; font-size: 12.5px; color: var(--ui-fm-muted); font-variant-numeric: tabular-nums; }\n.ui-fm__group-picked { flex: none; margin-left: auto; white-space: nowrap; font-size: 12.5px; color: var(--ui-fm-muted); }\n\n.ui-fm__panel {\n  display: grid;\n  grid-template-rows: 0fr;\n  transition: grid-template-rows 320ms var(--ui-fm-ease);\n}\n.ui-fm__group[data-open] .ui-fm__panel { grid-template-rows: 1fr; }\n.ui-fm__panel-inner {\n  min-height: 0;\n  overflow: hidden;\n  display: grid;\n  grid-template-columns: minmax(0, 1fr);\n  gap: 1px;\n  padding: 0 0 0;\n  opacity: 0;\n  transition: opacity 220ms ease, padding 320ms var(--ui-fm-ease);\n}\n.ui-fm__group[data-open] .ui-fm__panel-inner { opacity: 1; padding: 2px 0 6px; }\n/* A long list scrolls inside the open group, so the card keeps a sane height */\n.ui-fm__group[data-open] .ui-fm__panel-inner { max-height: 340px; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; }\n\n.ui-fm__option {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n  width: calc(100% + 12px);\n  height: 34px;\n  margin: 0 -6px !important;\n  padding: 0 6px;\n  border: 0;\n  border-radius: calc(var(--aui-radius-sm, 7px) + 3px);\n  background: none;\n  font-size: 14px;\n  color: var(--ui-fm-text) !important;\n  text-align: left;\n  transition: background-color 160ms ease;\n}\n.ui-fm__option-label { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; text-align: start; }\n.ui-fm__option:hover { background: var(--ui-fm-row-hover); }\n.ui-fm__option:focus-visible { outline-offset: -2px; }\n.ui-fm__box {\n  display: grid;\n  place-items: center;\n  width: 20px;\n  height: 20px;\n  flex: none;\n  border-radius: 6px;\n  background: var(--ui-fm-box);\n  box-shadow: inset 0 0 0 1px var(--ui-fm-box-line);\n  transition: background-color 180ms ease, box-shadow 180ms ease;\n}\n.ui-fm__box svg {\n  width: 14px;\n  height: 14px;\n  fill: none;\n  stroke: var(--ui-fm-check);\n  stroke-width: 2.2;\n  stroke-linecap: round;\n  stroke-linejoin: round;\n  stroke-dasharray: 20;\n  stroke-dashoffset: 20;\n  transition: stroke-dashoffset 260ms var(--ui-fm-ease);\n}\n.ui-fm__option[aria-checked=\"true\"] .ui-fm__box { background: var(--aui-primary, var(--ui-fm-ink)); box-shadow: inset 0 0 0 1px var(--aui-primary, var(--ui-fm-ink)); }\n.ui-fm__option[aria-checked=\"true\"] .ui-fm__box svg { stroke-dashoffset: 0; }\n\n/* ---------- Keyboard hints on the tray ---------- */\n\n.ui-fm__hints { display: flex; justify-content: space-between; gap: 12px; padding: 10px 8px 4px; }\n.ui-fm__hint { display: inline-flex; align-items: center; gap: 5px; font-size: 12.5px; color: var(--ui-fm-text); }\n.ui-fm__hint kbd {\n  display: grid;\n  place-items: center;\n  width: 22px;\n  height: 22px;\n  border-radius: calc(var(--aui-radius-sm, 7px) - 1px);\n  background: var(--ui-fm-kbd);\n  box-shadow: 0 0 0 1px var(--ui-fm-card-line), 0 1px 1px rgba(0, 0, 0, 0.04);\n}\n.ui-fm__hint kbd:last-of-type { margin-right: 4px; }\n.ui-fm__hint svg { width: 13px; height: 13px; fill: none; stroke: var(--ui-fm-text); stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }\n\n@keyframes ui-fm-chip-in { from { opacity: 0; transform: scale(0.9); } }\n\n@media (prefers-reduced-motion: reduce) {\n  .ui-fm__chips-outer[data-smooth], .ui-fm__panel, .ui-fm__panel-inner, .ui-fm__chevron, .ui-fm__box svg { transition: none; }\n  .ui-fm__chip { animation: none; }\n}\n\n/* Dark mode: follows a .dark class or data-theme=\"dark\" on any ancestor, e.g. <html> */\n:where(.dark, [data-theme=\"dark\"]) .ui-fm {\n  --ui-fm-tray: var(--aui-subtle, #161618);\n  --ui-fm-card: var(--aui-surface, #232326);\n  --ui-fm-card-line: var(--aui-border, rgba(255, 255, 255, 0.08));\n  --ui-fm-ink: var(--aui-ink, #ededed);\n  --ui-fm-text: var(--aui-ink, #c4c4c4);\n  --ui-fm-muted: var(--aui-muted, #8f8f8f);\n  --ui-fm-chip: var(--aui-subtle, #2a2a2d);\n  --ui-fm-chip-hover: #313135;\n  --ui-fm-box: #2a2a2d;\n  --ui-fm-box-line: var(--aui-border-strong, rgba(255, 255, 255, 0.1));\n  --ui-fm-check: var(--aui-on-primary, #121213);\n  --ui-fm-row-hover: var(--aui-subtle, rgba(255, 255, 255, 0.05));\n  --ui-fm-kbd: #232326;\n  --ui-fm-focus: var(--aui-focus, rgba(77, 141, 255, 0.8));\n  --ui-fm-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);\n}\n`;\n"}]}