Files
klz-cables.com/components/forms/FormSelect.tsx
2025-12-29 18:18:48 +01:00

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;