"use client"; import { ChevronDownIcon, ListFilterIcon, SearchIcon, XIcon } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type KeyboardEvent, type ReactNode, useId, useMemo, useRef, useState } from "react"; import { Checkbox } from "@/components/ui/checkbox"; import { cn } from "@/lib/utils"; export interface FacetOption { value: string; label: string; count?: number; icon?: ReactNode; } export interface FacetGroup { id: string; label: string; options: FacetOption[]; defaultOpen?: boolean; } export type FacetValue = Record; export interface FacetsProps { groups: FacetGroup[]; value?: FacetValue; defaultValue?: FacetValue; onValueChange?: (value: FacetValue) => void; summary?: (value: FacetValue) => ReactNode; className?: string; } export function Facets({ groups, value: valueProp, defaultValue = {}, onValueChange, summary, className }: FacetsProps) { const [inner, setInner] = useState(defaultValue); const value = valueProp ?? inner; const [open, setOpen] = useState>(() => Object.fromEntries(groups.map((g, i) => [g.id, g.defaultOpen ?? i === 0])), ); const [query, setQuery] = useState(""); const reduce = useReducedMotion(); const baseId = useId(); const panelRef = useRef(null); const commit = (next: FacetValue) => { if (valueProp === undefined) setInner(next); onValueChange?.(next); }; const toggle = (group: string, option: string, on: boolean) => { const current = value[group] ?? []; commit({ ...value, [group]: on ? [...current, option] : current.filter((v) => v !== option) }); }; const chips = groups.flatMap((g) => (value[g.id] ?? []).map((v) => ({ group: g, option: g.options.find((o) => o.value === v) })), ); const total = chips.length; const q = query.trim().toLowerCase(); const visible = useMemo( () => groups .map((g) => ({ ...g, options: q ? g.options.filter((o) => o.label.toLowerCase().includes(q)) : g.options })) .filter((g) => g.options.length > 0), [groups, q], ); const onKeyDown = (event: KeyboardEvent) => { if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return; const rows = [...(panelRef.current?.querySelectorAll("[data-filter-row]") ?? [])]; const index = rows.indexOf(document.activeElement as HTMLElement); if (index === -1 && event.key !== "ArrowDown") return; event.preventDefault(); const next = event.key === "Home" ? 0 : event.key === "End" ? rows.length - 1 : event.key === "ArrowDown" ? Math.min(index + 1, rows.length - 1) : Math.max(index - 1, 0); rows[next]?.focus(); }; const spring = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 420, damping: 34 }; return (
Filters {total} {chips.map(({ group, option }) => option ? ( {group.label}: {option.label} ) : null, )} {total > 0 && ( commit({})} transition={spring} type="button" > Clear all )}
{summary &&
{summary(value)}
}
{visible.length === 0 &&

No filters match “{query}”.

} {visible.map((group) => { const expanded = q ? true : open[group.id]; const selected = value[group.id]?.length ?? 0; const panelId = `${baseId}-${group.id}`; return (
{expanded && (
{group.options.map((option) => { const checked = value[group.id]?.includes(option.value) ?? false; return ( ); })}
)}
); })}
); }