"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 && ( )} ) } )}
); }