"use client";
import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
export interface FilterableItem {
/** Stable, unique id (used as the animation key). */
id: string;
title: string;
tags: string[];
/** Optional body for the default card. */
content?: React.ReactNode;
}
export interface SortOption<T extends FilterableItem = FilterableItem> {
value: string;
label: string;
/** Comparator; omit to keep the original order. */
compare?: (a: T, b: T) => number;
}
type Spring = { stiffness: number; damping: number; mass: number };
export interface FilterableGridProps<T extends FilterableItem = FilterableItem>
extends Omit<React.HTMLAttributes<HTMLDivElement>, "children" | "defaultValue" | "onChange"> {
items: T[];
/** Chips to show, in order. Defaults to every tag in the order it first appears. */
filters?: string[];
/** Pick several tags at once, or one at a time. */
selection?: "multi" | "single";
/** With several tags picked: show items that have any of them, or all of them. */
match?: "any" | "all";
/** Selected tags (controlled). An empty array shows everything. */
selected?: string[];
/** Initially selected tags (uncontrolled). */
defaultSelected?: string[];
onSelectedChange?: (selected: string[]) => void;
/** Sort choices. Defaults to Featured (original order), A–Z and Z–A by title. */
sortOptions?: SortOption<T>[];
/** Show the sort menu. */
showSort?: boolean;
/** Show the “Showing x of y” count (it is also the live region). */
showCount?: boolean;
/** Text of the count and its announcement. */
countLabel?: (shown: number, total: number) => string;
/** Show how many items carry each tag inside its chip. */
showTagCounts?: boolean;
/** Renders one item. Defaults to a simple card with title, tags and content. */
renderItem?: (item: T) => React.ReactNode;
/** Shown when no item matches. A “Clear filters” button is added below it. */
emptyState?: React.ReactNode;
/** Smallest column width in px; the grid fits as many columns as it can. */
minItemWidth?: number;
/** Gap between items in px. */
gap?: number;
/** Spring for items moving to their new place. */
spring?: Spring;
/** Accessible name of the chip group. */
label?: string;
/** Active chip and focus colour. */
accent?: string;
}
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
const byTitle = (a: FilterableItem, b: FilterableItem) => a.title.localeCompare(b.title);
const DEFAULT_SORTS: SortOption<FilterableItem>[] = [
{ value: "featured", label: "Featured" },
{ value: "az", label: "A–Z", compare: byTitle },
{ value: "za", label: "Z–A", compare: (a, b) => byTitle(b, a) },
];
const defaultCount = (shown: number, total: number) => `Showing ${shown} of ${total}`;
function DefaultCard({ item }: { item: FilterableItem }) {
return (
<div className="flex h-full flex-col rounded-xl border border-zinc-900/10 bg-white p-4 dark:border-white/10 dark:bg-zinc-900">
<h3 className="text-sm font-semibold tracking-tight text-zinc-950 dark:text-zinc-50">{item.title}</h3>
{item.content && <div className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">{item.content}</div>}
<ul className="mt-auto flex list-none flex-wrap gap-1 p-0 pt-3" aria-label="Tags">
{item.tags.map((tag) => (
<li key={tag} className="rounded-full border border-zinc-900/10 px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.1em] text-zinc-500 dark:border-white/10 dark:text-zinc-400">
{tag}
</li>
))}
</ul>
</div>
);
}
/**
* A grid of items filtered by tag chips and sorted from a menu. When the selection changes,
* items that stay glide to their new cells (motion layout), leaving items pop out of the flow
* and shrink away, and new ones grow in (AnimatePresence popLayout). A polite live region
* reports “Showing x of y”.
*/
export function FilterableGrid<T extends FilterableItem>({
items,
filters,
selection = "multi",
match = "any",
selected: selectedProp,
defaultSelected = [],
onSelectedChange,
sortOptions,
showSort = true,
showCount = true,
countLabel = defaultCount,
showTagCounts = true,
renderItem,
emptyState,
minItemWidth = 220,
gap = 16,
spring = { stiffness: 400, damping: 36, mass: 0.9 },
label = "Filter by tag",
accent = "#FF7448",
className = "",
style,
...props
}: FilterableGridProps<T>) {
const uid = React.useId();
const reduceMotion = usePrefersReducedMotion();
const [internal, setInternal] = React.useState<string[]>(defaultSelected);
const selected = selectedProp ?? internal;
const sorts = (sortOptions ?? DEFAULT_SORTS) as SortOption<T>[];
const [sortValue, setSortValue] = React.useState(sorts[0]?.value ?? "");
const chips = filters ?? [...new Set(items.flatMap((item) => item.tags))];
const tagCounts = new Map<string, number>();
for (const item of items) for (const tag of item.tags) tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
const setSelected = (next: string[]) => {
if (selectedProp === undefined) setInternal(next);
onSelectedChange?.(next);
};
const toggle = (tag: string) => {
const on = selected.includes(tag);
if (selection === "single") setSelected(on ? [] : [tag]);
else setSelected(on ? selected.filter((t) => t !== tag) : [...selected, tag]);
};
// In single mode only the first selected tag counts, even if more are passed in.
const active = selection === "single" ? selected.slice(0, 1) : selected;
const filtered = items.filter((item) => {
if (!active.length) return true;
return match === "all" ? active.every((t) => item.tags.includes(t)) : active.some((t) => item.tags.includes(t));
});
const compare = sorts.find((s) => s.value === sortValue)?.compare;
const visible = compare ? [...filtered].sort(compare) : filtered;
const layoutTransition = reduceMotion ? { duration: 0 } : { type: "spring" as const, ...spring };
const focusRing =
"outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--fg-accent)]";
const chipBase = `inline-flex h-8 items-center gap-1.5 rounded-full border px-3 text-[13px] font-medium tracking-tight transition-colors duration-150 ${focusRing}`;
const chipOff =
"border-zinc-900/10 bg-white text-zinc-600 hover:border-zinc-900/25 hover:text-zinc-950 dark:border-white/10 dark:bg-white/[0.03] dark:text-zinc-400 dark:hover:border-white/25 dark:hover:text-zinc-100";
const chipOn = "border-[var(--fg-accent)] bg-[var(--fg-accent)] text-zinc-950";
return (
<div
className={`flex w-full min-w-0 flex-col gap-5 ${className}`}
style={{ ["--fg-accent" as string]: accent, ...style }}
{...props}
>
<div className="flex flex-wrap items-center justify-between gap-3">
<div role="group" aria-label={label} className="flex flex-wrap gap-2">
<button type="button" aria-pressed={active.length === 0} onClick={() => setSelected([])} className={`${chipBase} ${active.length === 0 ? chipOn : chipOff}`}>
All
{showTagCounts && <span className="font-mono text-[10px] opacity-60">{items.length}</span>}
</button>
{chips.map((tag) => {
const on = active.includes(tag);
return (
<button key={tag} type="button" aria-pressed={on} onClick={() => toggle(tag)} className={`${chipBase} ${on ? chipOn : chipOff}`}>
{tag}
{showTagCounts && (
<span className="font-mono text-[10px] opacity-60">
<span className="sr-only">, </span>
{tagCounts.get(tag) ?? 0}
<span className="sr-only"> items</span>
</span>
)}
</button>
);
})}
</div>
{showSort && sorts.length > 1 && (
<label className="flex items-center gap-2 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">
Sort
<select
value={sortValue}
onChange={(event) => setSortValue(event.target.value)}
className={`h-8 rounded-lg border border-zinc-900/10 bg-white px-2 font-sans text-[13px] normal-case tracking-tight text-zinc-900 dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-100 ${focusRing}`}
>
{sorts.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
</label>
)}
</div>
{/* Always rendered so it can announce changes; visually hidden when the count is turned off. */}
<p
id={`${uid}-count`}
aria-live="polite"
className={showCount ? "-mt-2 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400" : "sr-only"}
>
{countLabel(visible.length, items.length)}
</p>
<ul
aria-describedby={`${uid}-count`}
className="relative m-0 grid list-none p-0"
style={{ gap, gridTemplateColumns: `repeat(auto-fill, minmax(min(100%, ${minItemWidth}px), 1fr))` }}
>
<AnimatePresence mode="popLayout" initial={false}>
{visible.map((item) => (
<motion.li
key={item.id}
layout={!reduceMotion}
initial={reduceMotion ? false : { opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={reduceMotion ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, scale: 0.9 }}
transition={{ layout: layoutTransition, default: reduceMotion ? { duration: 0 } : { duration: 0.22, ease: [0.22, 1, 0.36, 1] } }}
className="min-w-0"
>
{renderItem ? renderItem(item) : <DefaultCard item={item} />}
</motion.li>
))}
</AnimatePresence>
</ul>
{visible.length === 0 && (
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed border-zinc-900/15 px-6 py-10 text-center dark:border-white/15">
<div className="text-sm text-zinc-600 dark:text-zinc-400">{emptyState ?? "Nothing matches these filters."}</div>
<button
type="button"
onClick={() => setSelected([])}
className={`h-8 rounded-full border border-zinc-900/15 px-3 text-[13px] font-medium text-zinc-900 hover:bg-zinc-900/[0.04] dark:border-white/15 dark:text-zinc-100 dark:hover:bg-white/[0.06] ${focusRing}`}
>
Clear filters
</button>
</div>
)}
</div>
);
}