"use client" import { cva, type VariantProps } from 'class-variance-authority'; import { Check, ChevronsUpDown, X, XCircle } from 'lucide-react'; import * as React from 'react'; import { useAppT } from '@djangocfg/i18n'; import { Badge } from '../data/badge'; import { Button } from '../forms/button'; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator } from '../navigation/command'; import { Popover, PopoverContent, PopoverTrigger } from '../overlay/popover'; import { Separator } from '../layout/separator'; import { cn } from '../../lib'; // ==================== TYPES ==================== export interface MultiSelectProOption { label: string value: string description?: string // Optional subtitle/description text shown below label icon?: React.ComponentType<{ className?: string }> disabled?: boolean style?: { badgeColor?: string iconColor?: string gradient?: string } } export interface MultiSelectProGroup { heading: string options: MultiSelectProOption[] } export interface AnimationConfig { badgeAnimation?: "bounce" | "pulse" | "wiggle" | "fade" | "slide" | "none" popoverAnimation?: "scale" | "slide" | "fade" | "flip" | "none" optionHoverAnimation?: "highlight" | "scale" | "glow" | "none" duration?: number delay?: number } export interface ResponsiveConfig { mobile?: { maxDisplay?: number; compact?: boolean } tablet?: { maxDisplay?: number; compact?: boolean } desktop?: { maxDisplay?: number; compact?: boolean } } export interface MultiSelectProRef { reset: () => void getSelectedValues: () => string[] setSelectedValues: (values: string[]) => void clear: () => void focus: () => void } // ==================== VARIANTS ==================== const multiSelectVariants = cva( "w-full justify-between min-h-10 h-auto py-2", { variants: { variant: { default: "border-input bg-background hover:bg-accent/50", secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", inverted: "bg-primary text-primary-foreground hover:bg-primary/90", }, }, defaultVariants: { variant: "default", }, } ) const badgeAnimations = { bounce: "animate-bounce", pulse: "animate-pulse", wiggle: "animate-wiggle", fade: "animate-fadeIn", slide: "animate-slideIn", none: "", } const popoverAnimations = { scale: "data-[state=open]:animate-scaleIn data-[state=closed]:animate-scaleOut", slide: "data-[state=open]:animate-slideDown data-[state=closed]:animate-slideUp", fade: "data-[state=open]:animate-fadeIn data-[state=closed]:animate-fadeOut", flip: "data-[state=open]:animate-flipIn data-[state=closed]:animate-flipOut", none: "", } // ==================== PROPS ==================== export interface MultiSelectProProps extends VariantProps { options: MultiSelectProOption[] | MultiSelectProGroup[] onValueChange?: (value: string[]) => void defaultValue?: string[] placeholder?: string variant?: "default" | "secondary" | "destructive" | "inverted" animation?: number animationConfig?: AnimationConfig maxCount?: number modalPopover?: boolean asChild?: boolean className?: string hideSelectAll?: boolean searchable?: boolean emptyIndicator?: React.ReactNode autoSize?: boolean singleLine?: boolean popoverClassName?: string disabled?: boolean responsive?: boolean | ResponsiveConfig minWidth?: string maxWidth?: string deduplicateOptions?: boolean resetOnDefaultValueChange?: boolean closeOnSelect?: boolean } // ==================== HELPERS ==================== function isGroupedOptions(options: MultiSelectProOption[] | MultiSelectProGroup[]): options is MultiSelectProGroup[] { return options.length > 0 && 'heading' in options[0]! } function flattenOptions(options: MultiSelectProOption[] | MultiSelectProGroup[]): MultiSelectProOption[] { if (isGroupedOptions(options)) { return options.flatMap((group) => group.options) } return options } function deduplicateOptions(options: MultiSelectProOption[]): MultiSelectProOption[] { const seen = new Set() return options.filter((option) => { if (seen.has(option.value)) { return false } seen.add(option.value) return true }) } // ==================== COMPONENT ==================== export const MultiSelectPro = React.forwardRef( ( { options, onValueChange, defaultValue = [], placeholder, variant = "default", animation = 0, animationConfig, maxCount = 3, modalPopover = false, className, hideSelectAll = false, searchable = true, emptyIndicator, autoSize = false, singleLine = false, popoverClassName, disabled = false, responsive = false, minWidth, maxWidth, deduplicateOptions: shouldDeduplicate = false, resetOnDefaultValueChange = true, closeOnSelect = false, }, ref ) => { const t = useAppT() const [open, setOpen] = React.useState(false) const [selectedValues, setSelectedValues] = React.useState(defaultValue) const [search, setSearch] = React.useState("") const buttonRef = React.useRef(null) const [announcements, setAnnouncements] = React.useState("") // Cache selected options to persist them when they disappear from current options const [selectedOptionsCache, setSelectedOptionsCache] = React.useState>(new Map()) // Prepare translations const translations = React.useMemo(() => ({ placeholder: placeholder ?? t('ui.select.placeholder'), search: t('ui.select.search'), selectAll: t('ui.select.selectAll'), clearAll: t('ui.select.clearAll'), noResults: t('ui.select.noResults'), moreItems: (count: number) => t('ui.select.moreItems', { count }), remove: (label: string) => t('ui.actions.remove', { item: label }), }), [placeholder, t]) // Process options const flatOptions = React.useMemo(() => { const flat = flattenOptions(options) return shouldDeduplicate ? deduplicateOptions(flat) : flat }, [options, shouldDeduplicate]) // Update cache whenever new options appear React.useEffect(() => { setSelectedOptionsCache(prev => { const updated = new Map(prev) flatOptions.forEach(option => { if (selectedValues.includes(option.value)) { updated.set(option.value, option) } }) return updated }) }, [flatOptions, selectedValues]) // Responsive configuration (reserved for future use) // @ts-ignore reserved for future use const _responsiveConfig = React.useMemo((): ResponsiveConfig => { if (typeof responsive === 'boolean') { return responsive ? { mobile: { maxDisplay: 2, compact: true }, tablet: { maxDisplay: 4, compact: false }, desktop: { maxDisplay: 6, compact: false }, } : {} } return responsive || {} }, [responsive]) // Animation configuration const animConfig = React.useMemo( (): AnimationConfig => ({ badgeAnimation: animationConfig?.badgeAnimation || (animation > 0 ? "bounce" : "none"), popoverAnimation: animationConfig?.popoverAnimation || "scale", optionHoverAnimation: animationConfig?.optionHoverAnimation || "highlight", duration: animationConfig?.duration || animation || 0.3, delay: animationConfig?.delay || 0, }), [animation, animationConfig] ) // Reset on defaultValue change React.useEffect(() => { if (resetOnDefaultValueChange) { setSelectedValues(defaultValue) } }, [defaultValue, resetOnDefaultValueChange]) // Announce changes for screen readers const announce = React.useCallback((message: string) => { setAnnouncements(message) setTimeout(() => setAnnouncements(""), 1000) }, []) // Toggle selection const toggleOption = React.useCallback( (value: string) => { const isRemoving = selectedValues.includes(value) const newValues = isRemoving ? selectedValues.filter((v) => v !== value) : [...selectedValues, value] setSelectedValues(newValues) onValueChange?.(newValues) const option = flatOptions.find((o) => o.value === value) if (option) { // Add to cache when selecting if (!isRemoving) { setSelectedOptionsCache(prev => new Map(prev).set(value, option)) } announce( isRemoving ? `Removed ${option.label}` : `Added ${option.label}` ) } if (closeOnSelect) { setOpen(false) } }, [selectedValues, onValueChange, flatOptions, announce, closeOnSelect] ) // Select all const handleSelectAll = React.useCallback(() => { const allValues = flatOptions.filter((o) => !o.disabled).map((o) => o.value) setSelectedValues(allValues) onValueChange?.(allValues) // Cache all selected options setSelectedOptionsCache(prev => { const updated = new Map(prev) flatOptions.forEach(option => { if (!option.disabled) { updated.set(option.value, option) } }) return updated }) announce(`Selected all ${allValues.length} options`) }, [flatOptions, onValueChange, announce]) // Clear all const handleClearAll = React.useCallback(() => { setSelectedValues([]) onValueChange?.([]) announce("Cleared all selections") }, [onValueChange, announce]) // Imperative methods React.useImperativeHandle(ref, () => ({ reset: () => { setSelectedValues(defaultValue) announce("Reset to default values") }, getSelectedValues: () => selectedValues, setSelectedValues: (values: string[]) => { setSelectedValues(values) onValueChange?.(values) }, clear: handleClearAll, focus: () => buttonRef.current?.focus(), })) // Filter options const filteredOptions = React.useMemo(() => { if (!search) return options const searchLower = search.toLowerCase() if (isGroupedOptions(options)) { return options .map((group) => ({ ...group, options: group.options.filter( (option) => option.label.toLowerCase().includes(searchLower) || option.value.toLowerCase().includes(searchLower) ), })) .filter((group) => group.options.length > 0) } return options.filter( (option) => option.label.toLowerCase().includes(searchLower) || option.value.toLowerCase().includes(searchLower) ) }, [options, search]) // Selected options for display - use cache as fallback const selectedOptions = React.useMemo( () => selectedValues.map(value => { // First try to find in current options const option = flatOptions.find(o => o.value === value) // If not found, fallback to cache return option || selectedOptionsCache.get(value) }).filter((option): option is MultiSelectProOption => option !== undefined), [flatOptions, selectedValues, selectedOptionsCache] ) // Render badge with custom styles const renderBadge = (option: MultiSelectProOption, index: number) => { const { style, icon: Icon } = option const badgeStyle: React.CSSProperties = {} if (style?.gradient) { badgeStyle.background = style.gradient badgeStyle.color = style.iconColor || "white" } else if (style?.badgeColor) { badgeStyle.backgroundColor = style.badgeColor badgeStyle.color = style.iconColor || "white" } const animationClass = animConfig.badgeAnimation ? badgeAnimations[animConfig.badgeAnimation] : "" return ( {Icon && } {option.label} {!disabled && ( )} ) } // Display value const displayValue = React.useMemo(() => { if (selectedOptions.length === 0) { return {translations.placeholder} } const displayed = selectedOptions.slice(0, maxCount) const remaining = selectedOptions.length - maxCount return (
{displayed.map((option, index) => renderBadge(option, index))} {remaining > 0 && ( {translations.moreItems(remaining)} )}
) }, [selectedOptions, maxCount, translations, singleLine, variant, disabled, animConfig]) // Render options const renderOptions = () => { if (isGroupedOptions(filteredOptions)) { return filteredOptions.map((group, groupIndex) => ( {groupIndex > 0 && } {group.options.map((option) => { const isSelected = selectedValues.includes(option.value) const Icon = option.icon return ( !option.disabled && toggleOption(option.value)} disabled={option.disabled} className={cn( "cursor-pointer", option.disabled && "opacity-50 cursor-not-allowed" )} > {Icon && }
{option.label} {option.description && ( {option.description} )}
) })}
)) } return ( {(filteredOptions as MultiSelectProOption[]).map((option) => { const isSelected = selectedValues.includes(option.value) const Icon = option.icon return ( !option.disabled && toggleOption(option.value)} disabled={option.disabled} className={cn( "cursor-pointer", option.disabled && "opacity-50 cursor-not-allowed" )} > {Icon && }
{option.label} {option.description && ( {option.description} )}
) })}
) } const containerStyle: React.CSSProperties = {} if (minWidth) containerStyle.minWidth = minWidth if (maxWidth) containerStyle.maxWidth = maxWidth return (
{/* ARIA Live Region for announcements */}
{announcements}
{ if (!disabled) { setOpen(isOpen) if (isOpen) { announce(`Dropdown opened. ${flatOptions.length} options available`) } else { setSearch("") announce("Dropdown closed") } } }} modal={modalPopover} > {searchable && ( )} {!hideSelectAll && !isGroupedOptions(options) && ( <> {translations.selectAll} )} {filteredOptions.length === 0 ? ( {emptyIndicator || translations.noResults} ) : ( renderOptions() )}
) } ) MultiSelectPro.displayName = "MultiSelectPro"