ADD new frontend
This commit is contained in:
598
frontend/src/components/ui/Select/Select.tsx
Normal file
598
frontend/src/components/ui/Select/Select.tsx
Normal file
@@ -0,0 +1,598 @@
|
||||
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;
|
||||
3
frontend/src/components/ui/Select/index.ts
Normal file
3
frontend/src/components/ui/Select/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default } from './Select';
|
||||
export { default as Select } from './Select';
|
||||
export type { SelectProps, SelectOption } from './Select';
|
||||
Reference in New Issue
Block a user