migration wip
This commit is contained in:
200
components/forms/FormSelect.tsx
Normal file
200
components/forms/FormSelect.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user