"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, ChevronDown, Plus, X } from "lucide-react";
import { cn } from "@/lib/utils";
export type ComboOption = {
value: string;
label: string;
description?: string;
/** Any CSS colour for the chip dot. */
color?: string;
disabled?: boolean;
};
export type ComboGroup = { label: string; options: ComboOption[] };
export interface MultiSelectComboboxProps {
groups?: ComboGroup[];
value?: string[];
defaultValue?: string[];
onChange?: (values: string[], options: ComboOption[]) => void;
/** Allow creating options that don't exist yet. */
creatable?: boolean;
onCreate?: (option: ComboOption) => void;
maxSelected?: number;
label?: string;
placeholder?: string;
emptyText?: string;
/** Emits one hidden input per selected value. */
name?: string;
defaultOpen?: boolean;
className?: string;
}
const DEFAULT_GROUPS: ComboGroup[] = [
{
label: "Frontend",
options: [
{ value: "react", label: "React", color: "#0ea5e9" },
{ value: "vue", label: "Vue", color: "#10b981" },
{ value: "svelte", label: "Svelte", color: "#f97316" },
{ value: "solid", label: "Solid", color: "#3b82f6" },
],
},
{
label: "Backend",
options: [
{ value: "node", label: "Node.js", color: "#22c55e" },
{ value: "go", label: "Go", color: "#06b6d4" },
{ value: "rust", label: "Rust", color: "#ea580c" },
{ value: "elixir", label: "Elixir", color: "#8b5cf6" },
],
},
];
const norm = (s: string) => s.trim().toLowerCase();
const slugify = (s: string) => norm(s).replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "item";
export function MultiSelectCombobox({
groups: groupsProp = DEFAULT_GROUPS,
value,
defaultValue = [],
onChange,
creatable = true,
onCreate,
maxSelected,
label = "Technologies",
placeholder = "Search or add…",
emptyText = "No matches",
name,
defaultOpen = false,
className,
}: MultiSelectComboboxProps) {
const uid = React.useId();
const reduce = useReducedMotion();
const [inner, setInner] = React.useState<string[]>(defaultValue);
const selected = value ?? inner;
const [created, setCreated] = React.useState<ComboOption[]>([]);
const [open, setOpen] = React.useState(defaultOpen);
const [query, setQuery] = React.useState("");
const [active, setActive] = React.useState<string | null>(null);
const [chipFocus, setChipFocus] = React.useState<number | null>(null);
const [announce, setAnnounce] = React.useState("");
const [shake, setShake] = React.useState(0);
const rootRef = React.useRef<HTMLDivElement>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
const listRef = React.useRef<HTMLUListElement>(null);
const groups = React.useMemo(
() => (created.length ? [...groupsProp, { label: "Created", options: created }] : groupsProp),
[groupsProp, created],
);
const all = React.useMemo(() => groups.flatMap((g) => g.options), [groups]);
const byValue = React.useMemo(() => new Map(all.map((o) => [o.value, o])), [all]);
const selectedOpts = selected.map((v) => byValue.get(v) ?? { value: v, label: v });
const limitReached = maxSelected !== undefined && selected.length >= maxSelected;
const q = norm(query);
const filtered = React.useMemo(
() =>
groups
.map((g) => ({ ...g, options: g.options.filter((o) => !q || norm(o.label).includes(q) || norm(o.description ?? "").includes(q)) }))
.filter((g) => g.options.length),
[groups, q],
);
const exact = all.some((o) => norm(o.label) === q);
const canCreate = creatable && !!q && !exact;
const CREATE = "__create__";
const navigable = [
...filtered.flatMap((g) => g.options.filter((o) => !o.disabled && (!limitReached || selected.includes(o.value))).map((o) => o.value)),
...(canCreate && !limitReached ? [CREATE] : []),
];
const activeValue = active && navigable.includes(active) ? active : navigable[0] ?? null;
const optId = (v: string) => `${uid}-o-${v.replace(/[^a-zA-Z0-9_-]/g, "_")}`;
const setSelected = (next: string[]) => {
if (value === undefined) setInner(next);
onChange?.(next, next.map((v) => byValue.get(v) ?? { value: v, label: v }));
};
const toggle = (v: string) => {
const opt = byValue.get(v);
if (selected.includes(v)) {
setSelected(selected.filter((x) => x !== v));
setAnnounce(`${opt?.label ?? v} removed`);
return;
}
if (limitReached) {
setShake((n) => n + 1);
setAnnounce(`Limit of ${maxSelected} reached`);
return;
}
setSelected([...selected, v]);
setAnnounce(`${opt?.label ?? v} selected`);
};
const create = () => {
const text = query.trim();
if (!text) return;
if (limitReached) {
setShake((n) => n + 1);
return;
}
let v = slugify(text);
while (byValue.has(v)) v += "-1";
const opt: ComboOption = { value: v, label: text, color: "var(--color-primary)" };
setCreated((c) => [...c, opt]);
onCreate?.(opt);
setSelected([...selected, v]);
setAnnounce(`Created ${text}`);
setQuery("");
};
const removeAt = (i: number) => {
const v = selected[i];
if (v === undefined) return;
toggle(v);
};
// close on outside press
React.useEffect(() => {
if (!open) return;
const onDown = (e: PointerEvent) => {
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("pointerdown", onDown);
return () => document.removeEventListener("pointerdown", onDown);
}, [open]);
React.useEffect(() => {
if (!open || !activeValue) return;
document.getElementById(optId(activeValue))?.scrollIntoView({ block: "nearest" });
// eslint-disable-next-line react-hooks/exhaustive-deps -- optId depends only on uid
}, [open, activeValue]);
const move = (delta: number) => {
if (!navigable.length) return;
const i = activeValue ? navigable.indexOf(activeValue) : -1;
setActive(navigable[(i + delta + navigable.length) % navigable.length]);
};
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
const caretAtStart = e.currentTarget.selectionStart === 0 && e.currentTarget.selectionEnd === 0;
if (chipFocus !== null) {
if (e.key === "ArrowLeft") {
e.preventDefault();
setChipFocus(Math.max(0, chipFocus - 1));
return;
}
if (e.key === "ArrowRight") {
e.preventDefault();
setChipFocus(chipFocus + 1 >= selected.length ? null : chipFocus + 1);
return;
}
if (e.key === "Backspace" || e.key === "Delete") {
e.preventDefault();
removeAt(chipFocus);
const nextLen = selected.length - 1;
setChipFocus(nextLen === 0 ? null : Math.min(chipFocus, nextLen - 1));
return;
}
if (e.key !== "Shift") setChipFocus(null);
}
switch (e.key) {
case "ArrowDown":
e.preventDefault();
if (!open) setOpen(true);
else move(1);
break;
case "ArrowUp":
e.preventDefault();
if (!open) setOpen(true);
else move(-1);
break;
case "Enter":
if (!open) return;
e.preventDefault();
if (activeValue === CREATE) create();
else if (activeValue) toggle(activeValue);
break;
case "Escape":
if (open) {
e.preventDefault();
setOpen(false);
} else if (query) setQuery("");
break;
case "Tab":
setOpen(false);
break;
case "ArrowLeft":
if (caretAtStart && selected.length) {
e.preventDefault();
setChipFocus(selected.length - 1);
}
break;
case "Backspace":
if (!query && selected.length) {
e.preventDefault();
setChipFocus(selected.length - 1); // first press highlights, second removes
}
break;
}
};
return (
<div ref={rootRef} className={cn("relative w-full max-w-md", className)}>
<div className="mb-1.5 flex items-baseline justify-between gap-2">
<label htmlFor={`${uid}-input`} id={`${uid}-label`} className="text-sm font-medium">
{label}
</label>
{maxSelected !== undefined && (
<motion.span
key={shake}
animate={shake && !reduce ? { x: [0, -5, 5, -3, 3, 0] } : undefined}
transition={{ duration: 0.35 }}
className={cn("text-xs tabular-nums", limitReached ? "font-medium text-amber-600 dark:text-amber-400" : "text-muted-foreground")}
>
{selected.length}/{maxSelected}
</motion.span>
)}
</div>
<div
onClick={() => {
inputRef.current?.focus();
setOpen(true);
}}
className={cn(
"flex min-h-11 w-full cursor-text flex-wrap items-center gap-1.5 rounded-xl border bg-background py-1.5 pl-1.5 pr-9 shadow-xs transition",
"hover:border-foreground/20 focus-within:border-ring focus-within:ring-4 focus-within:ring-ring/15",
)}
>
<ul aria-label="Selected" className="contents">
<AnimatePresence initial={false}>
{selectedOpts.map((o, i) => (
<motion.li
key={o.value}
layout={!reduce}
initial={{ opacity: 0, scale: 0.6 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.6, transition: { duration: 0.12 } }}
transition={{ type: "spring", stiffness: 600, damping: 32 }}
className={cn(
"inline-flex h-7 max-w-full items-center gap-1.5 rounded-lg border bg-muted/70 pl-2 pr-1 text-[13px] font-medium",
chipFocus === i && "border-ring bg-accent ring-2 ring-ring/30",
)}
>
<span aria-hidden className="size-2 shrink-0 rounded-full" style={{ background: o.color ?? "currentColor" }} />
<span className="truncate">{o.label}</span>
<button
type="button"
tabIndex={-1}
aria-label={`Remove ${o.label}`}
onClick={(e) => {
e.stopPropagation();
toggle(o.value);
inputRef.current?.focus();
}}
className="grid size-5 place-items-center rounded-md text-muted-foreground transition hover:bg-background hover:text-foreground"
>
<X className="size-3" />
</button>
</motion.li>
))}
</AnimatePresence>
</ul>
<input
ref={inputRef}
id={`${uid}-input`}
role="combobox"
aria-expanded={open}
aria-controls={`${uid}-list`}
aria-autocomplete="list"
aria-activedescendant={open && activeValue ? optId(activeValue) : undefined}
aria-describedby={`${uid}-help`}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setActive(null);
setChipFocus(null);
setOpen(true);
}}
onKeyDown={onKeyDown}
onFocus={() => setOpen(true)}
onBlur={() => setChipFocus(null)}
placeholder={selected.length ? "" : placeholder}
autoComplete="off"
className="h-7 min-w-24 flex-1 bg-transparent px-1.5 text-sm outline-none placeholder:text-muted-foreground/70"
/>
</div>
<button
type="button"
tabIndex={-1}
aria-label={open ? "Close options" : "Open options"}
onClick={() => {
setOpen((o) => !o);
inputRef.current?.focus();
}}
className="absolute right-2 top-[calc(1.25rem+0.375rem+0.5rem)] grid size-7 place-items-center rounded-lg text-muted-foreground transition hover:bg-muted hover:text-foreground"
>
<ChevronDown className={cn("size-4 transition-transform duration-200", open && "rotate-180")} />
</button>
<p id={`${uid}-help`} className="sr-only">
Use arrow keys to browse, Enter to toggle, Backspace to remove the last chip.
</p>
<span className="sr-only" aria-live="polite">
{announce}
</span>
<AnimatePresence>
{open && (
<motion.div
initial={reduce ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -4, scale: 0.98, transition: { duration: 0.12 } }}
transition={{ type: "spring", stiffness: 520, damping: 34 }}
className="absolute inset-x-0 top-[calc(100%+6px)] z-50 origin-top overflow-hidden rounded-xl border bg-popover text-popover-foreground shadow-xl shadow-black/10 dark:shadow-black/40"
>
<ul
ref={listRef}
id={`${uid}-list`}
role="listbox"
tabIndex={-1}
aria-multiselectable
aria-labelledby={`${uid}-label`}
className="max-h-64 overflow-y-auto overscroll-contain p-1.5 [scrollbar-width:thin]"
>
{filtered.map((g) => (
<li key={g.label} role="presentation">
<div id={`${uid}-g-${slugify(g.label)}`} className="px-2 pb-1 pt-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/80">
{g.label}
</div>
<ul role="group" aria-labelledby={`${uid}-g-${slugify(g.label)}`}>
{g.options.map((o) => {
const isSel = selected.includes(o.value);
const blocked = o.disabled || (limitReached && !isSel);
const isActive = activeValue === o.value;
return (
<li
key={o.value}
id={optId(o.value)}
role="option"
aria-selected={isSel}
aria-disabled={blocked || undefined}
onPointerDown={(e) => e.preventDefault()}
onPointerMove={() => !blocked && active !== o.value && setActive(o.value)}
onClick={() => !blocked && toggle(o.value)}
className={cn(
"relative flex h-9 cursor-pointer select-none items-center gap-2.5 rounded-lg px-2 text-sm transition-colors",
isActive && "bg-accent",
blocked && "cursor-not-allowed opacity-40",
)}
>
<span
aria-hidden
className={cn(
"grid size-4 shrink-0 place-items-center rounded-[5px] border transition-colors",
isSel ? "border-primary bg-primary text-primary-foreground" : "border-foreground/25 bg-background",
)}
>
<AnimatePresence initial={false}>
{isSel && (
<motion.span
initial={{ scale: 0, rotate: -30 }}
animate={{ scale: 1, rotate: 0 }}
exit={{ scale: 0 }}
transition={{ type: "spring", stiffness: 700, damping: 26 }}
>
<Check className="size-3" strokeWidth={3} />
</motion.span>
)}
</AnimatePresence>
</span>
<span aria-hidden className="size-2 shrink-0 rounded-full" style={{ background: o.color ?? "currentColor" }} />
<span className="min-w-0 flex-1 truncate">{o.label}</span>
{o.description && <span className="truncate text-xs text-muted-foreground">{o.description}</span>}
</li>
);
})}
</ul>
</li>
))}
{canCreate && (
<li
id={optId(CREATE)}
role="option"
aria-selected={false}
aria-disabled={limitReached || undefined}
onPointerDown={(e) => e.preventDefault()}
onPointerMove={() => setActive(CREATE)}
onClick={create}
className={cn(
"mt-1 flex h-9 cursor-pointer items-center gap-2.5 rounded-lg border-t px-2 text-sm",
activeValue === CREATE && "bg-accent",
limitReached && "cursor-not-allowed opacity-40",
)}
>
<span className="grid size-4 place-items-center rounded-[5px] bg-primary/15 text-primary">
<Plus className="size-3" strokeWidth={3} />
</span>
<span className="truncate">
Create <span className="font-semibold">“{query.trim()}”</span>
</span>
</li>
)}
{!filtered.length && !canCreate && (
<li role="presentation" className="px-3 py-6 text-center text-sm text-muted-foreground">
{emptyText}
</li>
)}
</ul>
{limitReached && (
<div className="border-t bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
You can pick up to {maxSelected}. Remove one to choose another.
</div>
)}
</motion.div>
)}
</AnimatePresence>
{name && selected.map((v) => <input key={v} type="hidden" name={name} value={v} />)}
</div>
);
}