import { useEffect, useMemo, useState, type ComponentProps, type ReactNode } from "react"; import { ChevronRight } from "lucide-react"; import { Identity } from "@/components/Identity"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Skeleton } from "@/components/ui/skeleton"; import { cn } from "@/lib/utils"; export interface MemberMultiSelectOption { userId: string; name?: string | null; email?: string | null; image?: string | null; } export function memberOptionLabel(member: MemberMultiSelectOption): string { return member.name?.trim() || member.email?.trim() || member.userId; } /** * People counterpart to `AgentMultiSelect` (PAP-17835). * * The organization-grant audience editor needs a searchable, keyboard-operable, * selected-first people picker, and the design calls for one system component * rather than a feature-local audience picker — a future Teams primitive can * replace what feeds it without changing this API. Behaviour deliberately * matches `AgentMultiSelect`: pass `onSave` for a staged draft (Save/Cancel), or * `onChange` for live selection. */ export function MemberMultiSelect({ members, selectedUserIds, onChange, onSave, loading = false, disabled = false, pending = false, getDescription, triggerLabel, triggerVariant = "outline", triggerSize = "default", triggerFullWidth = true, triggerClassName, contentAlign = "start", emptyMessage = "No members yet.", showSelectionPreview = true, filterPlaceholder = "Filter people", onOpenChange, }: { members: MemberMultiSelectOption[]; selectedUserIds: Set; onChange?: (next: Set) => void; onSave?: (next: Set) => void; loading?: boolean; disabled?: boolean; pending?: boolean; getDescription?: (member: MemberMultiSelectOption) => string | null | undefined; triggerLabel?: string; triggerVariant?: ComponentProps["variant"]; triggerSize?: ComponentProps["size"]; triggerFullWidth?: boolean; triggerClassName?: string; contentAlign?: ComponentProps["align"]; emptyMessage?: string; showSelectionPreview?: boolean; filterPlaceholder?: string; onOpenChange?: (open: boolean) => void; }): ReactNode { const [open, setOpen] = useState(false); const [filter, setFilter] = useState(""); const [draftUserIds, setDraftUserIds] = useState>(new Set(selectedUserIds)); const staged = Boolean(onSave); const workingUserIds = staged ? draftUserIds : selectedUserIds; useEffect(() => { if (open && staged) setDraftUserIds(new Set(selectedUserIds)); }, [open, selectedUserIds, staged]); const normalizedFilter = filter.trim().toLowerCase(); const filteredMembers = useMemo( () => members .filter((member) => { const description = getDescription?.(member) ?? member.email ?? ""; return `${memberOptionLabel(member)} ${description}`.toLowerCase().includes(normalizedFilter); }) .sort((a, b) => { const aSelected = workingUserIds.has(a.userId); const bSelected = workingUserIds.has(b.userId); if (aSelected !== bSelected) return aSelected ? -1 : 1; return memberOptionLabel(a).localeCompare(memberOptionLabel(b)); }), [members, getDescription, normalizedFilter, workingUserIds], ); const selectedCount = selectedUserIds.size; const selectedMembers = members.filter((member) => selectedUserIds.has(member.userId)); function setSelection(next: Set) { if (staged) setDraftUserIds(next); else onChange?.(next); } return (
{ setOpen(nextOpen); onOpenChange?.(nextOpen); if (!nextOpen) setFilter(""); }} >
setFilter(event.target.value)} placeholder={filterPlaceholder} className="h-8" autoFocus />
{loading ? (
) : members.length === 0 ? (
{emptyMessage}
) : (
{filteredMembers.map((member) => { const label = memberOptionLabel(member); const description = getDescription?.(member) ?? (member.email && member.email !== label ? member.email : null); return ( ); })} {filteredMembers.length === 0 ? (
No matches.
) : null}
)}
{workingUserIds.size === 0 ? "No people selected" : `${workingUserIds.size} selected`}
{staged ? ( ) : null}
{showSelectionPreview && selectedMembers.length > 0 ? (
{selectedMembers.slice(0, 3).map((member) => (
))} {selectedMembers.length > 3 ? (

and {selectedMembers.length - 3} more

) : null}
) : null}
); }