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}.jsonStep 2Add the block
$ npx shadcn@latest add @ecomcn/filter-panelPreview — live
100%
Usage
lib/facets.tsimport 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 orderimport {
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 serverimport { 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.
FilterPanelContextValuefacets: 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>FilterPanelPropsfacets: 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.ReactNodeFilterPanelFacetProps/** The facet's id — the same string that keys it in the URL. */
id: string
/** Collapsible facets start open unless this is false. */
defaultOpen?: booleanSource
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.tsimport * 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
}