import { type VariantProps, cva } from "class-variance-authority"; import { CheckIcon, ChevronDown, WandSparkles, XCircleIcon } from "lucide-react"; import * as React from "react"; import { Badge, Button, Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, Popover, PopoverContent, PopoverTrigger, Separator, } from "@repo/shadcn-ui/components"; import { cn } from "@repo/shadcn-ui/lib/utils"; import { useTranslation } from "../locales/i18n.ts"; /** * Variants for the multi-select component to handle different styles. * Uses class-variance-authority (cva) to define different styles based on "variant" prop. */ const multiSelectVariants = cva( "m-1 transition ease-in-out delay-150 hover:-translate-y-1 hover:scale-110 duration-300", { variants: { variant: { default: "border-foreground/10 text-foreground bg-card hover:bg-card/80", secondary: "border-foreground/10 bg-secondary text-secondary-foreground hover:bg-secondary/80", destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", inverted: "inverted", }, }, defaultVariants: { variant: "default", }, } ); export type MultiSelectOptionType = { /** The text to display for the option. */ label: string; /** The unique value associated with the option. */ value: string; /** * Optional group name to categorize the option. * Useful for grouping options in the UI. */ group?: string; /** Optional icon component to display alongside the option. */ icon?: React.ComponentType<{ className?: string; }>; }; /** * Props for MultiSelect component */ export interface MultiSelectProps extends React.ButtonHTMLAttributes, VariantProps { /** * An array of option objects to be displayed in the multi-select component. * Each option object has a label, value, and an optional icon. */ options: MultiSelectOptionType[]; /** * Callback function triggered when the selected values change. * Receives an array of the new selected values. */ onValueChange: (values: string[]) => void; /** * Optional function to validate an option before selection. * Receives the option value and should return true if valid, false otherwise. */ onValidateOption?: (value: string, selectedValues: string[]) => boolean; /** The default selected values when the component mounts. */ defaultValue?: string[]; /** * Placeholder text to be displayed when no values are selected. * Optional, defaults to "Select options". */ placeholder?: string; /** * Animation duration in seconds for the visual effects (e.g., bouncing badges). * Optional, defaults to 0 (no animation). */ animation?: number; /** * Maximum number of items to display. Extra selected items will be summarized. * Optional, defaults to 3. */ maxCount?: number; /** * The modality of the popover. When set to true, interaction with outside elements * will be disabled and only popover content will be visible to screen readers. * Optional, defaults to false. */ modalPopover?: boolean; /** * If true, renders the multi-select component as a child of another component. * Optional, defaults to false. */ asChild?: boolean; /** * Additional class names to apply custom styles to the multi-select component. * Optional, can be used to add custom styles. */ className?: string; /** * If true, allows selecting all visible options at once. * Optional, defaults to false. */ selectAllVisible?: boolean; /** * Filtra los items seleccionados */ filterSelected?: (selectedValues: string[]) => string[]; /** Si true, aplica el filtro automáticamente al cambiar los items seleccionados */ autoFilter?: boolean; } export const MultiSelect = React.forwardRef( ( { options, onValueChange, onValidateOption, variant, defaultValue = [], placeholder, animation = 0, maxCount = 3, modalPopover = false, asChild = false, className, selectAllVisible = false, filterSelected, autoFilter = false, ...props }, ref ) => { const [selectedValues, setSelectedValues] = React.useState(defaultValue ?? []); const [isPopoverOpen, setIsPopoverOpen] = React.useState(false); const [isAnimating, setIsAnimating] = React.useState(false); const { t } = useTranslation(); const applySelectedFilter = React.useCallback(() => { if (!filterSelected) return; const filtered = filterSelected(selectedValues); if (filtered.length !== selectedValues.length) { setSelectedValues(filtered); onValueChange(filtered); } }, [filterSelected, selectedValues, onValueChange]); // Filtro automático cuando cambia selectedValues React.useEffect(() => { if (autoFilter) applySelectedFilter(); }, [autoFilter, selectedValues, applySelectedFilter]); const grouped = options.reduce>((acc, item) => { if (!acc[item.group || ""]) acc[item.group || ""] = []; acc[item.group || ""].push(item); return acc; }, {}); const handleInputKeyDown = (event: React.KeyboardEvent) => { if (event.key === "Enter") { setIsPopoverOpen(true); } else if (event.key === "Backspace" && !event.currentTarget.value) { const newSelectedValues = [...selectedValues]; newSelectedValues.pop(); setSelectedValues(newSelectedValues); onValueChange(newSelectedValues); } }; const toggleOption = (option: string) => { if (onValidateOption && !onValidateOption(option, selectedValues)) { console.warn(`Option "${option}" is not valid.`); return; } const newSelectedValues = selectedValues.includes(option) ? selectedValues.filter((value) => value !== option) : [...selectedValues, option]; setSelectedValues(newSelectedValues); onValueChange(newSelectedValues); }; const handleClear = () => { setSelectedValues([]); onValueChange([]); }; const handleTogglePopover = () => { setIsPopoverOpen((prev) => !prev); }; const clearExtraOptions = () => { const newSelectedValues = selectedValues.slice(0, maxCount); setSelectedValues(newSelectedValues); onValueChange(newSelectedValues); }; const toggleAll = () => { if (selectedValues.length === options.length) { handleClear(); } else { const allValues = options.map((option) => option.value); setSelectedValues(allValues); onValueChange(allValues); } }; return ( setIsPopoverOpen(false)} > {t("components.multi_select.no_results")} {selectAllVisible && (
(Select All)
)} {Object.keys(grouped).map((group) => { return ( {grouped[group].map((option) => { const isSelected = selectedValues.includes(option.value); return ( toggleOption(option.value)} className='cursor-pointer' >
{option.icon && ( )} {option.label}
); })}
); })}
{selectedValues.length > 0 && ( <> {t("components.multi_select.clear_selection")} )} setIsPopoverOpen(false)} className='flex-1 justify-center cursor-pointer max-w-full' > {t("components.multi_select.close")}
{animation > 0 && selectedValues.length > 0 && ( setIsAnimating(!isAnimating)} /> )}
); } ); MultiSelect.displayName = "MultiSelect";