{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "filter-panel",
  "title": "Filter Panel",
  "author": "ecomcn <hello@ecomcn.dev>",
  "description": "Faceted filters with state that lives in the URL: applied-filter chips, checkbox lists with counts, a dual-handle range with typed inputs, colour swatches and toggles. Compound: one tag for the default layout, or FilterPanelHeader, FilterPanelChips and FilterPanelFacet in any order via useFilterPanel(). Ships a pure parser for server components and a useFilterParams hook.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "checkbox",
    "input",
    "slider",
    "switch"
  ],
  "files": [
    {
      "path": "src/registry/ecomcn/filter-panel/filter-panel.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Check, Minus, Plus, X } from \"lucide-react\"\n\nimport { Checkbox } from \"@/components/ui/checkbox\"\nimport { Input } from \"@/components/ui/input\"\nimport { Slider } from \"@/components/ui/slider\"\nimport { Switch } from \"@/components/ui/switch\"\nimport {\n  describeActiveFilters,\n  formatRangeBound,\n  getRange,\n  getToggle,\n  getValues,\n  removeFilter,\n  setRange,\n  toggleFilterValue,\n  type ActiveFilter,\n  type FilterFacet,\n  type FilterState,\n  type ListFacet,\n  type RangeFacet,\n  type SwatchFacet,\n  type ToggleFacet,\n} from \"@/lib/filter-params\"\nimport { cn } from \"@/lib/utils\"\n\n/* ─── context ──────────────────────────────────────────────────────────────\n * Compound component: <FilterPanel> owns the state and renders its own\n * provider, so nothing has to be mounted above it. Every part reads that\n * state through useFilterPanel(), which throws outside a panel instead of\n * failing silently with undefined values.\n */\n\nexport interface FilterPanelContextValue {\n  facets: FilterFacet[]\n  value: FilterState\n  setValue: (next: FilterState) => void\n  /** The facet with this id, if the panel has one. */\n  getFacet: (id: string) => FilterFacet | undefined\n  /** Human labels for everything that is on, in facet order. */\n  active: ActiveFilter[]\n  currency: string\n  locale?: string\n  loading?: boolean\n  title: string\n  titleId: string\n  /** Focus lands here when the control that had it disappears. */\n  rootRef: React.RefObject<HTMLElement | null>\n}\n\nconst FilterPanelContext = React.createContext<FilterPanelContextValue | null>(null)\n\nexport function useFilterPanel() {\n  const context = React.useContext(FilterPanelContext)\n  if (!context) {\n    throw new Error(\"useFilterPanel must be used within <FilterPanel>.\")\n  }\n  return context\n}\n\nconst NO_FILTERS: FilterState = {}\n\n/** Controlled when `value` is passed, uncontrolled from `defaultValue` otherwise. */\nfunction useControllableState<T>(\n  value: T | undefined,\n  defaultValue: T,\n  onChange?: (next: T) => void\n) {\n  const [uncontrolled, setUncontrolled] = React.useState(defaultValue)\n  const controlled = value !== undefined\n  const set = React.useCallback(\n    (next: T) => {\n      if (!controlled) setUncontrolled(next)\n      onChange?.(next)\n    },\n    [controlled, onChange]\n  )\n  return [controlled ? value : uncontrolled, set] as const\n}\n\n/* ─── root ─────────────────────────────────────────────────────────────── */\n\nexport interface FilterPanelProps\n  extends Omit<React.HTMLAttributes<HTMLElement>, \"defaultValue\" | \"onChange\"> {\n  facets: FilterFacet[]\n  /** Usually straight from `useFilterParams`, so the state lives in the URL. */\n  value?: FilterState\n  defaultValue?: FilterState\n  onValueChange?: (next: FilterState) => void\n  /** ISO 4217 code for `format: \"currency\"` range facets. */\n  currency?: string\n  locale?: string\n  /** Counts are stale while results load, so they render as placeholders. */\n  loading?: boolean\n  /** Default layout only: drop the title row, e.g. inside a sheet that has one. */\n  hideHeader?: boolean\n  title?: string\n  /**\n   * Compose your own layout from FilterPanelHeader, FilterPanelChips and\n   * FilterPanelFacet — reorder, omit, or put your own content between them.\n   * Leave empty for the default: header, chips, then every facet in order.\n   */\n  children?: React.ReactNode\n}\n\nexport function FilterPanel({\n  facets,\n  value: valueProp,\n  defaultValue = NO_FILTERS,\n  onValueChange,\n  currency = \"USD\",\n  locale,\n  loading,\n  hideHeader,\n  title = \"Filters\",\n  className,\n  children,\n  ...props\n}: FilterPanelProps) {\n  const [value, setValue] = useControllableState(valueProp, defaultValue, onValueChange)\n  const titleId = React.useId()\n  const rootRef = React.useRef<HTMLElement>(null)\n\n  const context = React.useMemo<FilterPanelContextValue>(\n    () => ({\n      facets,\n      value,\n      setValue,\n      getFacet: (id) => facets.find((facet) => facet.id === id),\n      active: describeActiveFilters(value, facets, { currency, locale }),\n      currency,\n      locale,\n      loading,\n      title,\n      titleId,\n      rootRef,\n    }),\n    [facets, value, setValue, currency, locale, loading, title, titleId]\n  )\n\n  return (\n    <FilterPanelContext.Provider value={context}>\n      <section\n        ref={rootRef}\n        tabIndex={-1}\n        aria-label={title}\n        aria-busy={loading || undefined}\n        data-slot=\"filter-panel\"\n        className={cn(\"text-sm outline-none\", className)}\n        {...props}\n      >\n        {children ?? (\n          <>\n            {hideHeader ? null : <FilterPanelHeader />}\n            <FilterPanelChips />\n            {facets.length === 0 ? (\n              <p className=\"border-t py-4 text-muted-foreground\">\n                No filters for this collection.\n              </p>\n            ) : (\n              facets.map((facet) => <FilterPanelFacet key={facet.id} id={facet.id} />)\n            )}\n          </>\n        )}\n      </section>\n    </FilterPanelContext.Provider>\n  )\n}\n\n/* ─── parts ────────────────────────────────────────────────────────────── */\n\nexport function FilterPanelHeader({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement>) {\n  const { active, setValue, title, titleId, rootRef } = useFilterPanel()\n\n  return (\n    <div\n      data-slot=\"filter-panel-header\"\n      className={cn(\"flex items-baseline justify-between gap-4 pb-3\", className)}\n      {...props}\n    >\n      <h2 id={titleId} className=\"text-xs font-medium tracking-[0.14em] uppercase\">\n        {title}\n        {active.length > 0 && (\n          <span className=\"ml-1.5 text-muted-foreground tabular-nums\">{active.length}</span>\n        )}\n      </h2>\n      {active.length > 0 && (\n        <button\n          type=\"button\"\n          onClick={() => {\n            // This button is about to disappear; hand focus to the panel first.\n            rootRef.current?.focus()\n            setValue({})\n          }}\n          className=\"text-xs text-muted-foreground underline-offset-4 outline-none hover:text-foreground hover:underline focus-visible:text-foreground focus-visible:underline\"\n        >\n          Clear all\n        </button>\n      )}\n    </div>\n  )\n}\n\nexport function FilterPanelChips({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLUListElement>) {\n  const { active, value, setValue, rootRef } = useFilterPanel()\n\n  // Removing a chip deletes the button that had focus. Put focus on the chip\n  // that slid into its place — or the panel itself — instead of <body>.\n  const chips = React.useRef<(HTMLButtonElement | null)[]>([])\n  const refocus = React.useRef<number | null>(null)\n\n  React.useEffect(() => {\n    if (refocus.current === null) return\n    const index = Math.min(refocus.current, active.length - 1)\n    refocus.current = null\n    ;(chips.current[index] ?? rootRef.current)?.focus()\n  }, [active.length, rootRef])\n\n  if (active.length === 0) return null\n\n  return (\n    <ul\n      aria-label=\"Applied filters\"\n      data-slot=\"filter-panel-chips\"\n      className={cn(\"flex flex-wrap gap-1.5 pb-4\", className)}\n      {...props}\n    >\n      {active.map((filter, index) => (\n        <li key={`${filter.facetId}:${filter.value ?? \"\"}`}>\n          <button\n            ref={(node) => {\n              chips.current[index] = node\n            }}\n            type=\"button\"\n            onClick={() => {\n              refocus.current = index\n              setValue(removeFilter(value, filter.facetId, filter.value))\n            }}\n            className=\"group inline-flex h-7 items-center gap-1.5 rounded-sm border bg-secondary/60 pr-1.5 pl-2 text-xs transition-colors outline-none hover:border-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n          >\n            <span className=\"sr-only\">Remove {filter.facetLabel}:</span>\n            {filter.label}\n            <X\n              className=\"size-3 text-muted-foreground group-hover:text-foreground\"\n              aria-hidden\n            />\n          </button>\n        </li>\n      ))}\n    </ul>\n  )\n}\n\nexport interface FilterPanelFacetProps extends React.HTMLAttributes<HTMLDivElement> {\n  /** The facet's id — the same string that keys it in the URL. */\n  id: string\n  /** Collapsible facets start open unless this is false. */\n  defaultOpen?: boolean\n}\n\nexport function FilterPanelFacet({ id, defaultOpen = true, className, ...props }: FilterPanelFacetProps) {\n  const { getFacet } = useFilterPanel()\n  const facet = getFacet(id)\n\n  // An id with no facet renders nothing, so a server that drops an empty\n  // facet from the list doesn't break a hand-written layout.\n  if (!facet) return null\n  if (facet.type === \"toggle\") {\n    return <ToggleRow facet={facet} className={className} {...props} />\n  }\n  return (\n    <FacetSection\n      facet={facet}\n      defaultOpen={defaultOpen}\n      className={className}\n      {...props}\n    />\n  )\n}\n\n/* ─── internals ────────────────────────────────────────────────────────── */\n\nfunction FacetSection({\n  facet,\n  defaultOpen,\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement> & {\n  facet: ListFacet | SwatchFacet | RangeFacet\n  defaultOpen: boolean\n}) {\n  const { value } = useFilterPanel()\n  const [open, setOpen] = React.useState(defaultOpen)\n  const id = React.useId()\n\n  if (facet.type !== \"range\" && facet.options.length === 0) return null\n\n  const selected =\n    facet.type === \"range\"\n      ? getRange(value, facet.id)\n        ? 1\n        : 0\n      : getValues(value, facet.id).length\n\n  return (\n    // Groups are separated by a top rule, not boxed: the panel reads as one\n    // ruled column, the way a printed index does.\n    <div\n      data-slot=\"filter-panel-facet\"\n      data-facet={facet.id}\n      className={cn(\"border-t py-4\", className)}\n      {...props}\n    >\n      <h3>\n        <button\n          type=\"button\"\n          aria-expanded={open}\n          aria-controls={`${id}-body`}\n          onClick={() => setOpen((o) => !o)}\n          className=\"flex w-full items-center justify-between gap-3 rounded-sm text-left font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\n        >\n          <span>\n            {facet.label}\n            {selected > 0 && (\n              <span className=\"ml-1.5 font-normal text-muted-foreground tabular-nums\">\n                ({selected})\n              </span>\n            )}\n          </span>\n          {open ? (\n            <Minus className=\"size-3.5 text-muted-foreground\" aria-hidden />\n          ) : (\n            <Plus className=\"size-3.5 text-muted-foreground\" aria-hidden />\n          )}\n        </button>\n      </h3>\n      <div id={`${id}-body`} hidden={!open} className=\"pt-3\">\n        {facet.type === \"list\" && <ListOptions facet={facet} />}\n        {facet.type === \"swatch\" && <SwatchOptions facet={facet} />}\n        {facet.type === \"range\" && <RangeControl facet={facet} />}\n      </div>\n    </div>\n  )\n}\n\nfunction Count({ count }: { count?: number }) {\n  const { loading, locale } = useFilterPanel()\n  if (loading) {\n    return (\n      <span aria-hidden className=\"h-3 w-6 shrink-0 animate-pulse rounded-sm bg-muted\" />\n    )\n  }\n  if (count === undefined) return null\n  // Tabular figures, right-aligned: a column of counts that doesn't line up\n  // is the first thing that makes a filter panel look unfinished.\n  return (\n    <span className=\"shrink-0 text-xs text-muted-foreground tabular-nums\">\n      {new Intl.NumberFormat(locale).format(count)}\n    </span>\n  )\n}\n\nfunction ListOptions({ facet }: { facet: ListFacet }) {\n  const { value, setValue } = useFilterPanel()\n  const [expanded, setExpanded] = React.useState(false)\n  const selected = getValues(value, facet.id)\n  const limit = facet.limit ?? 6\n\n  // Selected options stay visible when the list collapses, or a shopper\n  // loses sight of a filter that is still applied.\n  const collapsed = facet.options.filter(\n    (option, index) => index < limit || selected.includes(option.value)\n  )\n  const hidden = facet.options.length - collapsed.length\n  const visible = expanded ? facet.options : collapsed\n\n  return (\n    <>\n      <ul className=\"space-y-0.5\">\n        {visible.map((option) => {\n          const checked = selected.includes(option.value)\n          const disabled = option.disabled ?? (option.count === 0 && !checked)\n          return (\n            <li key={option.value}>\n              <label\n                className={cn(\n                  \"flex min-h-8 cursor-pointer items-center gap-3 py-1\",\n                  disabled && \"cursor-not-allowed text-muted-foreground\"\n                )}\n              >\n                <Checkbox\n                  checked={checked}\n                  disabled={disabled}\n                  onCheckedChange={() =>\n                    setValue(toggleFilterValue(value, facet.id, option.value))\n                  }\n                />\n                <span className=\"flex-1 leading-tight\">{option.label}</span>\n                <Count count={option.count} />\n              </label>\n            </li>\n          )\n        })}\n      </ul>\n      {(hidden > 0 || expanded) && facet.options.length > limit && (\n        <button\n          type=\"button\"\n          aria-expanded={expanded}\n          onClick={() => setExpanded((e) => !e)}\n          className=\"mt-2 text-xs underline underline-offset-4 outline-none hover:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n        >\n          {expanded ? \"Show fewer\" : `Show ${hidden} more`}\n        </button>\n      )}\n    </>\n  )\n}\n\nfunction SwatchOptions({ facet }: { facet: SwatchFacet }) {\n  const { value, setValue } = useFilterPanel()\n  const selected = getValues(value, facet.id)\n\n  return (\n    <ul className=\"grid grid-cols-2 gap-1.5\">\n      {facet.options.map((option) => {\n        const checked = selected.includes(option.value)\n        const disabled = option.disabled ?? (option.count === 0 && !checked)\n        return (\n          <li key={option.value}>\n            {/* The name is always printed and the selected state carries a\n                check and a heavier border — colour is never the only signal. */}\n            <button\n              type=\"button\"\n              aria-pressed={checked}\n              disabled={disabled}\n              onClick={() => setValue(toggleFilterValue(value, facet.id, option.value))}\n              className={cn(\n                \"flex w-full items-center gap-2 rounded-sm border px-2 py-1.5 text-left transition-colors outline-none hover:border-foreground/50 focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-45\",\n                checked && \"border-foreground bg-secondary\"\n              )}\n            >\n              <span\n                aria-hidden\n                className=\"grid size-4 shrink-0 place-items-center rounded-full ring-1 ring-foreground/20 ring-inset\"\n                style={{ background: option.swatch }}\n              >\n                {checked && (\n                  <Check className=\"size-3 text-white mix-blend-difference\" strokeWidth={3} />\n                )}\n              </span>\n              <span className=\"min-w-0 flex-1 truncate\" title={option.label}>\n                {option.label}\n              </span>\n              <Count count={option.count} />\n            </button>\n          </li>\n        )\n      })}\n    </ul>\n  )\n}\n\nfunction useAffix(facet: RangeFacet, currency?: string, locale?: string) {\n  return React.useMemo(() => {\n    if (facet.format !== \"currency\") {\n      return facet.unit ? { text: facet.unit, suffix: true } : null\n    }\n    const parts = new Intl.NumberFormat(locale, { style: \"currency\", currency }).formatToParts(1)\n    const symbol = parts.findIndex((p) => p.type === \"currency\")\n    const number = parts.findIndex((p) => p.type === \"integer\")\n    return symbol === -1 ? null : { text: parts[symbol].value, suffix: symbol > number }\n  }, [facet.format, facet.unit, currency, locale])\n}\n\nfunction RangeControl({ facet }: { facet: RangeFacet }) {\n  const { value, setValue, currency, locale } = useFilterPanel()\n  const id = React.useId()\n  const committed = getRange(value, facet.id)\n  const lo = committed?.min ?? facet.min\n  const hi = committed?.max ?? facet.max\n  const key = `${lo}:${hi}`\n\n  // A draft remembers which committed value it started from. When the URL\n  // catches up (instantly, or after a router round-trip) the key changes and\n  // the stale draft is simply ignored — no effect needed to reset it, and no\n  // flash back to the old value while a server fetch is in flight.\n  const [draft, setDraft] = React.useState<{ values: [number, number]; key: string } | null>(null)\n  const [text, setText] = React.useState<{ min?: string; max?: string; key: string } | null>(null)\n  const pending = React.useRef<[number, number] | null>(null)\n\n  const [shownLo, shownHi] = draft?.key === key ? draft.values : [lo, hi]\n  const typed = text?.key === key ? text : null\n  const affix = useAffix(facet, currency, locale)\n  const format = (n: number) => formatRangeBound(facet, n, { currency, locale })\n\n  const commit = (values: [number, number]) => {\n    const clamp = (n: number) => Math.min(facet.max, Math.max(facet.min, n))\n    const [a, b] = values.map(clamp)\n    const next: [number, number] = a <= b ? [a, b] : [b, a]\n    setDraft({ values: next, key })\n    if (next[0] !== lo || next[1] !== hi) {\n      setValue(setRange(value, facet, { min: next[0], max: next[1] }))\n    }\n  }\n\n  // Commit when the thumb is released, not on every pixel of the drag: each\n  // commit is a history entry and, on most stores, a request.\n  const commitPending = () => {\n    const values = pending.current\n    pending.current = null\n    if (values) commit(values)\n  }\n\n  const commitTyped = () => {\n    if (!typed) return\n    // Untouched field: keep what it showed. Emptied field: lift that bound.\n    // Garbage: ignore the edit rather than guess.\n    const parse = (raw: string | undefined, shown: number, edge: number) => {\n      if (raw === undefined) return shown\n      if (raw.trim() === \"\") return edge\n      const n = Number(raw.replace(/[^\\d.-]/g, \"\"))\n      return Number.isFinite(n) ? n : shown\n    }\n    setText(null)\n    commit([parse(typed.min, shownLo, facet.min), parse(typed.max, shownHi, facet.max)])\n  }\n\n  const field = (bound: \"min\" | \"max\") => (\n    <label className=\"relative flex-1\">\n      <span className=\"sr-only\">\n        {bound === \"min\" ? \"Minimum\" : \"Maximum\"} {facet.label.toLowerCase()}\n      </span>\n      {affix && (\n        <span\n          aria-hidden\n          className={cn(\n            \"pointer-events-none absolute top-1/2 -translate-y-1/2 text-xs text-muted-foreground\",\n            affix.suffix ? \"right-2.5\" : \"left-2.5\"\n          )}\n        >\n          {affix.text}\n        </span>\n      )}\n      <Input\n        inputMode=\"decimal\"\n        autoComplete=\"off\"\n        value={typed?.[bound] ?? String(bound === \"min\" ? shownLo : shownHi)}\n        onChange={(event) =>\n          setText({ ...(typed ?? { key }), [bound]: event.target.value })\n        }\n        onBlur={commitTyped}\n        onKeyDown={(event) => {\n          if (event.key === \"Enter\") commitTyped()\n        }}\n        className={cn(\"h-9 tabular-nums\", affix && (affix.suffix ? \"pr-7\" : \"pl-6\"))}\n      />\n    </label>\n  )\n\n  return (\n    <div>\n      {/* Not a live region: the thumbs already announce their own values,\n          and a readout that speaks on every pixel of a drag is noise. */}\n      <p id={`${id}-readout`} className=\"mb-3 tabular-nums\">\n        {shownLo <= facet.min && shownHi >= facet.max\n          ? `Any ${facet.label.toLowerCase()}`\n          : `${format(shownLo)} – ${format(shownHi)}`}\n      </p>\n      <div\n        role=\"group\"\n        aria-label={`${facet.label} range`}\n        aria-describedby={`${id}-readout`}\n        className=\"px-1 py-2\"\n        onPointerDown={() => {\n          const end = () => {\n            window.removeEventListener(\"pointerup\", end)\n            window.removeEventListener(\"pointercancel\", end)\n            commitPending()\n          }\n          window.addEventListener(\"pointerup\", end)\n          window.addEventListener(\"pointercancel\", end)\n        }}\n        onKeyUp={commitPending}\n      >\n        <Slider\n          min={facet.min}\n          max={facet.max}\n          step={facet.step ?? 1}\n          value={[shownLo, shownHi]}\n          onValueChange={(next: number | readonly number[]) => {\n            const values: [number, number] =\n              typeof next === \"number\" ? [next, next] : [next[0], next[1]]\n            pending.current = values\n            setDraft({ values, key })\n          }}\n        />\n      </div>\n      <div className=\"mt-3 flex items-center gap-2\">\n        {field(\"min\")}\n        <span aria-hidden className=\"text-muted-foreground\">\n          –\n        </span>\n        {field(\"max\")}\n      </div>\n    </div>\n  )\n}\n\nfunction ToggleRow({\n  facet,\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement> & { facet: ToggleFacet }) {\n  const { value, setValue } = useFilterPanel()\n  return (\n    <div\n      data-slot=\"filter-panel-facet\"\n      data-facet={facet.id}\n      className={cn(\"border-t py-4\", className)}\n      {...props}\n    >\n      <label className=\"flex cursor-pointer items-center justify-between gap-4\">\n        <span className=\"font-medium\">{facet.label}</span>\n        <span className=\"flex items-center gap-3\">\n          <Count count={facet.count} />\n          <Switch\n            checked={getToggle(value, facet.id)}\n            onCheckedChange={(on: boolean) => setValue({ ...value, [facet.id]: on || undefined })}\n          />\n        </span>\n      </label>\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/ecomcn/filter-panel.tsx"
    },
    {
      "path": "src/registry/ecomcn/filter-panel/filter-params.ts",
      "content": "/**\n * Filter state <-> URL search params.\n *\n * Pure functions, no React. The same parser runs in a server component (to\n * fetch the filtered page) and in the browser (to drive the panel), so the\n * URL is the only source of truth a listing page needs.\n *\n *   ?category=lighting&category=seating   list / swatch facets: repeated keys\n *   ?price=40-200                          range facets: `min-max`, either side optional\n *   ?in_stock=1                            toggle facets\n *\n * Params that are not facets (`q`, `sort`, `page`, utm tags) pass through.\n */\n\nexport interface FilterOption {\n  value: string\n  label: string\n  /** Results if this option were selected. `0` disables it unless it is already on. */\n  count?: number\n  /** Any CSS colour or `url(...)` — renders the option as a swatch. */\n  swatch?: string\n  disabled?: boolean\n}\n\ninterface FacetBase {\n  /** Doubles as the URL key, so keep it short and stable. */\n  id: string\n  label: string\n}\n\nexport interface ListFacet extends FacetBase {\n  type: \"list\"\n  options: FilterOption[]\n  /** Options shown before \"Show more\". Selected options always stay visible. */\n  limit?: number\n}\n\nexport interface SwatchFacet extends FacetBase {\n  type: \"swatch\"\n  options: FilterOption[]\n}\n\nexport interface RangeFacet extends FacetBase {\n  type: \"range\"\n  min: number\n  max: number\n  step?: number\n  /** `currency` formats bounds with the panel's `currency` and `locale`. */\n  format?: \"currency\" | \"number\"\n  /** Suffix for `format: \"number\"`, e.g. \"cm\" or \"★\". */\n  unit?: string\n}\n\nexport interface ToggleFacet extends FacetBase {\n  type: \"toggle\"\n  count?: number\n}\n\nexport type FilterFacet = ListFacet | SwatchFacet | RangeFacet | ToggleFacet\n\nexport interface FilterRange {\n  min?: number\n  max?: number\n}\n\nexport type FilterValue = string[] | FilterRange | boolean\n\n/** Keyed by facet id. A missing key means \"no constraint\". */\nexport type FilterState = Record<string, FilterValue | undefined>\n\n/** Anything a router hands you: a query string, URLSearchParams, or Next's `searchParams` object. */\nexport type SearchInput =\n  | string\n  | URLSearchParams\n  | Record<string, string | string[] | undefined>\n\nexport interface ActiveFilter {\n  facetId: string\n  facetLabel: string\n  /** Set for list and swatch facets — the option being removed. */\n  value?: string\n  label: string\n}\n\nexport function toSearchParams(input: SearchInput | null | undefined) {\n  if (!input) return new URLSearchParams()\n  if (typeof input === \"string\" || input instanceof URLSearchParams) {\n    return new URLSearchParams(input)\n  }\n  const params = new URLSearchParams()\n  for (const [key, raw] of Object.entries(input)) {\n    if (raw === undefined) continue\n    for (const value of Array.isArray(raw) ? raw : [raw]) params.append(key, value)\n  }\n  return params\n}\n\n// `-?` on both sides, so negative bounds still parse: `-10--2` is -10 to -2.\nconst RANGE = /^(-?\\d+(?:\\.\\d+)?)?-(-?\\d+(?:\\.\\d+)?)?$/\n\nfunction normaliseRange(facet: RangeFacet, range: FilterRange | undefined) {\n  if (!range) return undefined\n  let min = Number.isFinite(range.min) ? range.min : undefined\n  let max = Number.isFinite(range.max) ? range.max : undefined\n  if (min !== undefined && max !== undefined && min > max) [min, max] = [max, min]\n  // A bound at the edge of the scale is no constraint at all — drop it so the\n  // URL stays clean and the chip does not read \"$0 – $500\".\n  if (min !== undefined && min <= facet.min) min = undefined\n  if (max !== undefined && max >= facet.max) max = undefined\n  if (min === undefined && max === undefined) return undefined\n  return { min, max }\n}\n\nexport function parseFilterParams(\n  input: SearchInput | null | undefined,\n  facets: FilterFacet[]\n): FilterState {\n  const params = toSearchParams(input)\n  const state: FilterState = {}\n\n  for (const facet of facets) {\n    if (facet.type === \"list\" || facet.type === \"swatch\") {\n      const values = [...new Set(params.getAll(facet.id).filter(Boolean))]\n      if (values.length) state[facet.id] = values\n    } else if (facet.type === \"range\") {\n      const match = RANGE.exec(params.get(facet.id) ?? \"\")\n      if (!match) continue\n      const range = normaliseRange(facet, {\n        min: match[1] === undefined ? undefined : Number(match[1]),\n        max: match[2] === undefined ? undefined : Number(match[2]),\n      })\n      if (range) state[facet.id] = range\n    } else {\n      const raw = params.get(facet.id)\n      if (raw === \"1\" || raw === \"true\") state[facet.id] = true\n    }\n  }\n\n  return state\n}\n\n/**\n * Writes `state` over `base`, keeping every non-facet param. Facet keys are\n * written in facet order, so equal states always produce equal URLs.\n */\nexport function serializeFilterParams(\n  state: FilterState,\n  facets: FilterFacet[],\n  base?: SearchInput | null\n) {\n  const params = toSearchParams(base)\n  for (const facet of facets) params.delete(facet.id)\n\n  for (const facet of facets) {\n    const value = state[facet.id]\n    if (facet.type === \"list\" || facet.type === \"swatch\") {\n      for (const option of getValues(state, facet.id)) params.append(facet.id, option)\n    } else if (facet.type === \"range\") {\n      const range = normaliseRange(facet, getRange(state, facet.id))\n      if (range) params.set(facet.id, `${range.min ?? \"\"}-${range.max ?? \"\"}`)\n    } else if (value === true) {\n      params.set(facet.id, \"1\")\n    }\n  }\n\n  return params\n}\n\nexport function isSameFilters(a: FilterState, b: FilterState, facets: FilterFacet[]) {\n  return (\n    serializeFilterParams(a, facets).toString() ===\n    serializeFilterParams(b, facets).toString()\n  )\n}\n\nexport function getValues(state: FilterState, id: string): string[] {\n  const value = state[id]\n  return Array.isArray(value) ? value : []\n}\n\nexport function getRange(state: FilterState, id: string): FilterRange | undefined {\n  const value = state[id]\n  return value && typeof value === \"object\" && !Array.isArray(value) ? value : undefined\n}\n\nexport function getToggle(state: FilterState, id: string) {\n  return state[id] === true\n}\n\nexport function toggleFilterValue(state: FilterState, id: string, value: string): FilterState {\n  const current = getValues(state, id)\n  const next = current.includes(value)\n    ? current.filter((v) => v !== value)\n    : [...current, value]\n  return { ...state, [id]: next.length ? next : undefined }\n}\n\n/** Sets a range, dropping bounds that sit on the edge of the scale. */\nexport function setRange(state: FilterState, facet: RangeFacet, range: FilterRange): FilterState {\n  return { ...state, [facet.id]: normaliseRange(facet, range) }\n}\n\n/** Removes one option, or the whole facet when `value` is omitted. */\nexport function removeFilter(state: FilterState, id: string, value?: string): FilterState {\n  if (value === undefined) return { ...state, [id]: undefined }\n  const next = getValues(state, id).filter((v) => v !== value)\n  return { ...state, [id]: next.length ? next : undefined }\n}\n\nexport function countActiveFilters(state: FilterState, facets: FilterFacet[]) {\n  return describeActiveFilters(state, facets).length\n}\n\nexport function formatRangeBound(\n  facet: RangeFacet,\n  value: number,\n  { currency = \"USD\", locale }: { currency?: string; locale?: string } = {}\n) {\n  if (facet.format === \"currency\") {\n    return new Intl.NumberFormat(locale, {\n      style: \"currency\",\n      currency,\n      maximumFractionDigits: Number.isInteger(value) ? 0 : 2,\n    }).format(value)\n  }\n  const n = new Intl.NumberFormat(locale).format(value)\n  return facet.unit ? `${n}${facet.unit.length > 1 ? \" \" : \"\"}${facet.unit}` : n\n}\n\n/**\n * Human labels for everything that is on — the chips above the panel, and\n * the words an empty state needs to say *which* filters emptied the grid.\n */\nexport function describeActiveFilters(\n  state: FilterState,\n  facets: FilterFacet[],\n  format: { currency?: string; locale?: string } = {}\n): ActiveFilter[] {\n  const active: ActiveFilter[] = []\n\n  for (const facet of facets) {\n    if (facet.type === \"list\" || facet.type === \"swatch\") {\n      for (const value of getValues(state, facet.id)) {\n        const option = facet.options.find((o) => o.value === value)\n        active.push({\n          facetId: facet.id,\n          facetLabel: facet.label,\n          value,\n          label: option?.label ?? value,\n        })\n      }\n    } else if (facet.type === \"range\") {\n      const range = normaliseRange(facet, getRange(state, facet.id))\n      if (!range) continue\n      const lo = range.min === undefined ? null : formatRangeBound(facet, range.min, format)\n      const hi = range.max === undefined ? null : formatRangeBound(facet, range.max, format)\n      active.push({\n        facetId: facet.id,\n        facetLabel: facet.label,\n        label: lo && hi ? `${lo} – ${hi}` : lo ? `${lo} and up` : `Up to ${hi}`,\n      })\n    } else if (getToggle(state, facet.id)) {\n      active.push({ facetId: facet.id, facetLabel: facet.label, label: facet.label })\n    }\n  }\n\n  return active\n}\n",
      "type": "registry:lib",
      "target": "lib/filter-params.ts"
    },
    {
      "path": "src/registry/ecomcn/filter-panel/use-filter-params.ts",
      "content": "import * as React from \"react\"\n\nimport {\n  isSameFilters,\n  parseFilterParams,\n  serializeFilterParams,\n  type FilterFacet,\n  type FilterState,\n} from \"@/lib/filter-params\"\n\n/** Fired after our own pushState — the History API raises no event for it. */\nconst LOCATION_EVENT = \"ecomcn:locationchange\"\nconst RESET_PAGE = [\"page\"]\n\nfunction subscribe(onChange: () => void) {\n  window.addEventListener(\"popstate\", onChange)\n  window.addEventListener(LOCATION_EVENT, onChange)\n  return () => {\n    window.removeEventListener(\"popstate\", onChange)\n    window.removeEventListener(LOCATION_EVENT, onChange)\n  }\n}\n\nconst readSearch = () => window.location.search\n\nexport interface UseFilterParamsOptions {\n  /**\n   * Your router's current params — Next's `useSearchParams()`, React\n   * Router's `searchParams`. Omit it and the hook reads `window.location`.\n   */\n  searchParams?: URLSearchParams | string | null\n  /**\n   * How to write the next URL. Defaults to the History API, which is enough\n   * when results are fetched on the client. Pass your router's push when the\n   * server renders the results, so each change triggers a fetch.\n   */\n  navigate?: (href: string, options: { replace: boolean }) => void\n  /** \"push\" makes Back undo the last filter, which is what shoppers expect. */\n  history?: \"push\" | \"replace\"\n  /** Dropped on every change — page 4 of the old result set means nothing now. */\n  resetParams?: string[]\n  /** The query string the server rendered with, so hydration matches. */\n  initialSearch?: string\n}\n\n/**\n * `useState`, but the state lives in the URL. Deep links, refresh, share and\n * the Back button all work because there is no second copy to fall out of sync.\n *\n * Keep `facets` referentially stable (module scope or `useMemo`).\n */\nexport function useFilterParams(\n  facets: FilterFacet[],\n  {\n    searchParams,\n    navigate,\n    history = \"push\",\n    resetParams = RESET_PAGE,\n    initialSearch = \"\",\n  }: UseFilterParamsOptions = {}\n) {\n  const locationSearch = React.useSyncExternalStore(\n    subscribe,\n    readSearch,\n    () => initialSearch\n  )\n  const controlled = searchParams !== undefined && searchParams !== null\n  const search = controlled ? searchParams.toString() : locationSearch\n\n  const filters = React.useMemo(\n    () => parseFilterParams(search, facets),\n    [search, facets]\n  )\n\n  const setFilters = React.useCallback(\n    (next: FilterState | ((previous: FilterState) => FilterState)) => {\n      // Read the live URL, not the render-time snapshot, so two updates in\n      // one event compose instead of the second overwriting the first.\n      const current = controlled ? search : window.location.search\n      const previous = parseFilterParams(current, facets)\n      const value = typeof next === \"function\" ? next(previous) : next\n\n      // An unchanged state must not add a history entry, or Back stops working.\n      if (isSameFilters(value, previous, facets)) return\n\n      const params = serializeFilterParams(value, facets, current)\n      for (const key of resetParams) params.delete(key)\n      const query = params.toString()\n      const href = `${window.location.pathname}${query ? `?${query}` : \"\"}${window.location.hash}`\n\n      if (navigate) {\n        navigate(href, { replace: history === \"replace\" })\n        return\n      }\n      if (history === \"replace\") {\n        window.history.replaceState(window.history.state, \"\", href)\n      } else {\n        window.history.pushState(null, \"\", href)\n      }\n      window.dispatchEvent(new Event(LOCATION_EVENT))\n    },\n    [controlled, search, facets, navigate, history, resetParams]\n  )\n\n  return [filters, setFilters] as const\n}\n",
      "type": "registry:hook",
      "target": "hooks/use-filter-params.ts"
    }
  ],
  "categories": [
    "ecommerce",
    "catalog",
    "listing",
    "filters"
  ],
  "type": "registry:block"
}