ecomcn
All blocks

Browse/block

Filter panel

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.

Step 1Register the namespace — once per project

$ npx shadcn@latest registry add @ecomcn=https://ecomcn.vercel.app/r/{name}.json

Step 2Add the block

$ npx shadcn@latest add @ecomcn/filter-panel
Preview — live
100%

Usage

lib/facets.ts
import type { FilterFacet } from "@/lib/filter-params"

// Module scope (or useMemo): the hook parses the URL against these ids and bounds.
export const facets: FilterFacet[] = [
  {
    id: "category",
    label: "Category",
    type: "list",
    options: [
      { value: "lighting", label: "Lighting", count: 12 },
      { value: "seating", label: "Seating", count: 8 },
    ],
  },
  { id: "price", label: "Price", type: "range", min: 0, max: 500, step: 10, format: "currency" },
  {
    id: "color",
    label: "Colour",
    type: "swatch",
    options: [{ value: "sage", label: "Sage", swatch: "#7d8b7a", count: 4 }],
  },
  { id: "in_stock", label: "In stock only", type: "toggle" },
]
components/shop-filters.tsx
"use client"

import { FilterPanel } from "@/components/ecomcn/filter-panel"
import { useFilterParams } from "@/hooks/use-filter-params"
import { facets } from "@/lib/facets"

export function ShopFilters() {
  // useState, except the state is the URL: ?category=lighting&price=40-200
  const [filters, setFilters] = useFilterParams(facets)

  return <FilterPanel facets={facets} value={filters} onValueChange={setFilters} />
}
Your own layout — compound parts, in any order
import {
  FilterPanel,
  FilterPanelChips,
  FilterPanelFacet,
  FilterPanelHeader,
  useFilterPanel,
} from "@/components/ecomcn/filter-panel"

<FilterPanel facets={facets} value={filters} onValueChange={setFilters}>
  <FilterPanelHeader />
  <FilterPanelFacet id="price" />
  <SaleOnly />                      {/* your own part, below */}
  <FilterPanelFacet id="color" defaultOpen={false} />
  <FilterPanelChips />
</FilterPanel>

// Any component inside the panel can read and set the same state.
// Outside a <FilterPanel>, useFilterPanel() throws instead of returning undefined.
function SaleOnly() {
  const { value, setValue } = useFilterPanel()
  const on = value.sale === true
  return (
    <button aria-pressed={on} onClick={() => setValue({ ...value, sale: on ? undefined : true })}>
      Sale only
    </button>
  )
}
app/shop/page.tsx — the same parser, on the server
import { parseFilterParams } from "@/lib/filter-params"
import { facets } from "@/lib/facets"

export default async function ShopPage({
  searchParams,
}: {
  searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
  const filters = parseFilterParams(await searchParams, facets)
  const products = await getProducts(filters) // your data layer
  // ...
}
Server-rendered results? Hand the hook your router
// Next.js: useSearchParams needs a <Suspense> boundary above this component.
const router = useRouter()
const searchParams = useSearchParams()

const [filters, setFilters] = useFilterParams(facets, {
  searchParams,
  navigate: (href) => router.push(href, { scroll: false }),
})

Props & context

Read straight out of the source at build time, so this cannot drift from the file you install.

FilterPanelContextValue
facets: FilterFacet[]
  value: FilterState
  setValue: (next: FilterState) => void
  /** The facet with this id, if the panel has one. */
  getFacet: (id: string) => FilterFacet | undefined
  /** Human labels for everything that is on, in facet order. */
  active: ActiveFilter[]
  currency: string
  locale?: string
  loading?: boolean
  title: string
  titleId: string
  /** Focus lands here when the control that had it disappears. */
  rootRef: React.RefObject<HTMLElement | null>
FilterPanelProps
facets: FilterFacet[]
  /** Usually straight from `useFilterParams`, so the state lives in the URL. */
  value?: FilterState
  defaultValue?: FilterState
  onValueChange?: (next: FilterState) => void
  /** ISO 4217 code for `format: "currency"` range facets. */
  currency?: string
  locale?: string
  /** Counts are stale while results load, so they render as placeholders. */
  loading?: boolean
  /** Default layout only: drop the title row, e.g. inside a sheet that has one. */
  hideHeader?: boolean
  title?: string
  /**
   * Compose your own layout from FilterPanelHeader, FilterPanelChips and
   * FilterPanelFacet — reorder, omit, or put your own content between them.
   * Leave empty for the default: header, chips, then every facet in order.
   */
  children?: React.ReactNode
FilterPanelFacetProps
/** The facet's id — the same string that keys it in the URL. */
  id: string
  /** Collapsible facets start open unless this is false. */
  defaultOpen?: boolean

Source

components/ecomcn/filter-panel.tsx
"use client"

import * as React from "react"
import { Check, Minus, Plus, X } from "lucide-react"

import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import { Slider } from "@/components/ui/slider"
import { Switch } from "@/components/ui/switch"
import {
  describeActiveFilters,
  formatRangeBound,
  getRange,
  getToggle,
  getValues,
  removeFilter,
  setRange,
  toggleFilterValue,
  type ActiveFilter,
  type FilterFacet,
  type FilterState,
  type ListFacet,
  type RangeFacet,
  type SwatchFacet,
  type ToggleFacet,
} from "@/lib/filter-params"
import { cn } from "@/lib/utils"

/* ─── context ──────────────────────────────────────────────────────────────
 * Compound component: <FilterPanel> owns the state and renders its own
 * provider, so nothing has to be mounted above it. Every part reads that
 * state through useFilterPanel(), which throws outside a panel instead of
 * failing silently with undefined values.
 */

export interface FilterPanelContextValue {
  facets: FilterFacet[]
  value: FilterState
  setValue: (next: FilterState) => void
  /** The facet with this id, if the panel has one. */
  getFacet: (id: string) => FilterFacet | undefined
  /** Human labels for everything that is on, in facet order. */
  active: ActiveFilter[]
  currency: string
  locale?: string
  loading?: boolean
  title: string
  titleId: string
  /** Focus lands here when the control that had it disappears. */
  rootRef: React.RefObject<HTMLElement | null>
}

const FilterPanelContext = React.createContext<FilterPanelContextValue | null>(null)

export function useFilterPanel() {
  const context = React.useContext(FilterPanelContext)
  if (!context) {
    throw new Error("useFilterPanel must be used within <FilterPanel>.")
  }
  return context
}

const NO_FILTERS: FilterState = {}

/** Controlled when `value` is passed, uncontrolled from `defaultValue` otherwise. */
function useControllableState<T>(
  value: T | undefined,
  defaultValue: T,
  onChange?: (next: T) => void
) {
  const [uncontrolled, setUncontrolled] = React.useState(defaultValue)
  const controlled = value !== undefined
  const set = React.useCallback(
    (next: T) => {
      if (!controlled) setUncontrolled(next)
      onChange?.(next)
    },
    [controlled, onChange]
  )
  return [controlled ? value : uncontrolled, set] as const
}

/* ─── root ─────────────────────────────────────────────────────────────── */

export interface FilterPanelProps
  extends Omit<React.HTMLAttributes<HTMLElement>, "defaultValue" | "onChange"> {
  facets: FilterFacet[]
  /** Usually straight from `useFilterParams`, so the state lives in the URL. */
  value?: FilterState
  defaultValue?: FilterState
  onValueChange?: (next: FilterState) => void
  /** ISO 4217 code for `format: "currency"` range facets. */
  currency?: string
  locale?: string
  /** Counts are stale while results load, so they render as placeholders. */
  loading?: boolean
  /** Default layout only: drop the title row, e.g. inside a sheet that has one. */
  hideHeader?: boolean
  title?: string
  /**
   * Compose your own layout from FilterPanelHeader, FilterPanelChips and
   * FilterPanelFacet — reorder, omit, or put your own content between them.
   * Leave empty for the default: header, chips, then every facet in order.
   */
  children?: React.ReactNode
}

export function FilterPanel({
  facets,
  value: valueProp,
  defaultValue = NO_FILTERS,
  onValueChange,
  currency = "USD",
  locale,
  loading,
  hideHeader,
  title = "Filters",
  className,
  children,
  ...props
}: FilterPanelProps) {
  const [value, setValue] = useControllableState(valueProp, defaultValue, onValueChange)
  const titleId = React.useId()
  const rootRef = React.useRef<HTMLElement>(null)

  const context = React.useMemo<FilterPanelContextValue>(
    () => ({
      facets,
      value,
      setValue,
      getFacet: (id) => facets.find((facet) => facet.id === id),
      active: describeActiveFilters(value, facets, { currency, locale }),
      currency,
      locale,
      loading,
      title,
      titleId,
      rootRef,
    }),
    [facets, value, setValue, currency, locale, loading, title, titleId]
  )

  return (
    <FilterPanelContext.Provider value={context}>
      <section
        ref={rootRef}
        tabIndex={-1}
        aria-label={title}
        aria-busy={loading || undefined}
        data-slot="filter-panel"
        className={cn("text-sm outline-none", className)}
        {...props}
      >
        {children ?? (
          <>
            {hideHeader ? null : <FilterPanelHeader />}
            <FilterPanelChips />
            {facets.length === 0 ? (
              <p className="border-t py-4 text-muted-foreground">
                No filters for this collection.
              </p>
            ) : (
              facets.map((facet) => <FilterPanelFacet key={facet.id} id={facet.id} />)
            )}
          </>
        )}
      </section>
    </FilterPanelContext.Provider>
  )
}

/* ─── parts ────────────────────────────────────────────────────────────── */

export function FilterPanelHeader({
  className,
  ...props
}: React.HTMLAttributes<HTMLDivElement>) {
  const { active, setValue, title, titleId, rootRef } = useFilterPanel()

  return (
    <div
      data-slot="filter-panel-header"
      className={cn("flex items-baseline justify-between gap-4 pb-3", className)}
      {...props}
    >
      <h2 id={titleId} className="text-xs font-medium tracking-[0.14em] uppercase">
        {title}
        {active.length > 0 && (
          <span className="ml-1.5 text-muted-foreground tabular-nums">{active.length}</span>
        )}
      </h2>
      {active.length > 0 && (
        <button
          type="button"
          onClick={() => {
            // This button is about to disappear; hand focus to the panel first.
            rootRef.current?.focus()
            setValue({})
          }}
          className="text-xs text-muted-foreground underline-offset-4 outline-none hover:text-foreground hover:underline focus-visible:text-foreground focus-visible:underline"
        >
          Clear all
        </button>
      )}
    </div>
  )
}

export function FilterPanelChips({
  className,
  ...props
}: React.HTMLAttributes<HTMLUListElement>) {
  const { active, value, setValue, rootRef } = useFilterPanel()

  // Removing a chip deletes the button that had focus. Put focus on the chip
  // that slid into its place — or the panel itself — instead of <body>.
  const chips = React.useRef<(HTMLButtonElement | null)[]>([])
  const refocus = React.useRef<number | null>(null)

  React.useEffect(() => {
    if (refocus.current === null) return
    const index = Math.min(refocus.current, active.length - 1)
    refocus.current = null
    ;(chips.current[index] ?? rootRef.current)?.focus()
  }, [active.length, rootRef])

  if (active.length === 0) return null

  return (
    <ul
      aria-label="Applied filters"
      data-slot="filter-panel-chips"
      className={cn("flex flex-wrap gap-1.5 pb-4", className)}
      {...props}
    >
      {active.map((filter, index) => (
        <li key={`${filter.facetId}:${filter.value ?? ""}`}>
          <button
            ref={(node) => {
              chips.current[index] = node
            }}
            type="button"
            onClick={() => {
              refocus.current = index
              setValue(removeFilter(value, filter.facetId, filter.value))
            }}
            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"
          >
            <span className="sr-only">Remove {filter.facetLabel}:</span>
            {filter.label}
            <X
              className="size-3 text-muted-foreground group-hover:text-foreground"
              aria-hidden
            />
          </button>
        </li>
      ))}
    </ul>
  )
}

export interface FilterPanelFacetProps extends React.HTMLAttributes<HTMLDivElement> {
  /** The facet's id — the same string that keys it in the URL. */
  id: string
  /** Collapsible facets start open unless this is false. */
  defaultOpen?: boolean
}

export function FilterPanelFacet({ id, defaultOpen = true, className, ...props }: FilterPanelFacetProps) {
  const { getFacet } = useFilterPanel()
  const facet = getFacet(id)

  // An id with no facet renders nothing, so a server that drops an empty
  // facet from the list doesn't break a hand-written layout.
  if (!facet) return null
  if (facet.type === "toggle") {
    return <ToggleRow facet={facet} className={className} {...props} />
  }
  return (
    <FacetSection
      facet={facet}
      defaultOpen={defaultOpen}
      className={className}
      {...props}
    />
  )
}

/* ─── internals ────────────────────────────────────────────────────────── */

function FacetSection({
  facet,
  defaultOpen,
  className,
  ...props
}: React.HTMLAttributes<HTMLDivElement> & {
  facet: ListFacet | SwatchFacet | RangeFacet
  defaultOpen: boolean
}) {
  const { value } = useFilterPanel()
  const [open, setOpen] = React.useState(defaultOpen)
  const id = React.useId()

  if (facet.type !== "range" && facet.options.length === 0) return null

  const selected =
    facet.type === "range"
      ? getRange(value, facet.id)
        ? 1
        : 0
      : getValues(value, facet.id).length

  return (
    // Groups are separated by a top rule, not boxed: the panel reads as one
    // ruled column, the way a printed index does.
    <div
      data-slot="filter-panel-facet"
      data-facet={facet.id}
      className={cn("border-t py-4", className)}
      {...props}
    >
      <h3>
        <button
          type="button"
          aria-expanded={open}
          aria-controls={`${id}-body`}
          onClick={() => setOpen((o) => !o)}
          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"
        >
          <span>
            {facet.label}
            {selected > 0 && (
              <span className="ml-1.5 font-normal text-muted-foreground tabular-nums">
                ({selected})
              </span>
            )}
          </span>
          {open ? (
            <Minus className="size-3.5 text-muted-foreground" aria-hidden />
          ) : (
            <Plus className="size-3.5 text-muted-foreground" aria-hidden />
          )}
        </button>
      </h3>
      <div id={`${id}-body`} hidden={!open} className="pt-3">
        {facet.type === "list" && <ListOptions facet={facet} />}
        {facet.type === "swatch" && <SwatchOptions facet={facet} />}
        {facet.type === "range" && <RangeControl facet={facet} />}
      </div>
    </div>
  )
}

function Count({ count }: { count?: number }) {
  const { loading, locale } = useFilterPanel()
  if (loading) {
    return (
      <span aria-hidden className="h-3 w-6 shrink-0 animate-pulse rounded-sm bg-muted" />
    )
  }
  if (count === undefined) return null
  // Tabular figures, right-aligned: a column of counts that doesn't line up
  // is the first thing that makes a filter panel look unfinished.
  return (
    <span className="shrink-0 text-xs text-muted-foreground tabular-nums">
      {new Intl.NumberFormat(locale).format(count)}
    </span>
  )
}

function ListOptions({ facet }: { facet: ListFacet }) {
  const { value, setValue } = useFilterPanel()
  const [expanded, setExpanded] = React.useState(false)
  const selected = getValues(value, facet.id)
  const limit = facet.limit ?? 6

  // Selected options stay visible when the list collapses, or a shopper
  // loses sight of a filter that is still applied.
  const collapsed = facet.options.filter(
    (option, index) => index < limit || selected.includes(option.value)
  )
  const hidden = facet.options.length - collapsed.length
  const visible = expanded ? facet.options : collapsed

  return (
    <>
      <ul className="space-y-0.5">
        {visible.map((option) => {
          const checked = selected.includes(option.value)
          const disabled = option.disabled ?? (option.count === 0 && !checked)
          return (
            <li key={option.value}>
              <label
                className={cn(
                  "flex min-h-8 cursor-pointer items-center gap-3 py-1",
                  disabled && "cursor-not-allowed text-muted-foreground"
                )}
              >
                <Checkbox
                  checked={checked}
                  disabled={disabled}
                  onCheckedChange={() =>
                    setValue(toggleFilterValue(value, facet.id, option.value))
                  }
                />
                <span className="flex-1 leading-tight">{option.label}</span>
                <Count count={option.count} />
              </label>
            </li>
          )
        })}
      </ul>
      {(hidden > 0 || expanded) && facet.options.length > limit && (
        <button
          type="button"
          aria-expanded={expanded}
          onClick={() => setExpanded((e) => !e)}
          className="mt-2 text-xs underline underline-offset-4 outline-none hover:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
        >
          {expanded ? "Show fewer" : `Show ${hidden} more`}
        </button>
      )}
    </>
  )
}

function SwatchOptions({ facet }: { facet: SwatchFacet }) {
  const { value, setValue } = useFilterPanel()
  const selected = getValues(value, facet.id)

  return (
    <ul className="grid grid-cols-2 gap-1.5">
      {facet.options.map((option) => {
        const checked = selected.includes(option.value)
        const disabled = option.disabled ?? (option.count === 0 && !checked)
        return (
          <li key={option.value}>
            {/* The name is always printed and the selected state carries a
                check and a heavier border — colour is never the only signal. */}
            <button
              type="button"
              aria-pressed={checked}
              disabled={disabled}
              onClick={() => setValue(toggleFilterValue(value, facet.id, option.value))}
              className={cn(
                "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",
                checked && "border-foreground bg-secondary"
              )}
            >
              <span
                aria-hidden
                className="grid size-4 shrink-0 place-items-center rounded-full ring-1 ring-foreground/20 ring-inset"
                style={{ background: option.swatch }}
              >
                {checked && (
                  <Check className="size-3 text-white mix-blend-difference" strokeWidth={3} />
                )}
              </span>
              <span className="min-w-0 flex-1 truncate" title={option.label}>
                {option.label}
              </span>
              <Count count={option.count} />
            </button>
          </li>
        )
      })}
    </ul>
  )
}

function useAffix(facet: RangeFacet, currency?: string, locale?: string) {
  return React.useMemo(() => {
    if (facet.format !== "currency") {
      return facet.unit ? { text: facet.unit, suffix: true } : null
    }
    const parts = new Intl.NumberFormat(locale, { style: "currency", currency }).formatToParts(1)
    const symbol = parts.findIndex((p) => p.type === "currency")
    const number = parts.findIndex((p) => p.type === "integer")
    return symbol === -1 ? null : { text: parts[symbol].value, suffix: symbol > number }
  }, [facet.format, facet.unit, currency, locale])
}

function RangeControl({ facet }: { facet: RangeFacet }) {
  const { value, setValue, currency, locale } = useFilterPanel()
  const id = React.useId()
  const committed = getRange(value, facet.id)
  const lo = committed?.min ?? facet.min
  const hi = committed?.max ?? facet.max
  const key = `${lo}:${hi}`

  // A draft remembers which committed value it started from. When the URL
  // catches up (instantly, or after a router round-trip) the key changes and
  // the stale draft is simply ignored — no effect needed to reset it, and no
  // flash back to the old value while a server fetch is in flight.
  const [draft, setDraft] = React.useState<{ values: [number, number]; key: string } | null>(null)
  const [text, setText] = React.useState<{ min?: string; max?: string; key: string } | null>(null)
  const pending = React.useRef<[number, number] | null>(null)

  const [shownLo, shownHi] = draft?.key === key ? draft.values : [lo, hi]
  const typed = text?.key === key ? text : null
  const affix = useAffix(facet, currency, locale)
  const format = (n: number) => formatRangeBound(facet, n, { currency, locale })

  const commit = (values: [number, number]) => {
    const clamp = (n: number) => Math.min(facet.max, Math.max(facet.min, n))
    const [a, b] = values.map(clamp)
    const next: [number, number] = a <= b ? [a, b] : [b, a]
    setDraft({ values: next, key })
    if (next[0] !== lo || next[1] !== hi) {
      setValue(setRange(value, facet, { min: next[0], max: next[1] }))
    }
  }

  // Commit when the thumb is released, not on every pixel of the drag: each
  // commit is a history entry and, on most stores, a request.
  const commitPending = () => {
    const values = pending.current
    pending.current = null
    if (values) commit(values)
  }

  const commitTyped = () => {
    if (!typed) return
    // Untouched field: keep what it showed. Emptied field: lift that bound.
    // Garbage: ignore the edit rather than guess.
    const parse = (raw: string | undefined, shown: number, edge: number) => {
      if (raw === undefined) return shown
      if (raw.trim() === "") return edge
      const n = Number(raw.replace(/[^\d.-]/g, ""))
      return Number.isFinite(n) ? n : shown
    }
    setText(null)
    commit([parse(typed.min, shownLo, facet.min), parse(typed.max, shownHi, facet.max)])
  }

  const field = (bound: "min" | "max") => (
    <label className="relative flex-1">
      <span className="sr-only">
        {bound === "min" ? "Minimum" : "Maximum"} {facet.label.toLowerCase()}
      </span>
      {affix && (
        <span
          aria-hidden
          className={cn(
            "pointer-events-none absolute top-1/2 -translate-y-1/2 text-xs text-muted-foreground",
            affix.suffix ? "right-2.5" : "left-2.5"
          )}
        >
          {affix.text}
        </span>
      )}
      <Input
        inputMode="decimal"
        autoComplete="off"
        value={typed?.[bound] ?? String(bound === "min" ? shownLo : shownHi)}
        onChange={(event) =>
          setText({ ...(typed ?? { key }), [bound]: event.target.value })
        }
        onBlur={commitTyped}
        onKeyDown={(event) => {
          if (event.key === "Enter") commitTyped()
        }}
        className={cn("h-9 tabular-nums", affix && (affix.suffix ? "pr-7" : "pl-6"))}
      />
    </label>
  )

  return (
    <div>
      {/* Not a live region: the thumbs already announce their own values,
          and a readout that speaks on every pixel of a drag is noise. */}
      <p id={`${id}-readout`} className="mb-3 tabular-nums">
        {shownLo <= facet.min && shownHi >= facet.max
          ? `Any ${facet.label.toLowerCase()}`
          : `${format(shownLo)} – ${format(shownHi)}`}
      </p>
      <div
        role="group"
        aria-label={`${facet.label} range`}
        aria-describedby={`${id}-readout`}
        className="px-1 py-2"
        onPointerDown={() => {
          const end = () => {
            window.removeEventListener("pointerup", end)
            window.removeEventListener("pointercancel", end)
            commitPending()
          }
          window.addEventListener("pointerup", end)
          window.addEventListener("pointercancel", end)
        }}
        onKeyUp={commitPending}
      >
        <Slider
          min={facet.min}
          max={facet.max}
          step={facet.step ?? 1}
          value={[shownLo, shownHi]}
          onValueChange={(next: number | readonly number[]) => {
            const values: [number, number] =
              typeof next === "number" ? [next, next] : [next[0], next[1]]
            pending.current = values
            setDraft({ values, key })
          }}
        />
      </div>
      <div className="mt-3 flex items-center gap-2">
        {field("min")}
        <span aria-hidden className="text-muted-foreground">
          –
        </span>
        {field("max")}
      </div>
    </div>
  )
}

function ToggleRow({
  facet,
  className,
  ...props
}: React.HTMLAttributes<HTMLDivElement> & { facet: ToggleFacet }) {
  const { value, setValue } = useFilterPanel()
  return (
    <div
      data-slot="filter-panel-facet"
      data-facet={facet.id}
      className={cn("border-t py-4", className)}
      {...props}
    >
      <label className="flex cursor-pointer items-center justify-between gap-4">
        <span className="font-medium">{facet.label}</span>
        <span className="flex items-center gap-3">
          <Count count={facet.count} />
          <Switch
            checked={getToggle(value, facet.id)}
            onCheckedChange={(on: boolean) => setValue({ ...value, [facet.id]: on || undefined })}
          />
        </span>
      </label>
    </div>
  )
}
lib/filter-params.ts
/**
 * Filter state <-> URL search params.
 *
 * Pure functions, no React. The same parser runs in a server component (to
 * fetch the filtered page) and in the browser (to drive the panel), so the
 * URL is the only source of truth a listing page needs.
 *
 *   ?category=lighting&category=seating   list / swatch facets: repeated keys
 *   ?price=40-200                          range facets: `min-max`, either side optional
 *   ?in_stock=1                            toggle facets
 *
 * Params that are not facets (`q`, `sort`, `page`, utm tags) pass through.
 */

export interface FilterOption {
  value: string
  label: string
  /** Results if this option were selected. `0` disables it unless it is already on. */
  count?: number
  /** Any CSS colour or `url(...)` — renders the option as a swatch. */
  swatch?: string
  disabled?: boolean
}

interface FacetBase {
  /** Doubles as the URL key, so keep it short and stable. */
  id: string
  label: string
}

export interface ListFacet extends FacetBase {
  type: "list"
  options: FilterOption[]
  /** Options shown before "Show more". Selected options always stay visible. */
  limit?: number
}

export interface SwatchFacet extends FacetBase {
  type: "swatch"
  options: FilterOption[]
}

export interface RangeFacet extends FacetBase {
  type: "range"
  min: number
  max: number
  step?: number
  /** `currency` formats bounds with the panel's `currency` and `locale`. */
  format?: "currency" | "number"
  /** Suffix for `format: "number"`, e.g. "cm" or "★". */
  unit?: string
}

export interface ToggleFacet extends FacetBase {
  type: "toggle"
  count?: number
}

export type FilterFacet = ListFacet | SwatchFacet | RangeFacet | ToggleFacet

export interface FilterRange {
  min?: number
  max?: number
}

export type FilterValue = string[] | FilterRange | boolean

/** Keyed by facet id. A missing key means "no constraint". */
export type FilterState = Record<string, FilterValue | undefined>

/** Anything a router hands you: a query string, URLSearchParams, or Next's `searchParams` object. */
export type SearchInput =
  | string
  | URLSearchParams
  | Record<string, string | string[] | undefined>

export interface ActiveFilter {
  facetId: string
  facetLabel: string
  /** Set for list and swatch facets — the option being removed. */
  value?: string
  label: string
}

export function toSearchParams(input: SearchInput | null | undefined) {
  if (!input) return new URLSearchParams()
  if (typeof input === "string" || input instanceof URLSearchParams) {
    return new URLSearchParams(input)
  }
  const params = new URLSearchParams()
  for (const [key, raw] of Object.entries(input)) {
    if (raw === undefined) continue
    for (const value of Array.isArray(raw) ? raw : [raw]) params.append(key, value)
  }
  return params
}

// `-?` on both sides, so negative bounds still parse: `-10--2` is -10 to -2.
const RANGE = /^(-?\d+(?:\.\d+)?)?-(-?\d+(?:\.\d+)?)?$/

function normaliseRange(facet: RangeFacet, range: FilterRange | undefined) {
  if (!range) return undefined
  let min = Number.isFinite(range.min) ? range.min : undefined
  let max = Number.isFinite(range.max) ? range.max : undefined
  if (min !== undefined && max !== undefined && min > max) [min, max] = [max, min]
  // A bound at the edge of the scale is no constraint at all — drop it so the
  // URL stays clean and the chip does not read "$0 – $500".
  if (min !== undefined && min <= facet.min) min = undefined
  if (max !== undefined && max >= facet.max) max = undefined
  if (min === undefined && max === undefined) return undefined
  return { min, max }
}

export function parseFilterParams(
  input: SearchInput | null | undefined,
  facets: FilterFacet[]
): FilterState {
  const params = toSearchParams(input)
  const state: FilterState = {}

  for (const facet of facets) {
    if (facet.type === "list" || facet.type === "swatch") {
      const values = [...new Set(params.getAll(facet.id).filter(Boolean))]
      if (values.length) state[facet.id] = values
    } else if (facet.type === "range") {
      const match = RANGE.exec(params.get(facet.id) ?? "")
      if (!match) continue
      const range = normaliseRange(facet, {
        min: match[1] === undefined ? undefined : Number(match[1]),
        max: match[2] === undefined ? undefined : Number(match[2]),
      })
      if (range) state[facet.id] = range
    } else {
      const raw = params.get(facet.id)
      if (raw === "1" || raw === "true") state[facet.id] = true
    }
  }

  return state
}

/**
 * Writes `state` over `base`, keeping every non-facet param. Facet keys are
 * written in facet order, so equal states always produce equal URLs.
 */
export function serializeFilterParams(
  state: FilterState,
  facets: FilterFacet[],
  base?: SearchInput | null
) {
  const params = toSearchParams(base)
  for (const facet of facets) params.delete(facet.id)

  for (const facet of facets) {
    const value = state[facet.id]
    if (facet.type === "list" || facet.type === "swatch") {
      for (const option of getValues(state, facet.id)) params.append(facet.id, option)
    } else if (facet.type === "range") {
      const range = normaliseRange(facet, getRange(state, facet.id))
      if (range) params.set(facet.id, `${range.min ?? ""}-${range.max ?? ""}`)
    } else if (value === true) {
      params.set(facet.id, "1")
    }
  }

  return params
}

export function isSameFilters(a: FilterState, b: FilterState, facets: FilterFacet[]) {
  return (
    serializeFilterParams(a, facets).toString() ===
    serializeFilterParams(b, facets).toString()
  )
}

export function getValues(state: FilterState, id: string): string[] {
  const value = state[id]
  return Array.isArray(value) ? value : []
}

export function getRange(state: FilterState, id: string): FilterRange | undefined {
  const value = state[id]
  return value && typeof value === "object" && !Array.isArray(value) ? value : undefined
}

export function getToggle(state: FilterState, id: string) {
  return state[id] === true
}

export function toggleFilterValue(state: FilterState, id: string, value: string): FilterState {
  const current = getValues(state, id)
  const next = current.includes(value)
    ? current.filter((v) => v !== value)
    : [...current, value]
  return { ...state, [id]: next.length ? next : undefined }
}

/** Sets a range, dropping bounds that sit on the edge of the scale. */
export function setRange(state: FilterState, facet: RangeFacet, range: FilterRange): FilterState {
  return { ...state, [facet.id]: normaliseRange(facet, range) }
}

/** Removes one option, or the whole facet when `value` is omitted. */
export function removeFilter(state: FilterState, id: string, value?: string): FilterState {
  if (value === undefined) return { ...state, [id]: undefined }
  const next = getValues(state, id).filter((v) => v !== value)
  return { ...state, [id]: next.length ? next : undefined }
}

export function countActiveFilters(state: FilterState, facets: FilterFacet[]) {
  return describeActiveFilters(state, facets).length
}

export function formatRangeBound(
  facet: RangeFacet,
  value: number,
  { currency = "USD", locale }: { currency?: string; locale?: string } = {}
) {
  if (facet.format === "currency") {
    return new Intl.NumberFormat(locale, {
      style: "currency",
      currency,
      maximumFractionDigits: Number.isInteger(value) ? 0 : 2,
    }).format(value)
  }
  const n = new Intl.NumberFormat(locale).format(value)
  return facet.unit ? `${n}${facet.unit.length > 1 ? " " : ""}${facet.unit}` : n
}

/**
 * Human labels for everything that is on — the chips above the panel, and
 * the words an empty state needs to say *which* filters emptied the grid.
 */
export function describeActiveFilters(
  state: FilterState,
  facets: FilterFacet[],
  format: { currency?: string; locale?: string } = {}
): ActiveFilter[] {
  const active: ActiveFilter[] = []

  for (const facet of facets) {
    if (facet.type === "list" || facet.type === "swatch") {
      for (const value of getValues(state, facet.id)) {
        const option = facet.options.find((o) => o.value === value)
        active.push({
          facetId: facet.id,
          facetLabel: facet.label,
          value,
          label: option?.label ?? value,
        })
      }
    } else if (facet.type === "range") {
      const range = normaliseRange(facet, getRange(state, facet.id))
      if (!range) continue
      const lo = range.min === undefined ? null : formatRangeBound(facet, range.min, format)
      const hi = range.max === undefined ? null : formatRangeBound(facet, range.max, format)
      active.push({
        facetId: facet.id,
        facetLabel: facet.label,
        label: lo && hi ? `${lo} – ${hi}` : lo ? `${lo} and up` : `Up to ${hi}`,
      })
    } else if (getToggle(state, facet.id)) {
      active.push({ facetId: facet.id, facetLabel: facet.label, label: facet.label })
    }
  }

  return active
}
hooks/use-filter-params.ts
import * as React from "react"

import {
  isSameFilters,
  parseFilterParams,
  serializeFilterParams,
  type FilterFacet,
  type FilterState,
} from "@/lib/filter-params"

/** Fired after our own pushState — the History API raises no event for it. */
const LOCATION_EVENT = "ecomcn:locationchange"
const RESET_PAGE = ["page"]

function subscribe(onChange: () => void) {
  window.addEventListener("popstate", onChange)
  window.addEventListener(LOCATION_EVENT, onChange)
  return () => {
    window.removeEventListener("popstate", onChange)
    window.removeEventListener(LOCATION_EVENT, onChange)
  }
}

const readSearch = () => window.location.search

export interface UseFilterParamsOptions {
  /**
   * Your router's current params — Next's `useSearchParams()`, React
   * Router's `searchParams`. Omit it and the hook reads `window.location`.
   */
  searchParams?: URLSearchParams | string | null
  /**
   * How to write the next URL. Defaults to the History API, which is enough
   * when results are fetched on the client. Pass your router's push when the
   * server renders the results, so each change triggers a fetch.
   */
  navigate?: (href: string, options: { replace: boolean }) => void
  /** "push" makes Back undo the last filter, which is what shoppers expect. */
  history?: "push" | "replace"
  /** Dropped on every change — page 4 of the old result set means nothing now. */
  resetParams?: string[]
  /** The query string the server rendered with, so hydration matches. */
  initialSearch?: string
}

/**
 * `useState`, but the state lives in the URL. Deep links, refresh, share and
 * the Back button all work because there is no second copy to fall out of sync.
 *
 * Keep `facets` referentially stable (module scope or `useMemo`).
 */
export function useFilterParams(
  facets: FilterFacet[],
  {
    searchParams,
    navigate,
    history = "push",
    resetParams = RESET_PAGE,
    initialSearch = "",
  }: UseFilterParamsOptions = {}
) {
  const locationSearch = React.useSyncExternalStore(
    subscribe,
    readSearch,
    () => initialSearch
  )
  const controlled = searchParams !== undefined && searchParams !== null
  const search = controlled ? searchParams.toString() : locationSearch

  const filters = React.useMemo(
    () => parseFilterParams(search, facets),
    [search, facets]
  )

  const setFilters = React.useCallback(
    (next: FilterState | ((previous: FilterState) => FilterState)) => {
      // Read the live URL, not the render-time snapshot, so two updates in
      // one event compose instead of the second overwriting the first.
      const current = controlled ? search : window.location.search
      const previous = parseFilterParams(current, facets)
      const value = typeof next === "function" ? next(previous) : next

      // An unchanged state must not add a history entry, or Back stops working.
      if (isSameFilters(value, previous, facets)) return

      const params = serializeFilterParams(value, facets, current)
      for (const key of resetParams) params.delete(key)
      const query = params.toString()
      const href = `${window.location.pathname}${query ? `?${query}` : ""}${window.location.hash}`

      if (navigate) {
        navigate(href, { replace: history === "replace" })
        return
      }
      if (history === "replace") {
        window.history.replaceState(window.history.state, "", href)
      } else {
        window.history.pushState(null, "", href)
      }
      window.dispatchEvent(new Event(LOCATION_EVENT))
    },
    [controlled, search, facets, navigate, history, resetParams]
  )

  return [filters, setFilters] as const
}