200 lines
5.4 KiB
TypeScript
200 lines
5.4 KiB
TypeScript
import React, { useState, useCallback } from 'react';
|
|
import { cn } from '@/lib/utils';
|
|
import FormLabel from './FormLabel';
|
|
import FormError from './FormError';
|
|
|
|
/**
|
|
* FormSelect Component
|
|
* Select dropdown with placeholder, multi-select support, and custom styling
|
|
*/
|
|
|
|
export interface SelectOption {
|
|
value: string | number;
|
|
label: string;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
export interface FormSelectProps extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, 'multiple' | 'size'> {
|
|
label?: string;
|
|
error?: string | string[];
|
|
helpText?: string;
|
|
required?: boolean;
|
|
options: SelectOption[];
|
|
placeholder?: string;
|
|
multiple?: boolean;
|
|
showSearch?: boolean;
|
|
containerClassName?: string;
|
|
selectClassName?: string;
|
|
onSearch?: (query: string) => void;
|
|
}
|
|
|
|
export const FormSelect: React.FC<FormSelectProps> = ({
|
|
label,
|
|
error,
|
|
helpText,
|
|
required = false,
|
|
options,
|
|
placeholder = 'Select an option',
|
|
multiple = false,
|
|
showSearch = false,
|
|
containerClassName,
|
|
selectClassName,
|
|
onSearch,
|
|
disabled = false,
|
|
value,
|
|
onChange,
|
|
...props
|
|
}) => {
|
|
const [isFocused, setIsFocused] = useState(false);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
|
|
const hasError = !!error;
|
|
const showError = hasError;
|
|
|
|
const handleFocus = () => setIsFocused(true);
|
|
const handleBlur = () => setIsFocused(false);
|
|
|
|
const handleChange = useCallback((e: React.ChangeEvent<HTMLSelectElement>) => {
|
|
onChange?.(e);
|
|
}, [onChange]);
|
|
|
|
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const query = e.target.value;
|
|
setSearchQuery(query);
|
|
if (onSearch) {
|
|
onSearch(query);
|
|
}
|
|
}, [onSearch]);
|
|
|
|
const filteredOptions = showSearch && searchQuery
|
|
? options.filter(option =>
|
|
option.label.toLowerCase().includes(searchQuery.toLowerCase())
|
|
)
|
|
: options;
|
|
|
|
const inputId = props.id || (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined);
|
|
|
|
const baseSelectClasses = cn(
|
|
'w-full px-3 py-2 border rounded-md transition-all duration-200',
|
|
'bg-neutral-light text-text-primary',
|
|
'focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary',
|
|
'disabled:opacity-60 disabled:cursor-not-allowed',
|
|
'appearance-none cursor-pointer',
|
|
'bg-[length:1.5em_1.5em] bg-[position:right_0.5rem_center] bg-no-repeat',
|
|
'bg-[url("data:image/svg+xml,%3csvg xmlns=\'http://www.w3.org/2000/svg\' fill=\'none\' viewBox=\'0 0 20 20\'%3e%3cpath stroke=\'%236c757d\' stroke-linecap=\'round\' stroke-linejoin=\'round\' stroke-width=\'1.5\' d=\'M6 8l4 4 4-4\'/%3e%3c/svg%3e")]',
|
|
{
|
|
'border-neutral-dark hover:border-neutral-dark': !hasError && !isFocused,
|
|
'border-primary ring-2 ring-primary': isFocused && !hasError,
|
|
'border-danger ring-2 ring-danger/20': hasError,
|
|
'pr-10': !showSearch,
|
|
},
|
|
selectClassName
|
|
);
|
|
|
|
const containerClasses = cn(
|
|
'flex flex-col gap-1.5',
|
|
containerClassName
|
|
);
|
|
|
|
const searchInputClasses = cn(
|
|
'w-full px-3 py-2 border-b border-neutral-dark bg-transparent',
|
|
'focus:outline-none focus:border-primary',
|
|
'placeholder:text-text-light'
|
|
);
|
|
|
|
// Custom dropdown arrow
|
|
const dropdownArrow = (
|
|
<svg
|
|
className="w-4 h-4 text-text-secondary pointer-events-none absolute right-3 top-1/2 -translate-y-1/2"
|
|
fill="currentColor"
|
|
viewBox="0 0 20 20"
|
|
>
|
|
<path
|
|
fillRule="evenodd"
|
|
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
|
|
clipRule="evenodd"
|
|
/>
|
|
</svg>
|
|
);
|
|
|
|
const renderOptions = () => {
|
|
// Add placeholder as first option if not multiple and no value
|
|
const showPlaceholder = !multiple && !value && placeholder;
|
|
|
|
return (
|
|
<>
|
|
{showPlaceholder && (
|
|
<option value="" disabled>
|
|
{placeholder}
|
|
</option>
|
|
)}
|
|
{filteredOptions.map((option) => (
|
|
<option
|
|
key={option.value}
|
|
value={option.value}
|
|
disabled={option.disabled}
|
|
className={option.disabled ? 'opacity-50' : ''}
|
|
>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className={containerClasses}>
|
|
{label && (
|
|
<FormLabel htmlFor={inputId} required={required}>
|
|
{label}
|
|
</FormLabel>
|
|
)}
|
|
|
|
<div className="relative">
|
|
{showSearch && (
|
|
<input
|
|
type="text"
|
|
placeholder="Search options..."
|
|
value={searchQuery}
|
|
onChange={handleSearchChange}
|
|
className={searchInputClasses}
|
|
disabled={disabled}
|
|
/>
|
|
)}
|
|
|
|
<select
|
|
id={inputId}
|
|
className={baseSelectClasses}
|
|
value={value}
|
|
onChange={handleChange}
|
|
onFocus={handleFocus}
|
|
onBlur={handleBlur}
|
|
disabled={disabled}
|
|
multiple={multiple}
|
|
aria-invalid={hasError}
|
|
aria-describedby={helpText || showError ? `${inputId}-error` : undefined}
|
|
required={required}
|
|
{...props}
|
|
>
|
|
{renderOptions()}
|
|
</select>
|
|
|
|
{!showSearch && dropdownArrow}
|
|
</div>
|
|
|
|
{helpText && (
|
|
<p className="text-xs text-text-secondary" id={`${inputId}-help`}>
|
|
{helpText}
|
|
</p>
|
|
)}
|
|
|
|
{showError && (
|
|
<FormError errors={error} id={`${inputId}-error`} />
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
FormSelect.displayName = 'FormSelect';
|
|
|
|
export default FormSelect; |