598 lines
18 KiB
TypeScript
598 lines
18 KiB
TypeScript
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<React.SelectHTMLAttributes<HTMLSelectElement>, 'onChange' | 'onSelect'> {
|
|
label?: string;
|
|
error?: string;
|
|
helperText?: string;
|
|
placeholder?: string;
|
|
size?: 'sm' | 'md' | 'lg';
|
|
variant?: 'outline' | 'filled' | 'unstyled';
|
|
options: SelectOption[];
|
|
value?: string | number | Array<string | number>;
|
|
defaultValue?: string | number | Array<string | number>;
|
|
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<string | number>) => void;
|
|
onBlur?: (event: React.FocusEvent<HTMLDivElement>) => void;
|
|
onFocus?: (event: React.FocusEvent<HTMLDivElement>) => void;
|
|
renderOption?: (option: SelectOption, isSelected: boolean) => React.ReactNode;
|
|
renderValue?: (value: string | number | Array<string | number>, options: SelectOption[]) => React.ReactNode;
|
|
noOptionsMessage?: string;
|
|
loadingMessage?: string;
|
|
createLabel?: string;
|
|
}
|
|
|
|
const Select = forwardRef<HTMLDivElement, SelectProps>(({
|
|
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<string | number | Array<string | number>>(
|
|
value !== undefined ? value : defaultValue || (multiple ? [] : '')
|
|
);
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
|
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
const optionsRef = useRef<HTMLDivElement>(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<string | number>;
|
|
|
|
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<HTMLInputElement>) => {
|
|
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 <span className="text-input-placeholder">{placeholder}</span>;
|
|
}
|
|
|
|
if (currentValue.length === 1) {
|
|
const option = selectedOptions[0];
|
|
return option ? option.label : currentValue[0];
|
|
}
|
|
|
|
return <span>{currentValue.length} elementos seleccionados</span>;
|
|
}
|
|
|
|
const selectedOption = selectedOptions[0];
|
|
if (selectedOption) {
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
{selectedOption.icon && <span>{selectedOption.icon}</span>}
|
|
<span>{selectedOption.label}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return <span className="text-input-placeholder">{placeholder}</span>;
|
|
};
|
|
|
|
const renderMultipleValues = () => {
|
|
if (!multiple || !Array.isArray(currentValue) || currentValue.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const selectedOptions = getSelectedOptions();
|
|
|
|
if (selectedOptions.length <= 3) {
|
|
return (
|
|
<div className="flex flex-wrap gap-1">
|
|
{selectedOptions.map(option => (
|
|
<span
|
|
key={option.value}
|
|
className="inline-flex items-center gap-1 px-2 py-1 bg-bg-tertiary text-text-primary rounded text-sm"
|
|
>
|
|
{option.icon && <span className="text-xs">{option.icon}</span>}
|
|
<span>{option.label}</span>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => handleRemoveOption(option.value, e)}
|
|
className="text-text-tertiary hover:text-text-primary transition-colors duration-150"
|
|
>
|
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</span>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const renderOptions = () => {
|
|
if (loading) {
|
|
return (
|
|
<div className="px-3 py-2 text-text-secondary">
|
|
{loadingMessage}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (filteredOptions.length === 0) {
|
|
return (
|
|
<div className="px-3 py-2 text-text-secondary">
|
|
{noOptionsMessage}
|
|
{createable && searchTerm.trim() && (
|
|
<button
|
|
type="button"
|
|
onClick={handleCreate}
|
|
className="block w-full text-left px-3 py-2 text-color-primary hover:bg-dropdown-item-hover transition-colors duration-150"
|
|
>
|
|
{createLabel} "{searchTerm.trim()}"
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const renderOptionItem = (option: SelectOption, index: number) => {
|
|
const isSelected = isOptionSelected(option);
|
|
const isHighlighted = index === highlightedIndex;
|
|
|
|
if (renderOption) {
|
|
return renderOption(option, isSelected);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
key={option.value}
|
|
className={clsx(
|
|
'flex items-center justify-between px-3 py-2 cursor-pointer transition-colors duration-150',
|
|
{
|
|
'bg-dropdown-item-hover': isHighlighted,
|
|
'bg-color-primary/10 text-color-primary': isSelected && !multiple,
|
|
'opacity-50 cursor-not-allowed': option.disabled,
|
|
}
|
|
)}
|
|
onClick={() => handleOptionSelect(option)}
|
|
onMouseEnter={() => setHighlightedIndex(index)}
|
|
>
|
|
<div className="flex items-center gap-2 flex-1">
|
|
{multiple && (
|
|
<input
|
|
type="checkbox"
|
|
checked={isSelected}
|
|
readOnly
|
|
className="rounded border-input-border text-color-primary focus:ring-color-primary"
|
|
/>
|
|
)}
|
|
{option.icon && <span>{option.icon}</span>}
|
|
<div className="flex-1">
|
|
<div className="font-medium">{option.label}</div>
|
|
{option.description && (
|
|
<div className="text-xs text-text-secondary">{option.description}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{isSelected && !multiple && (
|
|
<svg className="w-4 h-4 text-color-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
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(
|
|
<div key={groupName} className="px-3 py-1 text-xs font-semibold text-text-tertiary uppercase tracking-wide">
|
|
{groupName}
|
|
</div>
|
|
);
|
|
|
|
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(
|
|
<button
|
|
key="__create__"
|
|
type="button"
|
|
onClick={handleCreate}
|
|
className="block w-full text-left px-3 py-2 text-color-primary hover:bg-dropdown-item-hover transition-colors duration-150 border-t border-border-primary"
|
|
>
|
|
{createLabel} "{searchTerm.trim()}"
|
|
</button>
|
|
);
|
|
}
|
|
|
|
return allOptions;
|
|
};
|
|
|
|
return (
|
|
<div className="w-full">
|
|
{label && (
|
|
<label
|
|
htmlFor={selectId}
|
|
className="block text-sm font-medium text-text-primary mb-2"
|
|
>
|
|
{label}
|
|
{isRequired && (
|
|
<span className="text-color-error ml-1">*</span>
|
|
)}
|
|
</label>
|
|
)}
|
|
|
|
<div
|
|
ref={ref || containerRef}
|
|
className={clsx(baseClasses, className)}
|
|
onKeyDown={handleKeyDown}
|
|
tabIndex={disabled ? -1 : 0}
|
|
onFocus={onFocus}
|
|
onBlur={onBlur}
|
|
{...props}
|
|
>
|
|
<div
|
|
className={clsx(triggerClasses, sizeClasses[size])}
|
|
onClick={() => !disabled && setIsOpen(!isOpen)}
|
|
>
|
|
<div className="flex-1 min-w-0">
|
|
{multiple && Array.isArray(currentValue) && currentValue.length > 0 && currentValue.length <= 3 ? (
|
|
renderMultipleValues()
|
|
) : (
|
|
renderSelectedValue()
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1 ml-2">
|
|
{clearable && currentValue && (multiple ? (Array.isArray(currentValue) && currentValue.length > 0) : true) && (
|
|
<button
|
|
type="button"
|
|
onClick={handleClear}
|
|
className="text-text-tertiary hover:text-text-primary transition-colors duration-150 p-1"
|
|
tabIndex={-1}
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
|
|
<svg
|
|
className={clsx('w-4 h-4 text-text-tertiary transition-transform duration-200', {
|
|
'rotate-180': isOpen,
|
|
})}
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={clsx(dropdownClasses)} style={{ maxHeight: isOpen ? maxHeight : 0 }}>
|
|
{searchable && (
|
|
<div className="p-2 border-b border-border-primary">
|
|
<input
|
|
ref={searchInputRef}
|
|
type="text"
|
|
placeholder="Buscar..."
|
|
value={searchTerm}
|
|
onChange={handleSearchChange}
|
|
className="w-full px-3 py-2 border border-input-border rounded bg-input-bg focus:outline-none focus:ring-2 focus:ring-color-primary/20"
|
|
onClick={(e) => e.stopPropagation()}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div
|
|
ref={optionsRef}
|
|
className="max-h-60 overflow-y-auto"
|
|
style={{ maxHeight: searchable ? maxHeight - 60 : maxHeight }}
|
|
>
|
|
{renderOptions()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{error && (
|
|
<p className="mt-2 text-sm text-color-error">
|
|
{error}
|
|
</p>
|
|
)}
|
|
|
|
{helperText && !error && (
|
|
<p className="mt-2 text-sm text-text-secondary">
|
|
{helperText}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
});
|
|
|
|
Select.displayName = 'Select';
|
|
|
|
export default Select; |