"use client";
import * as React from "react";
import { motion, useAnimate } from "motion/react";
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
/** SSR-safe: hydrates as "no preference", then follows the media query. */
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
export interface ChipOption {
value: string;
label?: string;
/** Optional count shown after the label, e.g. number of matching results. */
count?: number;
}
type Spring = { stiffness: number; damping: number; mass: number };
export interface ChipSelectProps
extends Omit<React.ComponentPropsWithoutRef<"div">, "onChange" | "defaultValue" | "children"> {
options: ChipOption[];
/** Controlled selection (values in option order). */
value?: string[];
/** Initial selection when uncontrolled. */
defaultValue?: string[];
onChange?: (value: string[]) => void;
/** Visible group label. */
label?: React.ReactNode;
/** Helper text linked with aria-describedby. */
hint?: React.ReactNode;
/** Multi-select chips (checkboxes) or single-select chips (radios). */
multiple?: boolean;
/** Maximum selections in multi mode; 0 means no limit. */
max?: number;
/** Show this many chips and collapse the rest behind "+N more"; 0 shows all. */
collapseAfter?: number;
/** Show option counts. */
showCounts?: boolean;
/** Show "Select all" and "Clear" (multi mode). */
showActions?: boolean;
size?: "sm" | "md";
/** Spring for the check-in animation. */
spring?: Spring;
/** Selected fill, focus ring and limit feedback colour. */
accent?: string;
/** Adds one hidden input per selected value (repeated name) for native forms. */
name?: string;
disabled?: boolean;
}
const DEFAULT_SPRING: Spring = { stiffness: 600, damping: 30, mass: 0.7 };
/**
* Filter / interest chips. In multi mode each chip is a role="checkbox" inside a labelled group;
* in single mode they are radios in a radiogroup. Either way the set is one tab stop with arrow
* key navigation (roving tabindex), and Space or Enter toggles the focused chip.
*/
export function ChipSelect({
options,
value,
defaultValue,
onChange,
label = "Filters",
hint,
multiple = true,
max = 0,
collapseAfter = 8,
showCounts = true,
showActions = true,
size = "md",
spring = DEFAULT_SPRING,
accent = "#FF7448",
name,
disabled = false,
className = "",
style,
...props
}: ChipSelectProps) {
const reduceMotion = usePrefersReducedMotion();
const uid = React.useId();
const labelId = `${uid}-label`;
const hintId = `${uid}-hint`;
const statusId = `${uid}-status`;
const [inner, setInner] = React.useState<string[]>(() => defaultValue ?? []);
const selected = value ?? inner;
const [expanded, setExpanded] = React.useState(false);
// The group's tab stop: starts on the first selected chip, then follows focus.
const [activeIndex, setActiveIndex] = React.useState(() => Math.max(0, options.findIndex((o) => selected.includes(o.value))));
const [announcement, setAnnouncement] = React.useState("");
const [limitHit, setLimitHit] = React.useState(false);
const chipRefs = React.useRef<(HTMLButtonElement | null)[]>([]);
const pendingFocus = React.useRef<number | null>(null);
const limitTimer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const [scope, animate] = useAnimate<HTMLDivElement>();
React.useEffect(() => () => clearTimeout(limitTimer.current), []);
const limit = multiple && max > 0 ? max : Infinity;
const atLimit = selected.length >= limit;
const collapsible = collapseAfter > 0 && options.length > collapseAfter;
const visibleCount = collapsible && !expanded ? collapseAfter : options.length;
const hidden = options.slice(visibleCount);
const hiddenSelected = hidden.filter((o) => selected.includes(o.value)).length;
const active = Math.min(activeIndex, visibleCount - 1);
const labelOf = (o: ChipOption) => o.label ?? o.value;
// Focus the first revealed chip after "+N more" expands the list.
React.useEffect(() => {
if (pendingFocus.current === null) return;
chipRefs.current[pendingFocus.current]?.focus();
pendingFocus.current = null;
}, [expanded]);
const announce = (message: string) => setAnnouncement((prev) => (prev === message ? `${message} ` : message));
const commit = (next: string[]) => {
// Keep values in option order so the form value is stable.
const ordered = options.map((o) => o.value).filter((v) => next.includes(v));
if (value === undefined) setInner(ordered);
onChange?.(ordered);
};
const flagLimit = (index: number) => {
setLimitHit(true);
clearTimeout(limitTimer.current);
limitTimer.current = setTimeout(() => setLimitHit(false), 1600);
announce(`You can choose up to ${limit}. Deselect one to pick another.`);
const chip = chipRefs.current[index];
if (chip && !reduceMotion) animate(chip, { x: [0, -5, 5, -3, 2, 0] }, { duration: 0.35, ease: "easeOut" });
};
const toggle = (index: number) => {
const option = options[index];
if (!option || disabled) return;
const isOn = selected.includes(option.value);
if (!multiple) {
if (isOn) return;
commit([option.value]);
announce(`${labelOf(option)} selected.`);
return;
}
if (isOn) {
commit(selected.filter((v) => v !== option.value));
announce(`${labelOf(option)} removed. ${selected.length - 1} selected.`);
} else if (atLimit) {
flagLimit(index);
} else {
commit([...selected, option.value]);
announce(`${labelOf(option)} added. ${selected.length + 1} selected.`);
}
};
const focusChip = (index: number) => {
const i = Math.max(0, Math.min(visibleCount - 1, index));
setActiveIndex(i);
chipRefs.current[i]?.focus();
// Radios follow focus, as in a native radio group.
if (!multiple) toggle(i);
};
const handleKeyDown = (index: number, event: React.KeyboardEvent<HTMLButtonElement>) => {
switch (event.key) {
case "ArrowRight":
case "ArrowDown":
event.preventDefault();
focusChip(index + 1 >= visibleCount ? 0 : index + 1);
break;
case "ArrowLeft":
case "ArrowUp":
event.preventDefault();
focusChip(index - 1 < 0 ? visibleCount - 1 : index - 1);
break;
case "Home":
event.preventDefault();
focusChip(0);
break;
case "End":
event.preventDefault();
focusChip(visibleCount - 1);
break;
case " ":
case "Enter":
event.preventDefault();
toggle(index);
break;
}
};
const selectAll = () => {
commit(options.map((o) => o.value));
announce(`All ${options.length} selected.`);
};
const clear = () => {
commit([]);
announce("Selection cleared.");
};
const pad = size === "sm" ? "h-8 px-3 text-[12px]" : "h-9 px-3.5 text-[13px]";
const chipTransition = reduceMotion ? { duration: 0 } : { type: "spring" as const, ...spring };
const canSelectAll = multiple && limit >= options.length;
const actionClass =
"rounded-md px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 outline-hidden transition-colors hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-[var(--cs-accent)] disabled:pointer-events-none disabled:opacity-40 dark:text-zinc-400 dark:hover:text-white";
return (
<div
className={`flex w-full flex-col gap-3 ${disabled ? "opacity-50" : ""} ${className}`}
style={{ ["--cs-accent" as string]: accent, ...style }}
{...props}
>
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
<div className="flex items-baseline gap-2">
<span id={labelId} className="font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">
{label}
</span>
{multiple && (
<span
id={statusId}
className={`font-mono text-[10px] tabular-nums tracking-[0.14em] transition-colors duration-200 ${
limitHit ? "text-[color-mix(in_srgb,var(--cs-accent)_72%,black)] dark:text-[var(--cs-accent)]" : "text-zinc-400 dark:text-zinc-500"
}`}
>
{selected.length}
{Number.isFinite(limit) ? `/${limit}` : ""} selected
</span>
)}
</div>
{showActions && multiple && (
<div className="flex items-center gap-1">
{canSelectAll && (
<button type="button" className={actionClass} disabled={disabled || selected.length === options.length} onClick={selectAll}>
Select all
</button>
)}
<button type="button" className={actionClass} disabled={disabled || selected.length === 0} onClick={clear}>
Clear
</button>
</div>
)}
</div>
<div
ref={scope}
role={multiple ? "group" : "radiogroup"}
aria-labelledby={labelId}
aria-describedby={[multiple ? statusId : null, hint ? hintId : null].filter(Boolean).join(" ") || undefined}
aria-disabled={disabled || undefined}
className="flex flex-wrap items-center gap-2"
>
{options.slice(0, visibleCount).map((option, i) => {
const on = selected.includes(option.value);
const blocked = !on && atLimit;
return (
<motion.button
key={option.value}
ref={(el: HTMLButtonElement | null) => {
chipRefs.current[i] = el;
}}
layout={reduceMotion ? false : "position"}
transition={chipTransition}
type="button"
role={multiple ? "checkbox" : "radio"}
aria-checked={on}
disabled={disabled}
tabIndex={i === active ? 0 : -1}
onFocus={() => setActiveIndex(i)}
onClick={() => {
setActiveIndex(i);
toggle(i);
}}
onKeyDown={(e: React.KeyboardEvent<HTMLButtonElement>) => handleKeyDown(i, e)}
whileTap={reduceMotion || disabled ? undefined : { scale: 0.95 }}
className={`inline-flex select-none items-center rounded-full border font-medium tracking-tight outline-hidden transition-[background-color,border-color,color] duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--cs-accent)] motion-reduce:transition-none ${pad} ${
on
? "border-[var(--cs-accent)] bg-[color-mix(in_srgb,var(--cs-accent)_14%,transparent)] text-zinc-950 dark:text-white"
: blocked
? "border-zinc-900/10 text-zinc-400 dark:border-white/10 dark:text-zinc-500"
: "border-zinc-900/12 bg-white text-zinc-700 hover:border-zinc-900/30 hover:text-zinc-950 dark:border-white/12 dark:bg-white/[0.03] dark:text-zinc-300 dark:hover:border-white/30 dark:hover:text-white"
}`}
>
<motion.span
aria-hidden="true"
initial={false}
animate={{ width: on ? 16 : 0, opacity: on ? 1 : 0 }}
transition={chipTransition}
className="inline-flex shrink-0 items-center overflow-hidden"
>
<svg viewBox="0 0 12 12" className="size-3 shrink-0 text-[var(--cs-accent)]" fill="none">
<motion.path
d="M2.5 6.4l2.3 2.3 4.8-5.2"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
initial={false}
animate={{ pathLength: on ? 1 : 0 }}
transition={reduceMotion ? { duration: 0 } : { duration: 0.22, delay: on ? 0.06 : 0, ease: "easeOut" }}
/>
</svg>
</motion.span>
<span>{labelOf(option)}</span>
{showCounts && option.count !== undefined && (
<span
className={`ml-1.5 font-mono text-[10px] tabular-nums tracking-normal ${
on ? "text-zinc-600 dark:text-zinc-300" : "text-zinc-400 dark:text-zinc-500"
}`}
>
{option.count}
<span className="sr-only"> results</span>
</span>
)}
</motion.button>
);
})}
{collapsible && (
<button
type="button"
aria-expanded={expanded}
onClick={() => {
if (!expanded) pendingFocus.current = collapseAfter;
setExpanded(!expanded);
if (expanded && active >= collapseAfter) setActiveIndex(0);
}}
className={`inline-flex items-center gap-1.5 rounded-full border border-dashed border-zinc-900/20 font-mono uppercase tracking-[0.12em] text-zinc-500 outline-hidden transition-colors hover:border-zinc-900/40 hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--cs-accent)] dark:border-white/20 dark:text-zinc-400 dark:hover:border-white/40 dark:hover:text-white ${
size === "sm" ? "h-8 px-3 text-[10px]" : "h-9 px-3.5 text-[10px]"
}`}
>
{expanded ? (
"Show less"
) : (
<>
+{hidden.length} more
{hiddenSelected > 0 && (
<span className="rounded-full bg-[var(--cs-accent)] px-1.5 py-px text-[9px] tracking-normal text-zinc-950">
{hiddenSelected}
<span className="sr-only"> selected</span>
</span>
)}
</>
)}
</button>
)}
</div>
{(hint || (multiple && Number.isFinite(limit))) && (
<p id={hint ? hintId : undefined} className="text-[12px] leading-snug text-zinc-500 dark:text-zinc-400">
{atLimit && Number.isFinite(limit) ? (
<span className={limitHit ? "font-medium text-[color-mix(in_srgb,var(--cs-accent)_72%,black)] dark:text-[var(--cs-accent)]" : ""}>
Limit reached. Deselect one to choose another.
</span>
) : (
hint
)}
</p>
)}
<span className="sr-only" aria-live="polite" aria-atomic="true">
{announcement}
</span>
{name && selected.map((v) => <input key={v} type="hidden" name={name} value={v} />)}
</div>
);
}