Files
bakery-ia/frontend/src/components/ui/Select/Select.tsx

598 lines
18 KiB
TypeScript
Raw Normal View History

2025-08-28 10:41:04 +02:00
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;