import React from 'react' import { SvgTaillessLineArrowDown1, SvgDelete1, SvgCheck, } from '@chainlink/blocks-icons' import { useIsMobile, useIsTablet } from '../../hooks' import { cn } from '../../utils' import { Button } from '../Button' import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from '../Command' import { Popover, PopoverContent, PopoverTrigger } from '../Popover' // import { Tag } from '../Tag' function Badge({ className, ...props }: React.HTMLAttributes) { return (
) } /** * Base classes for the badges in the multi-select component. */ const badgeBaseClasses = 'transition-all duration-300 ease-in-out border border-border bg-background-alt text-muted-foreground hover:bg-background-alt/80 hover:-translate-y-0.1 hover:scale-105' /** * 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, 'className'> { /** * 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 /** * Maximum number of items to display. Extra selected items will be summarized. * Optional, defaults to 3. */ maxCount?: number 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 autoSize?: boolean 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 (< 768px) */ mobile?: { maxCount?: number hideIcons?: boolean compactMode?: boolean } /** Configuration for tablet devices (768px - 996px) */ tablet?: { maxCount?: number hideIcons?: boolean compactMode?: boolean } /** Configuration for desktop devices (>= 996px) */ 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 /** * The size of the component. * Optional, defaults to "default". */ size?: 'default' | 'sm' | 'xs' /** * The controlled open state of the popover. */ open?: boolean /** * The default open state of the popover. */ defaultOpen?: boolean /** * Event handler for the open state. */ onOpenChange?: (open: boolean) => void } /** * 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, defaultValue = [], placeholder = 'Select options', maxCount = 3, modalPopover = false, // asChild = false, className, hideSelectAll = false, searchable = true, emptyIndicator, autoSize = false, popoverClassName, disabled = false, responsive, minWidth, maxWidth, deduplicateOptions = false, resetOnDefaultValueChange = true, closeOnSelect = false, size = 'default', open, defaultOpen = false, onOpenChange, ...props }, ref, ) => { const [selectedValues, setSelectedValues] = React.useState(defaultValue) const [internalOpen, setInternalOpen] = React.useState(defaultOpen) const isPopoverOpen = open ?? internalOpen const handleOpenChange = React.useCallback( (newOpen: boolean) => { setInternalOpen(newOpen) onOpenChange?.(newOpen) }, [onOpenChange], ) const [searchValue, setSearchValue] = React.useState('') const sizeClasses = { default: 'max-h-12 h-12 p-2 pr-4 text-sm', sm: 'max-h-10 p-2 pr-3 min-h-10 h-10 text-xs', xs: 'min-h-8 max-h-8 py-0.5 px-1 pr-3 text-xs', } const badgeClasses = { default: 'p-2 max-h-8', sm: 'p-1 max-h-6', xs: 'px-1 min-h-6', } const iconClasses = { default: 'h-4 w-4', sm: 'h-4 w-4', xs: 'h-3 w-3', } const itemClasses = { default: 'py-3 px-4', sm: 'py-2.5 px-3', xs: 'py-1.5 px-3', } const searchClasses = { default: 'h-12 py-3', sm: 'h-10 py-2.5', xs: 'h-8 py-1.5', } 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) handleOpenChange(false) setSearchValue('') onValueChange(defaultValue) }, [defaultValue, onValueChange, handleOpenChange]) 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 isMobile = useIsMobile() const isTablet = useIsTablet() const screenSize = React.useMemo<'mobile' | 'tablet' | 'desktop'>(() => { if (isMobile) return 'mobile' if (isTablet) return 'tablet' return 'desktop' }, [isMobile, isTablet]) const getResponsiveSettings = () => { if (!responsive) { return { 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 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') { handleOpenChange(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) { handleOpenChange(false) } } const handleClear = () => { if (disabled) return setSelectedValues([]) onValueChange([]) } const handleTogglePopover = (e: React.MouseEvent) => { e.preventDefault() if (disabled) return handleOpenChange(!isPopoverOpen) } 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) { handleOpenChange(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(', ')}`}
handleOpenChange(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={cn('cursor-pointer', itemClasses[size])} > !opt.disabled) .length && 'text-brand', )} > Select All {getAllOptions().length > 20 ? ` - ${getAllOptions().length} options` : ''}
!opt.disabled) .length ? 'border-link bg-link text-primary-foreground' : 'border-input-border text-primary-foreground opacity-50 [&_svg]:invisible', )} aria-hidden="true" >
)} {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', itemClasses[size], option.disabled && 'cursor-not-allowed opacity-50', )} 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', itemClasses[size], option.disabled && 'cursor-not-allowed opacity-50', )} disabled={option.disabled} > {option.icon && ( ) })} )}
) }, ) MultiSelect.displayName = 'MultiSelect' export type { MultiSelectOption, MultiSelectGroup, MultiSelectProps }