"use client"; import { ChevronDown, XIcon } from "lucide-react"; import { type ComponentPropsWithoutRef, createContext, type ReactNode, useCallback, useContext, useEffect, useRef, useState, type WheelEvent, } 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"; export interface MultiSelectSearchConfig { placeholder?: string; emptyMessage?: string; } export type MultiSelectSearchProp = boolean | MultiSelectSearchConfig; type MultiSelectContextType = { open: boolean; setOpen: (open: boolean) => void; selectedValues: Set; toggleValue: (value: string) => void; setValues: (values: string[]) => void; items: Map; onItemAdded: (value: string, label: ReactNode) => void; onValuesChange?: (values: string[]) => void; }; const MultiSelectContext = createContext(null); const stopWheelPropagation = (event: WheelEvent) => { event.stopPropagation(); }; export function MultiSelect({ children, values, defaultValues, onValuesChange, open: controlledOpen, onOpenChange, }: { children: ReactNode; values?: string[]; defaultValues?: string[]; onValuesChange?: (values: string[]) => void; open?: boolean; onOpenChange?: (open: boolean) => void; }) { const [internalOpen, setInternalOpen] = useState(false); const [internalValues, setInternalValues] = useState( new Set(values ?? defaultValues), ); const open = controlledOpen ?? internalOpen; const selectedValues = values ? new Set(values) : internalValues; const [items, setItems] = useState>(new Map()); function setOpen(nextOpen: boolean) { if (controlledOpen === undefined) { setInternalOpen(nextOpen); } onOpenChange?.(nextOpen); } 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))); } function setValues(nextValues: string[]) { const nextSet = new Set(nextValues); setInternalValues(nextSet); onValuesChange?.(Array.from(nextSet)); } 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 selectedContextLabel = placeholder && /^All\s+/i.test(placeholder) && selectedValues.size > 0 ? placeholder.replace(/^All\s+/i, "").trim() : ""; 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 && selectedValues.size === 0 && ( {placeholder} )} {selectedContextLabel && ( {selectedContextLabel} )} {Array.from(selectedValues) .filter((value) => items.has(value)) .map((value) => ( { e.stopPropagation(); toggleValue(value); } : undefined } > {items.get(value)} {clickToRemove && ( )} ))} 0 && !shouldWrap ? "block" : "none", }} variant="tag" ref={overflowRef} className="px-2 py-1 text-xs font-medium" > +{overflowAmount}
); } export function MultiSelectContent({ search = true, children, width = "default", ...props }: { search?: MultiSelectSearchProp; children: ReactNode; width?: "default" | "wide"; } & Omit, "children">) { const canSearch = typeof search === "object" ? true : search; const listRef = useRef(null); const widthClasses = width === "wide" ? "w-[calc(100vw-2rem)] sm:w-max sm:min-w-[min(max(var(--radix-popover-trigger-width),24rem),32rem)] sm:max-w-[32rem]" : "w-[calc(100vw-2rem)] sm:w-max sm:min-w-[min(var(--radix-popover-trigger-width),22rem)] sm:max-w-[22rem]"; function handleSearchValueChange(searchValue: string) { if (!canSearch || !searchValue.trim()) return; requestAnimationFrame(() => { const firstVisibleItem = listRef.current?.querySelector( '[data-slot="multiselect-item"]:not([hidden])', ); firstVisibleItem?.scrollIntoView({ block: "nearest", }); }); } return ( <> {canSearch ? ( ) : ( ); } const itemValues = values ?? Array.from(items.keys()); const hasItems = itemValues.length > 0; const allSelected = hasItems && itemValues.every((value) => selectedValues.has(value)); const handleSelectAll = () => { setValues(itemValues); }; return ( ); } 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); }; }