"use client"; import * as PopoverPrimitive from "@radix-ui/react-popover"; import * as React from "react"; import { ChangeEvent, Children, useEffect, useState } from "react"; import { Command as CommandPrimitive } from "cmdk"; import { cls } from "../util"; import { CheckIcon, CloseIcon, KeyboardArrowDownIcon } from "../icons"; import { Separator } from "./Separator"; import { Chip } from "./Chip"; import { SelectInputLabel } from "./common/SelectInputLabel"; import { defaultBorderMixin, fieldBackgroundDisabledMixin, fieldBackgroundHoverMixin, fieldBackgroundInvisibleMixin, fieldBackgroundMixin, focusedDisabled } from "../styles"; import { useInjectStyles } from "../hooks"; import { usePortalContainer } from "../hooks/PortalContainerContext"; export type MultiSelectValue = string | number | boolean; // Make the context properly generic interface MultiSelectContextProps { fieldValue?: T[]; onItemClick: (v: T) => void; } // Create a proper generic context export const MultiSelectContext = React.createContext>({} as any); /** * Props for MultiSelect component */ interface MultiSelectProps { modalPopover?: boolean; className?: string; open?: boolean, name?: string, id?: string, onOpenChange?: (open: boolean) => void, value?: T[], inputClassName?: string, onChange?: React.EventHandler>, onValueChange?: (updatedValue: T[]) => void, placeholder?: React.ReactNode, size?: "smallest" | "small" | "medium" | "large", useChips?: boolean, label?: React.ReactNode | string, disabled?: boolean, error?: boolean, position?: "item-aligned" | "popper", endAdornment?: React.ReactNode, multiple?: boolean, includeSelectAll?: boolean, includeClear?: boolean, inputRef?: React.RefObject, padding?: boolean, invisible?: boolean, children: React.ReactNode; renderValues?: (values: T[]) => React.ReactNode; portalContainer?: HTMLElement | null; } // Use generic type for the forwarded ref export const MultiSelect = React.forwardRef< HTMLButtonElement, MultiSelectProps >( ( { value, size = "large", label, error, onValueChange, invisible, disabled, placeholder, modalPopover = true, includeClear = true, includeSelectAll = true, useChips = true, className, inputClassName, inputRef, children, renderValues, open, onOpenChange, portalContainer, endAdornment, }, ref ) => { const [isMounted, setIsMounted] = useState(false); const [isPopoverOpen, setIsPopoverOpen] = useState(open ?? false); const [selectedValues, setSelectedValues] = useState(value ?? []); // Get the portal container from context const contextContainer = usePortalContainer(); // Prioritize manual prop, fallback to context container const finalContainer = (portalContainer ?? contextContainer ?? undefined) as HTMLElement | undefined; useEffect(() => { setIsMounted(true); }, []); const onPopoverOpenChange = (open: boolean) => { setIsPopoverOpen(open); onOpenChange?.(open); } useEffect(() => { setIsPopoverOpen(open ?? false); }, [open]); const allValues = React.useMemo(() => children ? Children.map(children, (child) => { if (React.isValidElement(child)) { return child.props.value; } return null; })?.filter(Boolean) ?? [] : [], [children]); const optionsMap = React.useMemo(() => { const map = new Map(); Children.forEach(children, (child) => { if (React.isValidElement(child)) { map.set(String(child.props.value), child.props.children); } }); return map; }, [children]); React.useEffect(() => { setSelectedValues(value ?? []); }, [value]); function onItemClick(newValue: any) { let newSelectedValues: any[]; if (selectedValues.some(v => String(v) === String(newValue))) { newSelectedValues = selectedValues.filter(v => String(v) !== String(newValue)); } else { newSelectedValues = [...selectedValues, newValue]; } updateValues(newSelectedValues); } function updateValues(values: any[]) { setSelectedValues(values); onValueChange?.(values); } const handleInputKeyDown = ( event: React.KeyboardEvent ) => { if (event.key === "Enter") { onPopoverOpenChange(true); } else if (event.key === "Backspace" && !event.currentTarget.value) { const newSelectedValues = [...selectedValues]; newSelectedValues.pop(); updateValues(newSelectedValues); } }; const toggleOption = (value: any) => { const newSelectedValues = selectedValues.some(v => String(v) === String(value)) ? selectedValues.filter(v => String(v) !== String(value)) : [...selectedValues, value]; updateValues(newSelectedValues); }; const handleClear = () => { updateValues([]); }; const handleTogglePopover = () => { onPopoverOpenChange(!isPopoverOpen); }; const toggleAll = () => { if (selectedValues.length === allValues.length) { handleClear(); } else { updateValues(allValues); } onPopoverOpenChange(false); }; useInjectStyles("MultiSelect", ` [cmdk-group] { max-height: 45vh; overflow-y: auto; // width: 400px; } `) return ( {typeof label === "string" ? {label} : label} onPopoverOpenChange(false)} >
{selectedValues.length > 0 && (
Clear
)}
No results found. {includeSelectAll && (Select All) } {children}
); } ); MultiSelect.displayName = "MultiSelect"; export interface MultiSelectItemProps { value: T; children?: React.ReactNode, className?: string; } export const MultiSelectItem = React.memo(function MultiSelectItem({ children, value, className }: MultiSelectItemProps) { const context = React.useContext(MultiSelectContext); if (!context) throw new Error("MultiSelectItem must be used inside a MultiSelect"); const { fieldValue, onItemClick } = context; const isSelected = (fieldValue ?? []).some(v => String(v) === String(value)); return { e.preventDefault(); e.stopPropagation(); }} onSelect={(_) => { onItemClick(value); }} className={cls( "flex flex-row items-center gap-1.5", isSelected ? "bg-surface-accent-200 dark:bg-surface-accent-950" : "", "cursor-pointer", "m-1", "ring-offset-transparent", "p-1 rounded aria-[selected=true]:outline-none aria-[selected=true]:ring-2 aria-[selected=true]:ring-primary aria-[selected=true]:ring-opacity-75 aria-[selected=true]:ring-primary/75 aria-[selected=true]:ring-offset-2", "aria-[selected=true]:bg-surface-accent-100 aria-[selected=true]:dark:bg-surface-accent-900", "cursor-pointer p-2 rounded aria-[selected=true]:bg-surface-accent-100 aria-[selected=true]:dark:bg-surface-accent-900", "text-surface-accent-700 dark:text-surface-accent-300", className )} > {children} ; }); const InnerCheckBox = React.memo(function InnerCheckBox({ checked }: { checked: boolean }) { return
{checked && }
});