"use client"; import { CheckIcon, ChevronDown, XIcon } from "lucide-react"; import { type ComponentPropsWithoutRef, createContext, type ReactNode, useCallback, useContext, useEffect, useRef, useState, } from "react"; import { Badge } from "@/components/shadcn/badge/badge"; import { Button } from "@/components/shadcn/button/button"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, } from "@/components/shadcn/command"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/shadcn/popover"; import { cn } from "@/lib/utils"; type MultiSelectContextType = { open: boolean; setOpen: (open: boolean) => void; selectedValues: Set; toggleValue: (value: string) => void; items: Map; onItemAdded: (value: string, label: ReactNode) => void; onValuesChange?: (values: string[]) => void; }; const MultiSelectContext = createContext(null); export function MultiSelect({ children, values, defaultValues, onValuesChange, }: { children: ReactNode; values?: string[]; defaultValues?: string[]; onValuesChange?: (values: string[]) => void; }) { const [open, setOpen] = useState(false); const [internalValues, setInternalValues] = useState( new Set(values ?? defaultValues), ); const selectedValues = values ? new Set(values) : internalValues; const [items, setItems] = useState>(new Map()); function toggleValue(value: string) { const getNewSet = (prev: Set) => { const newSet = new Set(prev); if (newSet.has(value)) { newSet.delete(value); } else { newSet.add(value); } return newSet; }; setInternalValues(getNewSet); onValuesChange?.(Array.from(getNewSet(selectedValues))); } const onItemAdded = useCallback((value: string, label: ReactNode) => { setItems((prev) => { if (prev.get(value) === label) return prev; return new Map(prev).set(value, label); }); }, []); return ( {children} ); } export function MultiSelectTrigger({ className, children, size = "default", ...props }: { className?: string; children?: ReactNode; size?: "sm" | "default"; } & ComponentPropsWithoutRef) { const { open } = useMultiSelectContext(); return ( ); } export function MultiSelectValue({ placeholder, clickToRemove = true, className, overflowBehavior = "wrap-when-open", ...props }: { placeholder?: string; clickToRemove?: boolean; overflowBehavior?: "wrap" | "wrap-when-open" | "cutoff"; } & Omit, "children">) { const { selectedValues, toggleValue, items, open } = useMultiSelectContext(); const [overflowAmount, setOverflowAmount] = useState(0); const valueRef = useRef(null); const overflowRef = useRef(null); const shouldWrap = overflowBehavior === "wrap" || (overflowBehavior === "wrap-when-open" && open); const checkOverflow = useCallback(() => { if (valueRef.current === null) return; const containerElement = valueRef.current; const overflowElement = overflowRef.current; const items = containerElement.querySelectorAll( "[data-selected-item]", ); if (overflowElement !== null) overflowElement.style.display = "none"; items.forEach((child) => child.style.removeProperty("display")); let amount = 0; for (let i = items.length - 1; i >= 0; i--) { const child = items[i]!; if (containerElement.scrollWidth <= containerElement.clientWidth) { break; } amount = items.length - i; child.style.display = "none"; overflowElement?.style.removeProperty("display"); } setOverflowAmount(amount); }, []); const handleResize = useCallback( (node: HTMLDivElement) => { valueRef.current = node; const mutationObserver = new MutationObserver(checkOverflow); const observer = new ResizeObserver(debounce(checkOverflow, 100)); mutationObserver.observe(node, { childList: true, attributes: true, attributeFilter: ["class", "style"], }); observer.observe(node); return () => { observer.disconnect(); mutationObserver.disconnect(); valueRef.current = null; }; }, [checkOverflow], ); return (
{placeholder && ( {placeholder} )} {Array.from(selectedValues) .filter((value) => items.has(value)) .map((value) => ( { e.stopPropagation(); toggleValue(value); } : undefined } > {items.get(value)} {clickToRemove && ( )} ))} 0 && !shouldWrap ? "block" : "none", }} variant="outline" ref={overflowRef} className="text-bg-button-secondary border-slate-300 bg-slate-100 px-2 py-1 text-xs font-medium dark:border-slate-600 dark:bg-slate-800" > +{overflowAmount}
); } export function MultiSelectContent({ search = true, children, width = "default", ...props }: { search?: boolean | { placeholder?: string; emptyMessage?: string }; children: ReactNode; width?: "default" | "wide"; } & Omit, "children">) { const canSearch = typeof search === "object" ? true : search; const widthClasses = width === "wide" ? "w-auto min-w-[400px] max-w-[600px]" : "w-auto"; return ( <>
{children}
{canSearch ? ( ) : ( ); } function useMultiSelectContext() { const context = useContext(MultiSelectContext); if (context === null) { throw new Error( "useMultiSelectContext must be used within a MultiSelectContext", ); } return context; } function debounce void>( func: T, wait: number, ): (...args: Parameters) => void { let timeout: ReturnType | null = null; return function (this: unknown, ...args: Parameters) { if (timeout) clearTimeout(timeout); timeout = setTimeout(() => func.apply(this, args), wait); }; }