From cd1a3fda599e82e8eb35ec5a1db70af586347ba2 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 13 Oct 2025 15:39:54 +0200 Subject: [PATCH] feat: add comprehensive MultiSelect UI component - Feature-rich multi-select dropdown with search and filtering - Support for grouped options and custom icons - Disabled option support (for required selections) - Animations (bounce, pulse, wiggle, fade, slide) - Responsive design with mobile/tablet/desktop configs - Accessibility with ARIA labels and keyboard navigation - Badge display with customizable max count - Single-line or wrapped badge layout options - Imperative API via ref (reset, clear, setValues, focus) - Form integration with controlled/uncontrolled modes - Auto-dedupe options and comprehensive error handling --- web/src/components/ui/multi-select.tsx | 1183 ++++++++++++++++++++++++ 1 file changed, 1183 insertions(+) create mode 100644 web/src/components/ui/multi-select.tsx diff --git a/web/src/components/ui/multi-select.tsx b/web/src/components/ui/multi-select.tsx new file mode 100644 index 00000000..aa0b66a8 --- /dev/null +++ b/web/src/components/ui/multi-select.tsx @@ -0,0 +1,1183 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { + CheckIcon, + XCircle, + ChevronDown, + XIcon, + WandSparkles, +} from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { Separator } from "@/components/ui/separator"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@/components/ui/command"; + +/** + * Animation types and configurations + */ +export interface AnimationConfig { + /** Badge animation type */ + badgeAnimation?: "bounce" | "pulse" | "wiggle" | "fade" | "slide" | "none"; + /** Popover animation type */ + popoverAnimation?: "scale" | "slide" | "fade" | "flip" | "none"; + /** Option hover animation type */ + optionHoverAnimation?: "highlight" | "scale" | "glow" | "none"; + /** Animation duration in seconds */ + duration?: number; + /** Animation delay in seconds */ + delay?: number; +} + +/** + * 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-all duration-300 ease-in-out", { + 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", + }, + badgeAnimation: { + bounce: "hover:-translate-y-1 hover:scale-110", + pulse: "hover:animate-pulse", + wiggle: "hover:animate-wiggle", + fade: "hover:opacity-80", + slide: "hover:translate-x-1", + none: "", + }, + }, + defaultVariants: { + variant: "default", + badgeAnimation: "bounce", + }, +}); + +/** + * Option interface for MultiSelect component + */ +interface MultiSelectOption { + /** The text to display for the option. */ + label: string; + /** The unique value associated with the option. */ + value: string; + /** Optional icon component to display alongside the option. */ + icon?: React.ComponentType<{ className?: string }>; + /** Whether this option is disabled */ + disabled?: boolean; + /** Custom styling for the option */ + style?: { + /** Custom badge color */ + badgeColor?: string; + /** Custom icon color */ + iconColor?: string; + /** Gradient background for badge */ + gradient?: string; + }; +} + +/** + * Group interface for organizing options + */ +interface MultiSelectGroup { + /** Group heading */ + heading: string; + /** Options in this group */ + options: MultiSelectOption[]; +} + +/** + * Props for MultiSelect component + */ +interface MultiSelectProps + extends Omit< + React.ButtonHTMLAttributes, + "animationConfig" + >, + VariantProps { + /** + * An array of option objects or groups to be displayed in the multi-select component. + */ + options: MultiSelectOption[] | MultiSelectGroup[]; + /** + * Callback function triggered when the selected values change. + * Receives an array of the new selected values. + */ + onValueChange: (value: string[]) => void; + + /** 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; + + /** + * Advanced animation configuration for different component parts. + * Optional, allows fine-tuning of various animation effects. + */ + animationConfig?: AnimationConfig; + + /** + * 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, disables the select all functionality. + * Optional, defaults to false. + */ + hideSelectAll?: boolean; + + /** + * If true, shows search functionality in the popover. + * If false, hides the search input completely. + * Optional, defaults to true. + */ + searchable?: boolean; + + /** + * Custom empty state message when no options match search. + * Optional, defaults to "No results found." + */ + emptyIndicator?: React.ReactNode; + + /** + * If true, allows the component to grow and shrink with its content. + * If false, uses fixed width behavior. + * Optional, defaults to false. + */ + autoSize?: boolean; + + /** + * If true, shows badges in a single line with horizontal scroll. + * If false, badges wrap to multiple lines. + * Optional, defaults to false. + */ + singleLine?: boolean; + + /** + * Custom CSS class for the popover content. + * Optional, can be used to customize popover appearance. + */ + popoverClassName?: string; + + /** + * If true, disables the component completely. + * Optional, defaults to false. + */ + disabled?: boolean; + + /** + * Responsive configuration for different screen sizes. + * Allows customizing maxCount and other properties based on viewport. + * Can be boolean true for default responsive behavior or an object for custom configuration. + */ + responsive?: + | boolean + | { + /** Configuration for mobile devices (< 640px) */ + mobile?: { + maxCount?: number; + hideIcons?: boolean; + compactMode?: boolean; + }; + /** Configuration for tablet devices (640px - 1024px) */ + tablet?: { + maxCount?: number; + hideIcons?: boolean; + compactMode?: boolean; + }; + /** Configuration for desktop devices (> 1024px) */ + desktop?: { + maxCount?: number; + hideIcons?: boolean; + compactMode?: boolean; + }; + }; + + /** + * Minimum width for the component. + * Optional, defaults to auto-sizing based on content. + * When set, component will not shrink below this width. + */ + minWidth?: string; + + /** + * Maximum width for the component. + * Optional, defaults to 100% of container. + * Component will not exceed container boundaries. + */ + maxWidth?: string; + + /** + * If true, automatically removes duplicate options based on their value. + * Optional, defaults to false (shows warning in dev mode instead). + */ + deduplicateOptions?: boolean; + + /** + * If true, the component will reset its internal state when defaultValue changes. + * Useful for React Hook Form integration and form reset functionality. + * Optional, defaults to true. + */ + resetOnDefaultValueChange?: boolean; + + /** + * If true, automatically closes the popover after selecting an option. + * Useful for single-selection-like behavior or mobile UX. + * Optional, defaults to false. + */ + closeOnSelect?: boolean; +} + +/** + * Imperative methods exposed through ref + */ +export interface MultiSelectRef { + /** + * Programmatically reset the component to its default value + */ + reset: () => void; + /** + * Get current selected values + */ + getSelectedValues: () => string[]; + /** + * Set selected values programmatically + */ + setSelectedValues: (values: string[]) => void; + /** + * Clear all selected values + */ + clear: () => void; + /** + * Focus the component + */ + focus: () => void; +} + +export const MultiSelect = React.forwardRef( + ( + { + options, + onValueChange, + variant, + defaultValue = [], + placeholder = "Select options", + animation = 0, + animationConfig, + maxCount = 3, + modalPopover = false, + asChild = false, + className, + hideSelectAll = false, + searchable = true, + emptyIndicator, + autoSize = false, + singleLine = false, + popoverClassName, + disabled = false, + responsive, + minWidth, + maxWidth, + deduplicateOptions = false, + resetOnDefaultValueChange = true, + closeOnSelect = false, + ...props + }, + ref + ) => { + const [selectedValues, setSelectedValues] = + React.useState(defaultValue); + const [isPopoverOpen, setIsPopoverOpen] = React.useState(false); + const [isAnimating, setIsAnimating] = React.useState(false); + const [searchValue, setSearchValue] = React.useState(""); + + const [politeMessage, setPoliteMessage] = React.useState(""); + const [assertiveMessage, setAssertiveMessage] = React.useState(""); + const prevSelectedCount = React.useRef(selectedValues.length); + const prevIsOpen = React.useRef(isPopoverOpen); + const prevSearchValue = React.useRef(searchValue); + + const announce = React.useCallback( + (message: string, priority: "polite" | "assertive" = "polite") => { + if (priority === "assertive") { + setAssertiveMessage(message); + setTimeout(() => setAssertiveMessage(""), 100); + } else { + setPoliteMessage(message); + setTimeout(() => setPoliteMessage(""), 100); + } + }, + [] + ); + + const multiSelectId = React.useId(); + const listboxId = `${multiSelectId}-listbox`; + const triggerDescriptionId = `${multiSelectId}-description`; + const selectedCountId = `${multiSelectId}-count`; + + const prevDefaultValueRef = React.useRef(defaultValue); + + const isGroupedOptions = React.useCallback( + ( + opts: MultiSelectOption[] | MultiSelectGroup[] + ): opts is MultiSelectGroup[] => { + return opts.length > 0 && "heading" in opts[0]; + }, + [] + ); + + const arraysEqual = React.useCallback( + (a: string[], b: string[]): boolean => { + if (a.length !== b.length) return false; + const sortedA = [...a].sort(); + const sortedB = [...b].sort(); + return sortedA.every((val, index) => val === sortedB[index]); + }, + [] + ); + + const resetToDefault = React.useCallback(() => { + setSelectedValues(defaultValue); + setIsPopoverOpen(false); + setSearchValue(""); + onValueChange(defaultValue); + }, [defaultValue, onValueChange]); + + const buttonRef = React.useRef(null); + + React.useImperativeHandle( + ref, + () => ({ + reset: resetToDefault, + getSelectedValues: () => selectedValues, + setSelectedValues: (values: string[]) => { + setSelectedValues(values); + onValueChange(values); + }, + clear: () => { + setSelectedValues([]); + onValueChange([]); + }, + focus: () => { + if (buttonRef.current) { + buttonRef.current.focus(); + const originalOutline = buttonRef.current.style.outline; + const originalOutlineOffset = buttonRef.current.style.outlineOffset; + buttonRef.current.style.outline = "2px solid hsl(var(--ring))"; + buttonRef.current.style.outlineOffset = "2px"; + setTimeout(() => { + if (buttonRef.current) { + buttonRef.current.style.outline = originalOutline; + buttonRef.current.style.outlineOffset = originalOutlineOffset; + } + }, 1000); + } + }, + }), + [resetToDefault, selectedValues, onValueChange] + ); + + const [screenSize, setScreenSize] = React.useState< + "mobile" | "tablet" | "desktop" + >("desktop"); + + React.useEffect(() => { + if (typeof window === "undefined") return; + const handleResize = () => { + const width = window.innerWidth; + if (width < 640) { + setScreenSize("mobile"); + } else if (width < 1024) { + setScreenSize("tablet"); + } else { + setScreenSize("desktop"); + } + }; + handleResize(); + window.addEventListener("resize", handleResize); + return () => { + if (typeof window !== "undefined") { + window.removeEventListener("resize", handleResize); + } + }; + }, []); + + const getResponsiveSettings = () => { + if (!responsive) { + return { + maxCount: maxCount, + hideIcons: false, + compactMode: false, + }; + } + if (responsive === true) { + const defaultResponsive = { + mobile: { maxCount: 2, hideIcons: false, compactMode: true }, + tablet: { maxCount: 4, hideIcons: false, compactMode: false }, + desktop: { maxCount: 6, hideIcons: false, compactMode: false }, + }; + const currentSettings = defaultResponsive[screenSize]; + return { + maxCount: currentSettings?.maxCount ?? maxCount, + hideIcons: currentSettings?.hideIcons ?? false, + compactMode: currentSettings?.compactMode ?? false, + }; + } + const currentSettings = responsive[screenSize]; + return { + maxCount: currentSettings?.maxCount ?? maxCount, + hideIcons: currentSettings?.hideIcons ?? false, + compactMode: currentSettings?.compactMode ?? false, + }; + }; + + const responsiveSettings = getResponsiveSettings(); + + const getBadgeAnimationClass = () => { + if (animationConfig?.badgeAnimation) { + switch (animationConfig.badgeAnimation) { + case "bounce": + return isAnimating + ? "animate-bounce" + : "hover:-translate-y-1 hover:scale-110"; + case "pulse": + return "hover:animate-pulse"; + case "wiggle": + return "hover:animate-wiggle"; + case "fade": + return "hover:opacity-80"; + case "slide": + return "hover:translate-x-1"; + case "none": + return ""; + default: + return ""; + } + } + return isAnimating ? "animate-bounce" : ""; + }; + + const getPopoverAnimationClass = () => { + if (animationConfig?.popoverAnimation) { + switch (animationConfig.popoverAnimation) { + case "scale": + return "animate-scaleIn"; + case "slide": + return "animate-slideInDown"; + case "fade": + return "animate-fadeIn"; + case "flip": + return "animate-flipIn"; + case "none": + return ""; + default: + return ""; + } + } + return ""; + }; + + const getAllOptions = React.useCallback((): MultiSelectOption[] => { + if (options.length === 0) return []; + let allOptions: MultiSelectOption[]; + if (isGroupedOptions(options)) { + allOptions = options.flatMap((group) => group.options); + } else { + allOptions = options; + } + const valueSet = new Set(); + const duplicates: string[] = []; + const uniqueOptions: MultiSelectOption[] = []; + allOptions.forEach((option) => { + if (valueSet.has(option.value)) { + duplicates.push(option.value); + if (!deduplicateOptions) { + uniqueOptions.push(option); + } + } else { + valueSet.add(option.value); + uniqueOptions.push(option); + } + }); + if (process.env.NODE_ENV === "development" && duplicates.length > 0) { + const action = deduplicateOptions + ? "automatically removed" + : "detected"; + console.warn( + `MultiSelect: Duplicate option values ${action}: ${duplicates.join( + ", " + )}. ` + + `${ + deduplicateOptions + ? "Duplicates have been removed automatically." + : "This may cause unexpected behavior. Consider setting 'deduplicateOptions={true}' or ensure all option values are unique." + }` + ); + } + return deduplicateOptions ? uniqueOptions : allOptions; + }, [options, deduplicateOptions, isGroupedOptions]); + + const getOptionByValue = React.useCallback( + (value: string): MultiSelectOption | undefined => { + const option = getAllOptions().find((option) => option.value === value); + if (!option && process.env.NODE_ENV === "development") { + console.warn( + `MultiSelect: Option with value "${value}" not found in options list` + ); + } + return option; + }, + [getAllOptions] + ); + + const filteredOptions = React.useMemo(() => { + if (!searchable || !searchValue) return options; + if (options.length === 0) return []; + if (isGroupedOptions(options)) { + return options + .map((group) => ({ + ...group, + options: group.options.filter( + (option) => + option.label + .toLowerCase() + .includes(searchValue.toLowerCase()) || + option.value.toLowerCase().includes(searchValue.toLowerCase()) + ), + })) + .filter((group) => group.options.length > 0); + } + return options.filter( + (option) => + option.label.toLowerCase().includes(searchValue.toLowerCase()) || + option.value.toLowerCase().includes(searchValue.toLowerCase()) + ); + }, [options, searchValue, searchable, isGroupedOptions]); + + 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 = (optionValue: string) => { + if (disabled) return; + const option = getOptionByValue(optionValue); + if (option?.disabled) return; + const newSelectedValues = selectedValues.includes(optionValue) + ? selectedValues.filter((value) => value !== optionValue) + : [...selectedValues, optionValue]; + setSelectedValues(newSelectedValues); + onValueChange(newSelectedValues); + if (closeOnSelect) { + setIsPopoverOpen(false); + } + }; + + const handleClear = () => { + if (disabled) return; + setSelectedValues([]); + onValueChange([]); + }; + + const handleTogglePopover = () => { + if (disabled) return; + setIsPopoverOpen((prev) => !prev); + }; + + const clearExtraOptions = () => { + if (disabled) return; + const newSelectedValues = selectedValues.slice( + 0, + responsiveSettings.maxCount + ); + setSelectedValues(newSelectedValues); + onValueChange(newSelectedValues); + }; + + const toggleAll = () => { + if (disabled) return; + const allOptions = getAllOptions().filter((option) => !option.disabled); + if (selectedValues.length === allOptions.length) { + handleClear(); + } else { + const allValues = allOptions.map((option) => option.value); + setSelectedValues(allValues); + onValueChange(allValues); + } + + if (closeOnSelect) { + setIsPopoverOpen(false); + } + }; + + React.useEffect(() => { + if (!resetOnDefaultValueChange) return; + const prevDefaultValue = prevDefaultValueRef.current; + if (!arraysEqual(prevDefaultValue, defaultValue)) { + if (!arraysEqual(selectedValues, defaultValue)) { + setSelectedValues(defaultValue); + } + prevDefaultValueRef.current = [...defaultValue]; + } + }, [defaultValue, selectedValues, arraysEqual, resetOnDefaultValueChange]); + + const getWidthConstraints = () => { + const defaultMinWidth = screenSize === "mobile" ? "0px" : "200px"; + const effectiveMinWidth = minWidth || defaultMinWidth; + const effectiveMaxWidth = maxWidth || "100%"; + return { + minWidth: effectiveMinWidth, + maxWidth: effectiveMaxWidth, + width: autoSize ? "auto" : "100%", + }; + }; + + const widthConstraints = getWidthConstraints(); + + React.useEffect(() => { + if (!isPopoverOpen) { + setSearchValue(""); + } + }, [isPopoverOpen]); + + React.useEffect(() => { + const selectedCount = selectedValues.length; + const allOptions = getAllOptions(); + const totalOptions = allOptions.filter((opt) => !opt.disabled).length; + if (selectedCount !== prevSelectedCount.current) { + const diff = selectedCount - prevSelectedCount.current; + if (diff > 0) { + const addedItems = selectedValues.slice(-diff); + const addedLabels = addedItems + .map( + (value) => allOptions.find((opt) => opt.value === value)?.label + ) + .filter(Boolean); + + if (addedLabels.length === 1) { + announce( + `${addedLabels[0]} selected. ${selectedCount} of ${totalOptions} options selected.` + ); + } else { + announce( + `${addedLabels.length} options selected. ${selectedCount} of ${totalOptions} total selected.` + ); + } + } else if (diff < 0) { + announce( + `Option removed. ${selectedCount} of ${totalOptions} options selected.` + ); + } + prevSelectedCount.current = selectedCount; + } + + if (isPopoverOpen !== prevIsOpen.current) { + if (isPopoverOpen) { + announce( + `Dropdown opened. ${totalOptions} options available. Use arrow keys to navigate.` + ); + } else { + announce("Dropdown closed."); + } + prevIsOpen.current = isPopoverOpen; + } + + if ( + searchValue !== prevSearchValue.current && + searchValue !== undefined + ) { + if (searchValue && isPopoverOpen) { + const filteredCount = allOptions.filter( + (opt) => + opt.label.toLowerCase().includes(searchValue.toLowerCase()) || + opt.value.toLowerCase().includes(searchValue.toLowerCase()) + ).length; + + announce( + `${filteredCount} option${ + filteredCount === 1 ? "" : "s" + } found for "${searchValue}"` + ); + } + prevSearchValue.current = searchValue; + } + }, [selectedValues, isPopoverOpen, searchValue, announce, getAllOptions]); + + return ( + <> +
+
+ {politeMessage} +
+
+ {assertiveMessage} +
+
+ + +
+ Multi-select dropdown. Use arrow keys to navigate, Enter to select, + and Escape to close. +
+
+ {selectedValues.length === 0 + ? "No options selected" + : `${selectedValues.length} option${ + selectedValues.length === 1 ? "" : "s" + } selected: ${selectedValues + .map((value) => getOptionByValue(value)?.label) + .filter(Boolean) + .join(", ")}`} +
+ + + + + setIsPopoverOpen(false)}> + + {searchable && ( + + )} + {searchable && ( +
+ Type to filter options. Use arrow keys to navigate results. +
+ )} + + + {emptyIndicator || "No results found."} + {" "} + {!hideSelectAll && !searchValue && ( + + !opt.disabled).length + } + aria-label={`Select all ${ + getAllOptions().length + } options`} + className="cursor-pointer"> +
!opt.disabled) + .length + ? "bg-primary text-primary-foreground" + : "opacity-50 [&_svg]:invisible" + )} + aria-hidden="true"> + +
+ + (Select All + {getAllOptions().length > 20 + ? ` - ${getAllOptions().length} options` + : ""} + ) + +
+
+ )} + {isGroupedOptions(filteredOptions) ? ( + filteredOptions.map((group) => ( + + {group.options.map((option) => { + const isSelected = selectedValues.includes( + option.value + ); + return ( + toggleOption(option.value)} + role="option" + aria-selected={isSelected} + aria-disabled={option.disabled} + aria-label={`${option.label}${ + isSelected ? ", selected" : ", not selected" + }${option.disabled ? ", disabled" : ""}`} + className={cn( + "cursor-pointer", + option.disabled && "opacity-50 cursor-not-allowed" + )} + disabled={option.disabled}> + + {option.icon && ( + + ); + })} + + )) + ) : ( + + {filteredOptions.map((option) => { + const isSelected = selectedValues.includes(option.value); + return ( + toggleOption(option.value)} + role="option" + aria-selected={isSelected} + aria-disabled={option.disabled} + aria-label={`${option.label}${ + isSelected ? ", selected" : ", not selected" + }${option.disabled ? ", disabled" : ""}`} + className={cn( + "cursor-pointer", + option.disabled && "opacity-50 cursor-not-allowed" + )} + disabled={option.disabled}> + + {option.icon && ( + + ); + })} + + )} + + +
+ {selectedValues.length > 0 && ( + <> + + Clear + + + + )} + setIsPopoverOpen(false)} + className="flex-1 justify-center cursor-pointer max-w-full"> + Close + +
+
+
+
+
+ {animation > 0 && selectedValues.length > 0 && ( + setIsAnimating(!isAnimating)} + /> + )} +
+ + ); + } +); + +MultiSelect.displayName = "MultiSelect"; +export type { MultiSelectOption, MultiSelectGroup, MultiSelectProps };