"use client";
import { PlusIcon, SearchIcon, XIcon } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Fragment, type ReactNode, useId, useMemo, useState } from "react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
Combobox,
ComboboxChips,
ComboboxChipsInput,
ComboboxCollection,
ComboboxEmpty,
ComboboxGroup,
ComboboxGroupLabel,
ComboboxItem,
ComboboxList,
ComboboxPopup,
ComboboxPrimitive,
} from "@/components/ui/combobox";
import { cn } from "@/lib/utils";
export interface PickerOption {
id: string;
label: string;
hint?: string;
avatar?: string;
group: string;
}
type Row = PickerOption & { create?: boolean };
type Group = { value: string; items: Row[] };
const TINTS = ["bg-sky-700", "bg-violet-700", "bg-amber-700", "bg-emerald-700", "bg-rose-700"];
const tintFor = (id: string) => TINTS[[...id].reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 7) % TINTS.length];
const initials = (label: string) =>
label
.split(/\s+/)
.map((w) => w[0])
.join("")
.slice(0, 2)
.toUpperCase();
function OptionAvatar({ option, className }: { option: PickerOption; className?: string }) {
return (
{option.avatar && }
{initials(option.label)}
);
}
function Highlight({ text, query }: { text: string; query: string }) {
const q = query.trim();
if (!q) return <>{text}>;
const at = text.toLowerCase().indexOf(q.toLowerCase());
if (at < 0) return <>{text}>;
return (
<>
{text.slice(0, at)}
{text.slice(at, at + q.length)}
{text.slice(at + q.length)}
>
);
}
export interface PeoplePickerProps {
options: PickerOption[];
defaultValue?: PickerOption[];
value?: PickerOption[];
onValueChange?: (value: PickerOption[]) => void;
multiple?: boolean;
createGroup?: string | false;
placeholder?: string;
label?: ReactNode;
className?: string;
}
export function PeoplePicker({
options: initialOptions,
defaultValue = [],
value: valueProp,
onValueChange,
multiple = true,
createGroup = "New",
placeholder = "Search people or teams…",
label,
className,
}: PeoplePickerProps) {
const [options, setOptions] = useState(initialOptions);
const [inner, setInner] = useState(defaultValue);
const value = valueProp ?? inner;
const [query, setQuery] = useState("");
const reduce = useReducedMotion();
const labelId = useId();
const groups = useMemo(() => {
const q = query.trim().toLowerCase();
const byGroup = new Map();
for (const option of options) {
const hit = !q || option.label.toLowerCase().includes(q) || option.hint?.toLowerCase().includes(q);
if (!hit) continue;
byGroup.set(option.group, [...(byGroup.get(option.group) ?? []), option]);
}
const list: Group[] = [...byGroup].map(([g, items]) => ({ value: g, items }));
const exact = options.some((o) => o.label.toLowerCase() === q);
if (createGroup && q && !exact) {
const label = query.trim();
list.push({
value: "__create",
items: [{ id: `__create:${label}`, label, group: createGroup, create: true }],
});
}
return list;
}, [options, query, createGroup]);
const commit = (next: PickerOption[]) => {
if (valueProp === undefined) setInner(next);
onValueChange?.(next);
};
const onChange = (next: Row[], details?: { reason?: string }) => {
// Escape on a closed list would wipe every chip; only let it clear the typed text.
if (details?.reason === "escape-key") return;
let rows = next;
const created = rows.find((r) => r.create);
if (created) {
const option: PickerOption = {
id: `new-${created.label.toLowerCase().replace(/\s+/g, "-")}-${options.length}`,
label: created.label,
group: created.group,
hint: "Invite pending",
};
setOptions((list) => [...list, option]);
rows = rows.map((r) => (r.create ? option : r));
}
const clean = rows.map(({ create: _c, ...rest }) => rest as PickerOption);
commit(multiple ? clean : clean.slice(-1));
setQuery("");
};
const remove = (id: string) => commit(value.filter((v) => v.id !== id));
return (
{label && (
{label}
)}
filteredItems={groups}
inputValue={query}
isItemEqualToValue={(a, b) => a.id === b.id}
itemToStringLabel={(item) => item.label}
items={groups}
multiple
onInputValueChange={setQuery}
onValueChange={onChange}
value={value}
autoHighlight
>
}>
{value.map((option) => (
}
>
{option.label}
))}
No matches.
{(group: Group) => (
{group.value !== "__create" && {group.value}}
{(item: Row) =>
item.create ? (
Create “{item.label}”
) : (
{item.hint && (
)}
)
}
)}
);
}