import clsx from 'clsx'; import { isEqual } from 'lodash-es'; import { AlertTriangle, Check, ChevronsUpDown, LoaderCircle, SearchIcon, SquarePlus, X } from 'lucide-react'; import { useState, useEffect, useRef, useMemo, ReactNode } from 'react'; import { Popover, PopoverContent, PopoverTrigger, PopoverClose } from './popover'; import { Command, CommandEmpty, CommandGroup, CommandItem, CommandList } from './command'; import { Input } from './input'; import { Button } from './button'; import { VTooltip } from './tooltip'; export interface SelectBoxBaseProps { options: T[] | undefined; optionLabel?: (option: T) => React.ReactNode; onBlur?: () => void; onKeyDown?: (e: React.KeyboardEvent, isOpen: boolean) => void; label?: string; placeholder?: string; addNew?: () => void; addNewLabel?: string; disabled?: boolean; filterBy?: string | ((o: T) => string); by?: (keyof T & string) | ((a: T, z: T) => boolean) className?: string; popupClass?: string; isClearable?: boolean; border?: boolean; inline?: boolean; clearIcon?: ReactNode; clearTitle?: string; isLoading?: boolean; /** Show warning when value is not in options list (default: true) */ warnOnMissingValue?: boolean; /** Custom warning message when value is not in options */ missingValueWarning?: string; } interface SelectBoxSingleProps extends SelectBoxBaseProps { multiple?: false; value?: T; onChange: (option: T) => void; } interface SelectBoxMultipleProps extends SelectBoxBaseProps { multiple: true; value?: T[]; onChange: (options: T[]) => void; } type SelectBoxProps = SelectBoxSingleProps | SelectBoxMultipleProps; export function SelectBox({ options, optionLabel, value, onChange, addNew, addNewLabel, disabled, filterBy, label, placeholder, className, popupClass, isClearable, border = true, multiple = false, by, inline = false, isLoading = false, warnOnMissingValue = true, missingValueWarning = "Value not in options list, may not be valid", clearIcon, clearTitle }: Readonly>) { const triggerRef = useRef(null); const [open, setOpen] = useState(false); const [width, setWidth] = useState(0); const [filterValue, setFilterValue] = useState(''); // Check if value is in options list (for single select only) const isMissingValue = useMemo(() => { if (!warnOnMissingValue || multiple || value == null || !options) return false; // Use the isOptionsEqual helper which respects the 'by' comparator return !options.some(opt => { if (typeof by === 'string') { return (opt as any)[by] === (value as any)[by]; } else if (typeof by === 'function') { return by(opt, value as T); } else { return isEqual(opt, value); } }); }, [warnOnMissingValue, multiple, value, options, by]); useEffect(() => { const element = triggerRef.current; if (!element) { return; } const updateWidth = () => { const contentWidth = element.getBoundingClientRect().width; setWidth(contentWidth); }; const resizeObserver = new ResizeObserver(() => { updateWidth(); }); updateWidth(); resizeObserver.observe(element); return () => { resizeObserver.disconnect(); }; }, []); const handleTriggerClick = (e: React.MouseEvent) => { if (disabled || isLoading) { e.preventDefault(); return; } setOpen(!open); }; const _onClick = (opt: any) => { if (multiple) { const currentValues = Array.isArray(value) ? value : []; const isSelected = isOptionSelected(opt, currentValues); if (isSelected) { // Remove from selection const newValues = currentValues.filter(v => !isOptionsEqual(v, opt)); (onChange as (options: T[]) => void)(newValues); } else { // Add to selection (onChange as (options: T[]) => void)([...currentValues, opt]); } // Don't close the popover in multiple mode } else { setOpen(false); (onChange as (option: T) => void)(opt); } }; // Helper function to check if an option is selected const isOptionSelected = (option: T, selectedValues: T[]): boolean => { if (!selectedValues || selectedValues.length === 0) return false; return selectedValues.some(v => isOptionsEqual(v, option)); }; // Helper function to compare options for equality const isOptionsEqual = (a: T, b: T): boolean => { // Handle null/undefined values if (a == null || b == null) { return a === b; } if (typeof by === 'string') { return (a as any)[by] === (b as any)[by]; } else if (typeof by === 'function') { return by(a, b); } else { return isEqual(a, b); } }; let filteredOptions = options || []; function getFilterByFn(filterBy?: string | ((o: T) => string)) { if (!filterBy) { return (o: T) => String(o).toLowerCase(); } else if (typeof filterBy === 'string') { return (o: any) => String(o[filterBy]).toLowerCase(); } else { return filterBy; } } const filterLc = filterValue.toLowerCase(); const filterFn = getFilterByFn(filterBy); filteredOptions = filteredOptions.filter(o => filterFn(o).includes(filterLc)) const renderSingleValue = () => { if (!value || (Array.isArray(value) && value.length === 0)) { return {placeholder}; } const singleValue = Array.isArray(value) ? value[0] : value; return optionLabel ? optionLabel(singleValue) : singleValue as string; }; const renderMultipleValue = () => { const arrayValue = Array.isArray(value) ? value : (value ? [value] : []); if (arrayValue.length === 0) { return {placeholder}; } if (arrayValue.length === 1) { return optionLabel ? optionLabel(arrayValue[0]) : arrayValue[0] as string; } return (
{arrayValue.slice(0, 1).map((item, index) => ( {optionLabel ? optionLabel(item) : item as string} ))} {arrayValue.length > 1 && ( +{arrayValue.length - 1} more )}
); }; // Render the options list content const renderOptionsContent = () => ( <> {filterBy && (
)} { e.currentTarget.scrollTop += e.deltaY; }} > No result found. {filteredOptions?.map((opt, index) => { const isSelected = multiple ? isOptionSelected(opt, Array.isArray(value) ? value : []) : value != null ? isOptionsEqual(value as T, opt) : false; return ( _onClick(opt)} className="w-full" > {multiple || inline ? (
{optionLabel ? optionLabel(opt) : opt as String}
{isSelected && }
) : (
{optionLabel ? optionLabel(opt) : opt as String}
{isSelected && }
)}
); })}
{addNew && ( )} ); if (inline) { return (
{isLoading ?
: renderOptionsContent() }
); } return (
{isLoading ? ( ) : ( <>
0 : true) && "pr-2" )} > {label &&
{label}
}
{isMissingValue && ( )} {multiple ? renderMultipleValue() : renderSingleValue()}
{isClearable && value && (Array.isArray(value) ? value.length > 0 : true) && ( )} {!disabled && ( )}
)}
{renderOptionsContent()}
); }