import React, { forwardRef, useState, useRef, useEffect, useMemo } from 'react'; import { clsx } from 'clsx'; export interface SelectOption { value: string | number; label: string; disabled?: boolean; group?: string; icon?: React.ReactNode; description?: string; } export interface SelectProps extends Omit, 'onChange' | 'onSelect'> { label?: string; error?: string; helperText?: string; placeholder?: string; size?: 'sm' | 'md' | 'lg'; variant?: 'outline' | 'filled' | 'unstyled'; options: SelectOption[]; value?: string | number | Array; defaultValue?: string | number | Array; multiple?: boolean; searchable?: boolean; clearable?: boolean; loading?: boolean; isRequired?: boolean; isInvalid?: boolean; maxHeight?: number; dropdownPosition?: 'auto' | 'top' | 'bottom'; createable?: boolean; onCreate?: (inputValue: string) => void; onSearch?: (inputValue: string) => void; onChange?: (value: string | number | Array) => void; onBlur?: (event: React.FocusEvent) => void; onFocus?: (event: React.FocusEvent) => void; renderOption?: (option: SelectOption, isSelected: boolean) => React.ReactNode; renderValue?: (value: string | number | Array, options: SelectOption[]) => React.ReactNode; noOptionsMessage?: string; loadingMessage?: string; createLabel?: string; } const Select = forwardRef(({ label, error, helperText, placeholder = 'Seleccionar...', size = 'md', variant = 'outline', options = [], value, defaultValue, multiple = false, searchable = false, clearable = false, loading = false, isRequired = false, isInvalid = false, maxHeight = 300, dropdownPosition = 'auto', createable = false, onCreate, onSearch, onChange, onBlur, onFocus, renderOption, renderValue, noOptionsMessage = 'No hay opciones disponibles', loadingMessage = 'Cargando...', createLabel = 'Crear', className, id, disabled, ...props }, ref) => { const selectId = id || `select-${Math.random().toString(36).substr(2, 9)}`; const [internalValue, setInternalValue] = useState>( value !== undefined ? value : defaultValue || (multiple ? [] : '') ); const [isOpen, setIsOpen] = useState(false); const [searchTerm, setSearchTerm] = useState(''); const [highlightedIndex, setHighlightedIndex] = useState(-1); const containerRef = useRef(null); const searchInputRef = useRef(null); const optionsRef = useRef(null); // Use controlled value if provided, otherwise use internal state const currentValue = value !== undefined ? value : internalValue; // Filter options based on search term const filteredOptions = useMemo(() => { if (!searchable || !searchTerm) return options; return options.filter(option => option.label.toLowerCase().includes(searchTerm.toLowerCase()) || option.value.toString().toLowerCase().includes(searchTerm.toLowerCase()) ); }, [options, searchTerm, searchable]); // Group options if they have groups const groupedOptions = useMemo(() => { const groups: { [key: string]: SelectOption[] } = {}; const ungrouped: SelectOption[] = []; filteredOptions.forEach(option => { if (option.group) { if (!groups[option.group]) { groups[option.group] = []; } groups[option.group].push(option); } else { ungrouped.push(option); } }); return { groups, ungrouped }; }, [filteredOptions]); // Check if option is selected const isOptionSelected = (option: SelectOption) => { if (multiple && Array.isArray(currentValue)) { return currentValue.includes(option.value); } return currentValue === option.value; }; // Get selected options for display const getSelectedOptions = () => { if (multiple && Array.isArray(currentValue)) { return options.filter(option => currentValue.includes(option.value)); } return options.filter(option => option.value === currentValue); }; // Handle option selection const handleOptionSelect = (option: SelectOption) => { if (option.disabled) return; let newValue: string | number | Array; if (multiple) { const currentArray = Array.isArray(currentValue) ? currentValue : []; if (currentArray.includes(option.value)) { newValue = currentArray.filter(val => val !== option.value); } else { newValue = [...currentArray, option.value]; } } else { newValue = option.value; setIsOpen(false); } if (value === undefined) { setInternalValue(newValue); } onChange?.(newValue); }; // Handle search input change const handleSearchChange = (e: React.ChangeEvent) => { const value = e.target.value; setSearchTerm(value); setHighlightedIndex(-1); onSearch?.(value); }; // Handle clear selection const handleClear = (e: React.MouseEvent) => { e.stopPropagation(); const newValue = multiple ? [] : ''; if (value === undefined) { setInternalValue(newValue); } onChange?.(newValue); }; // Handle remove single option in multiple mode const handleRemoveOption = (optionValue: string | number, e: React.MouseEvent) => { e.stopPropagation(); if (!multiple || !Array.isArray(currentValue)) return; const newValue = currentValue.filter(val => val !== optionValue); if (value === undefined) { setInternalValue(newValue); } onChange?.(newValue); }; // Handle create new option const handleCreate = () => { if (!createable || !onCreate || !searchTerm.trim()) return; onCreate(searchTerm.trim()); setSearchTerm(''); setIsOpen(false); }; // Handle keyboard navigation const handleKeyDown = (e: React.KeyboardEvent) => { if (!isOpen) { if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') { e.preventDefault(); setIsOpen(true); } return; } switch (e.key) { case 'Escape': setIsOpen(false); setSearchTerm(''); break; case 'ArrowDown': e.preventDefault(); setHighlightedIndex(prev => prev < filteredOptions.length - 1 ? prev + 1 : 0 ); break; case 'ArrowUp': e.preventDefault(); setHighlightedIndex(prev => prev > 0 ? prev - 1 : filteredOptions.length - 1 ); break; case 'Enter': e.preventDefault(); if (highlightedIndex >= 0 && highlightedIndex < filteredOptions.length) { handleOptionSelect(filteredOptions[highlightedIndex]); } else if (createable && searchTerm.trim()) { handleCreate(); } break; case 'Tab': setIsOpen(false); break; } }; // Close dropdown when clicking outside useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (containerRef.current && !containerRef.current.contains(event.target as Node)) { setIsOpen(false); setSearchTerm(''); } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, []); // Focus search input when dropdown opens useEffect(() => { if (isOpen && searchable && searchInputRef.current) { searchInputRef.current.focus(); } }, [isOpen, searchable]); // Scroll highlighted option into view useEffect(() => { if (highlightedIndex >= 0 && optionsRef.current) { const optionElement = optionsRef.current.children[highlightedIndex] as HTMLElement; if (optionElement) { optionElement.scrollIntoView({ block: 'nearest' }); } } }, [highlightedIndex]); const hasError = isInvalid || !!error; const baseClasses = [ 'relative w-full cursor-pointer', 'transition-colors duration-200', 'focus:outline-none', { 'opacity-50 cursor-not-allowed': disabled, } ]; const triggerClasses = [ 'flex items-center justify-between w-full px-3 py-2', 'bg-input-bg border border-input-border rounded-lg', 'text-left transition-colors duration-200', 'focus:border-input-border-focus focus:ring-1 focus:ring-input-border-focus', { 'border-input-border-error focus:border-input-border-error focus:ring-input-border-error': hasError, 'bg-bg-secondary border-transparent focus:bg-input-bg focus:border-input-border-focus': variant === 'filled', 'bg-transparent border-none focus:ring-0': variant === 'unstyled', } ]; const sizeClasses = { sm: 'h-8 text-sm', md: 'h-10 text-base', lg: 'h-12 text-lg', }; const dropdownClasses = [ 'absolute z-50 w-full mt-1 bg-dropdown-bg border border-dropdown-border rounded-lg shadow-lg', 'transform transition-all duration-200 ease-out', { 'opacity-0 scale-95 pointer-events-none': !isOpen, 'opacity-100 scale-100': isOpen, } ]; const renderSelectedValue = () => { const selectedOptions = getSelectedOptions(); if (renderValue) { return renderValue(currentValue, selectedOptions); } if (multiple && Array.isArray(currentValue)) { if (currentValue.length === 0) { return {placeholder}; } if (currentValue.length === 1) { const option = selectedOptions[0]; return option ? option.label : currentValue[0]; } return {currentValue.length} elementos seleccionados; } const selectedOption = selectedOptions[0]; if (selectedOption) { return (
{selectedOption.icon && {selectedOption.icon}} {selectedOption.label}
); } return {placeholder}; }; const renderMultipleValues = () => { if (!multiple || !Array.isArray(currentValue) || currentValue.length === 0) { return null; } const selectedOptions = getSelectedOptions(); if (selectedOptions.length <= 3) { return (
{selectedOptions.map(option => ( {option.icon && {option.icon}} {option.label} ))}
); } return null; }; const renderOptions = () => { if (loading) { return (
{loadingMessage}
); } if (filteredOptions.length === 0) { return (
{noOptionsMessage} {createable && searchTerm.trim() && ( )}
); } const renderOptionItem = (option: SelectOption, index: number) => { const isSelected = isOptionSelected(option); const isHighlighted = index === highlightedIndex; if (renderOption) { return renderOption(option, isSelected); } return (
handleOptionSelect(option)} onMouseEnter={() => setHighlightedIndex(index)} >
{multiple && ( )} {option.icon && {option.icon}}
{option.label}
{option.description && (
{option.description}
)}
{isSelected && !multiple && ( )}
); }; const allOptions: React.ReactNode[] = []; // Add ungrouped options groupedOptions.ungrouped.forEach((option, index) => { allOptions.push(renderOptionItem(option, index)); }); // Add grouped options Object.entries(groupedOptions.groups).forEach(([groupName, groupOptions]) => { allOptions.push(
{groupName}
); groupOptions.forEach((option, index) => { const globalIndex = groupedOptions.ungrouped.length + index; allOptions.push(renderOptionItem(option, globalIndex)); }); }); // Add create option if applicable if (createable && searchTerm.trim() && !filteredOptions.some(opt => opt.label.toLowerCase() === searchTerm.toLowerCase() || opt.value.toString().toLowerCase() === searchTerm.toLowerCase() )) { allOptions.push( ); } return allOptions; }; return (
{label && ( )}
!disabled && setIsOpen(!isOpen)} >
{multiple && Array.isArray(currentValue) && currentValue.length > 0 && currentValue.length <= 3 ? ( renderMultipleValues() ) : ( renderSelectedValue() )}
{clearable && currentValue && (multiple ? (Array.isArray(currentValue) && currentValue.length > 0) : true) && ( )}
{searchable && (
e.stopPropagation()} />
)}
{renderOptions()}
{error && (

{error}

)} {helperText && !error && (

{helperText}

)}
); }); Select.displayName = 'Select'; export default Select;