'use client' import { Check, ChevronsUpDown, X as RemoveIcon } from 'lucide-react' import React, { useEffect } from 'react' import { VariantProps, cva } from 'class-variance-authority' import { SIZE_VARIANTS, SIZE_VARIANTS_DEFAULT } from 'ui/src/lib/constants' import { Badge, cn, useOnClickOutside } from 'ui' import { Command, CommandEmpty, CommandInput, CommandItem, CommandList, } from 'ui/src/components/shadcn/ui/command' interface MultiSelectContextProps { values: string[] onValuesChange: (value: string[]) => void toggleValue: (values: string) => void open: boolean setOpen: React.Dispatch> inputValue: string setInputValue: React.Dispatch> activeIndex: number setActiveIndex: React.Dispatch> size: MultiSelectorProps['size'] disabled?: boolean } const MultiSelectContext = React.createContext(null) const commandItemClass = cn( 'relative text-foreground-lighter text-left px-2 py-1.5 rounded', 'hover:text-foreground hover:!bg-overlay-hover w-full flex items-center space-x-2', 'peer-data-[value=true]:bg-overlay-hover peer-data-[value=true]:text-strong' ) function useMultiSelect() { const context = React.useContext(MultiSelectContext) if (!context) { throw new Error('useMultiSelect must be used within a MultiSelectProvider') } return context } const MultiSelectorVariants = cva('', { variants: { size: { ...SIZE_VARIANTS, }, }, defaultVariants: { size: SIZE_VARIANTS_DEFAULT, }, }) type MultiSelectorMode = 'combobox' | 'inline-combobox' type MultiSelectorProps = { mode?: MultiSelectorMode values: string[] onValuesChange: (value: string[]) => void disabled?: boolean } & React.ComponentPropsWithoutRef & VariantProps function MultiSelector({ values = [], onValuesChange, disabled, dir, size, className, children, ...props }: MultiSelectorProps) { const ref = React.useRef(null) const [open, setOpen] = React.useState(false) const [inputValue, setInputValue] = React.useState('') const [activeIndex, setActiveIndex] = React.useState(-1) const toggleValue = React.useCallback( (toggledValue: string) => { if (values.includes(toggledValue)) { onValuesChange(values.filter((value) => value !== toggledValue) || []) } else { onValuesChange([...values, toggledValue]) } }, [values] ) // detect clicks from outside useOnClickOutside(ref, () => { if (open) { setOpen(!open) } }) const handleKeyDown = React.useCallback( (e: React.KeyboardEvent) => { switch (e.key) { case 'Backspace': case 'Delete': if (values.length > 0 && inputValue.length === 0) { if (activeIndex !== -1 && activeIndex < values.length) { onValuesChange(values.filter((item) => item !== values[activeIndex])) const newIndex = activeIndex - 1 < 0 ? 0 : activeIndex - 1 setActiveIndex(newIndex) } else { onValuesChange(values.filter((item) => item !== values[values.length - 1])) } } break case 'Escape': activeIndex !== -1 ? setActiveIndex(-1) : setOpen(false) if (ref.current) { const button = (ref.current as HTMLDivElement).querySelector('button[role="combobox"]') button && (button as HTMLButtonElement).focus() } break case 'Enter': setOpen(true) break } }, [values, inputValue, activeIndex] ) return ( {children} ) } export interface MultiSelectorTriggerProps extends React.HTMLAttributes { label?: string persistLabel?: boolean className?: string badgeLimit?: number | 'wrap' deletableBadge?: boolean showIcon?: boolean mode?: MultiSelectorMode } const MultiSelectorTrigger = React.forwardRef( ( { label, persistLabel = false, className, deletableBadge = true, badgeLimit = 9999, showIcon = true, mode = 'combobox', children, ...props }, ref ) => { const { activeIndex, values, setInputValue, toggleValue, disabled, setOpen } = useMultiSelect() const inputRef = React.useRef(null) // Use the provided ref if available, otherwise use the local ref React.useImperativeHandle(ref, () => inputRef.current as HTMLButtonElement) const inlineInputRef = React.useRef(null) const badgesRef = React.useRef(null) const [visibleBadges, setVisibleBadges] = React.useState([]) const [extraBadgesCount, setExtraBadgesCount] = React.useState(0) const [isDeleteHovered, setIsDeleteHovered] = React.useState(false) const IS_BADGE_LIMIT_WRAP = badgeLimit === 'wrap' const IS_NUMERIC_LIMIT = typeof badgeLimit === 'number' const IS_INLINE_MODE = mode === 'inline-combobox' React.useEffect(() => { if (!inputRef?.current || !badgesRef.current) return if (IS_BADGE_LIMIT_WRAP) { setVisibleBadges(values) setExtraBadgesCount(0) } else { setVisibleBadges(values.slice(0, badgeLimit)) setExtraBadgesCount(Math.max(0, values.length - badgeLimit)) } }, [values, badgeLimit]) const badgeClasses = 'rounded shrink-0 px-1.5' const handleTriggerClick: React.MouseEventHandler = React.useCallback( (event) => { setOpen(true) setInputValue('') if (IS_INLINE_MODE) { event.stopPropagation() event.preventDefault() setTimeout(() => { inlineInputRef.current?.focus() }, 100) } }, [] ) return ( ) } ) MultiSelectorTrigger.displayName = 'MultiSelectorTrigger' MultiSelector.Trigger = MultiSelectorTrigger const MultiSelectorInputVariants = cva('bg-control border', { variants: { size: { ...SIZE_VARIANTS, }, }, defaultVariants: { size: SIZE_VARIANTS_DEFAULT, }, }) const MultiSelectorInput = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { showResetIcon?: boolean showSearchIcon?: boolean wrapperClassName?: string } >(({ className, wrapperClassName, showResetIcon, showSearchIcon, ...props }, ref) => { const { open, setOpen, inputValue, setInputValue, activeIndex, setActiveIndex, size, disabled } = useMultiSelect() const inputRef = React.useRef(null) // Use the provided ref if available, otherwise use the local ref React.useImperativeHandle(ref, () => inputRef.current as HTMLInputElement) const handleFocus = () => setOpen(true) const handleClick = () => setActiveIndex(-1) const handleReset = () => { setInputValue('') setInputFocus() } const setInputFocus = () => { setTimeout(() => { if (!inputRef?.current) return if (open) { inputRef.current.focus() } }, 100) } useEffect(() => { setInputFocus() if (!open) { inputRef.current?.blur() } }, [open]) return ( ) }) MultiSelectorInput.displayName = 'MultiSelectorInput' MultiSelector.Input = MultiSelectorInput const MultiSelectorContent = React.forwardRef>( ({ className, children }, ref) => { const { open } = useMultiSelect() return (
{open && children}
) } ) MultiSelectorContent.displayName = 'MultiSelectorContent' MultiSelector.Content = MultiSelectorContent const MultiSelectorList = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { creatable?: boolean } >(({ className, children, creatable = false }, ref) => { const { open, inputValue, setInputValue, toggleValue } = useMultiSelect() const options = !!children ? Array.isArray(children) ? (children as React.ReactNode[]) : typeof children === 'object' && 'props' in children ? children.props.children : [] : [] const availableOptions = options .filter((x: any) => !!x.props.value) .map((x: any) => x.props.value.toLowerCase()) const isOptionExists = availableOptions.some((x: string) => x === inputValue.toLowerCase()) return ( {children} {creatable && inputValue.length > 0 && !isOptionExists ? ( { open && toggleValue(inputValue) setInputValue('') }} className={commandItemClass} > Create "{inputValue}" ) : creatable && options.length === 0 ? (
Type to add a value
) : ( No results found )}
) }) MultiSelectorList.displayName = 'MultiSelectorList' MultiSelector.List = MultiSelectorList const MultiSelectorItem = React.forwardRef< HTMLDivElement, { value: string } & React.ComponentPropsWithoutRef >(({ className, value, children, ...props }, ref) => { const { values: selectedValues, setInputValue, toggleValue, open } = useMultiSelect() const isSelected = selectedValues.includes(value) return ( { open && toggleValue(value) setInputValue('') }} className={cn(commandItemClass, className)} {...props} >
{children}
) }) MultiSelectorItem.displayName = 'MultiSelectorItem' MultiSelector.Item = MultiSelectorItem export { MultiSelector, MultiSelectorContent, MultiSelectorInput, MultiSelectorItem, MultiSelectorList, MultiSelectorTrigger, }