Skip to main content
Glama
Multiselect.tsx17.8 kB
'use client'; import { Check, X as RemoveIcon } from 'lucide-react'; import { type ComponentProps, createContext, type Dispatch, type FC, type HTMLAttributes, type KeyboardEvent, type LegacyRef, type MouseEventHandler, type RefObject, type SetStateAction, type SyntheticEvent, useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react'; import { cn } from '../../utils/cn'; import { Badge, BadgeColor } from '../Badge'; import { Command, CommandRoot } from '../Command'; /** * Context properties for MultiSelect component state management * * @interface MultiSelectContextProps */ type MultiSelectContextProps = { /** Array of currently selected values */ value: string[]; /** Handler for value changes */ onValueChange: (value: string) => void; /** Whether the dropdown is currently open */ open: boolean; /** Function to set the open state */ setOpen: (value: boolean) => void; /** Current input field value for filtering */ inputValue: string; /** Function to set the input value */ setInputValue: Dispatch<SetStateAction<string>>; /** Index of currently focused option for keyboard navigation */ activeIndex: number; /** Function to set the active index */ setActiveIndex: Dispatch<SetStateAction<number>>; /** Ref to the input element */ ref: RefObject<HTMLInputElement | null>; /** Handler for option selection */ handleSelect: (e: SyntheticEvent<HTMLInputElement>) => void; }; const MultiSelectContext = createContext<MultiSelectContextProps | null>(null); /** * Custom hook to access MultiSelect context * * Provides access to the internal state and methods of the MultiSelect component. * Must be used within a MultiSelect component tree. * * @returns MultiSelectContextProps - All context properties and methods * @throws Error when used outside of MultiSelect component * * @example * ```tsx * function CustomMultiSelectItem() { * const { value, onValueChange, open } = useMultiSelect(); * // Use context properties... * } * ``` */ const useMultiSelect = () => { const context = useContext(MultiSelectContext); if (!context) { throw new Error('useMultiSelect must be used within MultiSelectProvider'); } return context; }; /** * Props interface for the main MultiSelect component * * @interface MultiSelectProps */ type MultiSelectProps = ComponentProps<typeof CommandRoot> & { /** * Array of selected values (controlled mode) * @example * ```tsx * const [selected, setSelected] = useState(['react', 'vue']); * <MultiSelect values={selected} onValueChange={setSelected} /> * ``` */ values?: string[]; /** * Default selected values for uncontrolled mode * @example * ```tsx * <MultiSelect defaultValues={['react']} /> * ``` */ defaultValues?: string[]; /** * Callback fired when selection changes * @param value - New array of selected values * @example * ```tsx * <MultiSelect onValueChange={(values) => console.log('Selected:', values)} /> * ``` */ onValueChange?: (value: string[]) => void; /** * Whether keyboard navigation should loop through options * @default false * @example * ```tsx * <MultiSelect loop /> // Arrow keys wrap around at list boundaries * ``` */ loop?: boolean; }; /** * MultiSelect - A comprehensive multi-selection dropdown component * * An advanced multi-select component that combines the functionality of a searchable dropdown * with the ability to select multiple values. Built on top of Command component primitives, * it provides filtering, keyboard navigation, and visual feedback through badges. * * ## Key Features * - **Multi-Selection**: Select multiple options with visual badge representation * - **Searchable**: Built-in filtering to quickly find options in large lists * - **Keyboard Navigation**: Full arrow key navigation with optional looping * - **Accessibility**: Screen reader support, ARIA attributes, and focus management * - **Flexible State**: Both controlled and uncontrolled usage patterns * - **Rich UI**: Customizable badges, icons, and content layout * * ## Use Cases * - Tag/category selection in forms * - Multi-user assignment interfaces * - Feature/permission selection * - Filter selection in search interfaces * - Any multi-choice selection requirement * * ## Architecture * The component follows a compound pattern similar to Select: * - `MultiSelect` (root): Manages state and provides context * - `MultiSelect.Trigger`: Container for input and selected badges * - `MultiSelect.Input`: Searchable input field with filtering * - `MultiSelect.Content`: Dropdown container for options * - `MultiSelect.List`: Options container with keyboard navigation * - `MultiSelect.Item`: Individual selectable options * * ## Accessibility * - **Keyboard Navigation**: Arrow keys, Enter to select, Backspace to remove * - **Screen Readers**: Proper ARIA labels and live region announcements * - **Focus Management**: Clear focus indicators and logical tab flow * - **Search**: Real-time filtering with screen reader announcements * * @example * Basic multi-select usage: * ```tsx * const [frameworks, setFrameworks] = useState<string[]>([]); * * <MultiSelect values={frameworks} onValueChange={setFrameworks}> * <MultiSelect.Trigger> * <MultiSelect.Input placeholder="Select frameworks..." /> * </MultiSelect.Trigger> * <MultiSelect.Content> * <MultiSelect.List> * <MultiSelect.Item value="react">React</MultiSelect.Item> * <MultiSelect.Item value="vue">Vue</MultiSelect.Item> * <MultiSelect.Item value="svelte">Svelte</MultiSelect.Item> * </MultiSelect.List> * </MultiSelect.Content> * </MultiSelect> * ``` * * @example * Advanced usage with keyboard looping: * ```tsx * <MultiSelect defaultValues={['react']} loop> * <MultiSelect.Trigger> * <MultiSelect.Input placeholder="Choose technologies..." /> * </MultiSelect.Trigger> * <MultiSelect.Content> * <MultiSelect.List> * <MultiSelect.Item value="react">⚛️ React</MultiSelect.Item> * <MultiSelect.Item value="vue">💚 Vue</MultiSelect.Item> * <MultiSelect.Item value="angular">🔴 Angular</MultiSelect.Item> * </MultiSelect.List> * </MultiSelect.Content> * </MultiSelect> * ``` * * @example * Form integration with validation: * ```tsx * <form> * <MultiSelect * values={selectedSkills} * onValueChange={setSelectedSkills} * required * > * <MultiSelect.Trigger className="min-h-[2.5rem]"> * <MultiSelect.Input placeholder="Select your skills..." /> * </MultiSelect.Trigger> * <MultiSelect.Content> * <MultiSelect.List> * <MultiSelect.Item value="javascript">JavaScript</MultiSelect.Item> * <MultiSelect.Item value="typescript">TypeScript</MultiSelect.Item> * <MultiSelect.Item value="python">Python</MultiSelect.Item> * </MultiSelect.List> * </MultiSelect.Content> * </MultiSelect> * </form> * ``` */ const MultiSelectRoot: FC<MultiSelectProps> = ({ values: valuesProp, defaultValues, onValueChange, loop = false, className, children, dir, ...props }) => { const [value, setValue] = useState<string[]>(defaultValues ?? []); const [inputValue, setInputValue] = useState(''); const [open, setOpen] = useState<boolean>(false); const [activeIndex, setActiveIndex] = useState<number>(-1); const inputRef = useRef<HTMLInputElement>(null); const [isValueSelected, setIsValueSelected] = useState(false); const [selectedValue, setSelectedValue] = useState(''); useEffect(() => { if (valuesProp) { setValue(valuesProp); } }, [valuesProp]); const onValueChangeHandler = useCallback( (val: string) => { if (value.includes(val)) { const newValue = value.filter((item) => item !== val); setValue(newValue); onValueChange?.(newValue); } else { const newValue = [...value, val]; setValue(newValue); onValueChange?.(newValue); } }, [value] ); const handleSelect = useCallback( (e: SyntheticEvent<HTMLInputElement>) => { e.preventDefault(); const target = e.currentTarget; const selection = target.value.substring( target.selectionStart ?? 0, target.selectionEnd ?? 0 ); setSelectedValue(selection); setIsValueSelected(selection === inputValue); }, [inputValue] ); const handleKeyDown = useCallback( (e: KeyboardEvent<HTMLDivElement>) => { e.stopPropagation(); const target = inputRef.current; if (!target) return; const moveNext = () => { const nextIndex = activeIndex + 1; setActiveIndex( nextIndex > value.length - 1 ? (loop ? 0 : -1) : nextIndex ); }; const movePrev = () => { const prevIndex = activeIndex - 1; setActiveIndex(prevIndex < 0 ? value.length - 1 : prevIndex); }; const moveCurrent = () => { const newIndex = activeIndex - 1 <= 0 ? value.length - 1 === 0 ? -1 : 0 : activeIndex - 1; setActiveIndex(newIndex); }; switch (e.key) { case 'ArrowLeft': if (dir === 'rtl') { if (value.length > 0 && (activeIndex !== -1 || loop)) { moveNext(); } } else if (value.length > 0 && target.selectionStart === 0) { movePrev(); } break; case 'ArrowRight': if (dir === 'rtl') { if (value.length > 0 && target.selectionStart === 0) { movePrev(); } } else if (value.length > 0 && (activeIndex !== -1 || loop)) { moveNext(); } break; case 'Backspace': case 'Delete': if (value.length > 0) { if (activeIndex !== -1 && activeIndex < value.length) { onValueChangeHandler(value[activeIndex]); moveCurrent(); } else if ( (target.selectionStart === 0 && selectedValue === inputValue) || isValueSelected ) { onValueChangeHandler(value[value.length - 1]); } } break; case 'Enter': setOpen(true); break; case 'Escape': if (activeIndex !== -1) { setActiveIndex(-1); } else if (open) { setOpen(false); } break; } }, [value, inputValue, activeIndex, loop] ); const memoValue = useMemo( () => ({ value, onValueChange: onValueChangeHandler, open, setOpen, inputValue, setInputValue, activeIndex, setActiveIndex, ref: inputRef, handleSelect, }), [ value, onValueChangeHandler, open, setOpen, inputValue, setInputValue, activeIndex, setActiveIndex, inputRef, handleSelect, ] ); return ( <MultiSelectContext value={memoValue}> <CommandRoot onKeyDown={handleKeyDown} className={cn( 'flex w-full flex-col gap-2 overflow-visible bg-transparent', className )} dir={dir} {...props} > {children} </CommandRoot> </MultiSelectContext> ); }; const MultiSelectTrigger: FC< HTMLAttributes<HTMLDivElement> & { getBadgeValue?: (value: string) => string; validationStyleEnabled?: boolean; } > = ({ className, getBadgeValue = (value) => value, validationStyleEnabled = false, children, ...props }) => { const { value, onValueChange, activeIndex } = useMultiSelect(); const mousePreventDefault: MouseEventHandler<HTMLButtonElement> = useCallback( (e) => { e.preventDefault(); e.stopPropagation(); }, [] ); return ( <div className={cn( // Base layout 'flex w-full flex-col gap-3', 'cursor-pointer select-text text-base shadow-none outline-none md:text-sm', // Corner shape 'rounded-xl [corner-shape:squircle] supports-[corner-shape:squircle]:rounded-2xl', // Spacing 'px-2 py-3 md:py-2', // Background and text 'bg-neutral-50 dark:bg-neutral-950', 'text-text', // Focus ring 'ring-0', 'focus-within:outline-none', 'focus-within:ring-3', 'focus-within:ring-neutral-200', 'dark:focus-within:ring-neutral-500', 'focus-within:ring-offset-white', 'dark:focus-within:ring-offset-neutral-500', // Remove box-shadow '[box-shadow:none]', // States 'disabled:cursor-not-allowed disabled:opacity-50', 'aria-invalid:border-error', // Validation styles validationStyleEnabled && 'valid:border-success invalid:border-error', className )} {...props} > {value.length > 0 && ( <div className="flex w-full flex-wrap gap-1"> {value.map((item, index) => ( <Badge key={item} className={cn( 'flex items-center gap-1 rounded-xl px-1', activeIndex === index && 'ring-2 ring-muted-foreground' )} color={BadgeColor.TEXT} > <span className="text-xs">{getBadgeValue(item)}</span> <button aria-label={`Remove ${item} option`} aria-roledescription="button to remove option" onMouseDown={mousePreventDefault} onClick={() => onValueChange(item)} > <span className="sr-only">Remove {item} option</span> <RemoveIcon className="size-4 cursor-pointer" /> </button> </Badge> ))} </div> )} {children} </div> ); }; const MultiSelectInput: FC<ComponentProps<typeof Command.Input>> = ({ className, ...props }) => { const { setOpen, inputValue, setInputValue, activeIndex, setActiveIndex, handleSelect, ref: inputRef, } = useMultiSelect(); return ( <Command.Input {...props} tabIndex={0} ref={inputRef as LegacyRef<HTMLInputElement>} value={inputValue} onValueChange={activeIndex === -1 ? setInputValue : undefined} onSelect={handleSelect} onBlur={() => setOpen(false)} onFocus={() => setOpen(true)} onClick={() => setActiveIndex(-1)} className={cn( 'ml-2 flex-1 cursor-pointer outline-hidden', className, activeIndex !== -1 && 'caret-transparent' )} /> ); }; const MultiSelectContent: FC<HTMLAttributes<HTMLDivElement>> = ({ children, }) => { const { open } = useMultiSelect(); return <div className="relative">{open && children}</div>; }; const MultiSelectList: typeof Command.List = ({ className, children }) => ( <Command.List className={cn( // Base layout 'absolute top-0 z-10 flex w-full flex-col gap-2', 'rounded-xl p-2 shadow-md', // Background and text 'bg-white dark:bg-neutral-950', 'text-text', // Border 'border border-neutral-200 dark:border-neutral-800', // Transitions 'transition-colors', className )} > {children} <Command.Empty> <span className="text-muted-foreground">No results found</span> </Command.Empty> </Command.List> ); const MultiSelectItem: FC< { value: string } & ComponentProps<typeof Command.Item> > = ({ className, value, children, ...props }) => { const { value: Options, onValueChange, setInputValue } = useMultiSelect(); const mousePreventDefault: MouseEventHandler<HTMLDivElement> = useCallback( (e) => { e.preventDefault(); e.stopPropagation(); }, [] ); const isIncluded = Options.includes(value); return ( <Command.Item {...props} onSelect={() => { onValueChange(value); setInputValue(''); }} className={cn( // Base layout 'flex cursor-pointer justify-between', 'rounded-lg px-2 py-1', // Hover and transitions 'transition-colors', 'hover:bg-neutral/10', // States isIncluded && 'opacity-50', props.disabled && 'cursor-not-allowed opacity-50', className )} onMouseDown={mousePreventDefault} > {children} {isIncluded && <Check className="size-4" />} </Command.Item> ); }; type MultiSelectType = typeof MultiSelectRoot & { Trigger: typeof MultiSelectTrigger; Input: typeof MultiSelectInput; Content: typeof MultiSelectContent; List: typeof MultiSelectList; Item: typeof MultiSelectItem; }; /** * * Usage example: * ```jsx * <MultiSelect * values={value} * onValuesChange={setValue} * loop * > * <MultiSelect.Trigger> * <MultiSelect.Input placeholder="Select your framework" /> * </MultiSelect.Trigger> * <MultiSelect.Content> * <MultiSelect.List> * <MultiSelect.Item value={"React"}>React</MultiSelect.Item> * <MultiSelect.Item value={"Vue"}>Vue</MultiSelect.Item> * <MultiSelect.Item value={"Svelte"}>Svelte</MultiSelect.Item> * </MultiSelect.List> * </MultiSelect.Content> * </MultiSelect> * ``` */ export const MultiSelect = MultiSelectRoot as MultiSelectType; MultiSelect.Trigger = MultiSelectTrigger; MultiSelect.Input = MultiSelectInput; MultiSelect.Content = MultiSelectContent; MultiSelect.List = MultiSelectList; MultiSelect.Item = MultiSelectItem;

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/aymericzip/intlayer'

If you have feedback or need assistance with the MCP directory API, please join our Discord server