215 lines
5.6 KiB
TypeScript
215 lines
5.6 KiB
TypeScript
|
|
/**
|
||
|
|
* FormSelect Component
|
||
|
|
*
|
||
|
|
* A reusable select/dropdown component that handles:
|
||
|
|
* - Label
|
||
|
|
* - Controller (react-hook-form)
|
||
|
|
* - Select with options
|
||
|
|
* - Error handling
|
||
|
|
* - Loading states
|
||
|
|
*
|
||
|
|
* This component reduces code duplication for select fields across forms.
|
||
|
|
*/
|
||
|
|
|
||
|
|
import React from 'react';
|
||
|
|
import { Control, Controller, FieldError, FieldPath, FieldValues } from 'react-hook-form';
|
||
|
|
import { Label } from '@/components/ui/label';
|
||
|
|
import {
|
||
|
|
Select,
|
||
|
|
SelectContent,
|
||
|
|
SelectItem,
|
||
|
|
SelectTrigger,
|
||
|
|
SelectValue,
|
||
|
|
} from '@/components/ui/select';
|
||
|
|
import { cn } from '@/lib/utils';
|
||
|
|
|
||
|
|
// =============================================================================
|
||
|
|
// TYPES
|
||
|
|
// =============================================================================
|
||
|
|
|
||
|
|
export interface SelectOption {
|
||
|
|
value: string | number;
|
||
|
|
label: string;
|
||
|
|
disabled?: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface FormSelectProps<
|
||
|
|
TFieldValues extends FieldValues = FieldValues,
|
||
|
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||
|
|
> {
|
||
|
|
// Form control
|
||
|
|
control: Control<TFieldValues>;
|
||
|
|
name: TName;
|
||
|
|
|
||
|
|
// Field configuration
|
||
|
|
label?: string;
|
||
|
|
placeholder?: string;
|
||
|
|
required?: boolean;
|
||
|
|
|
||
|
|
// Options
|
||
|
|
options: SelectOption[];
|
||
|
|
|
||
|
|
// Styling
|
||
|
|
className?: string;
|
||
|
|
labelClassName?: string;
|
||
|
|
selectClassName?: string;
|
||
|
|
errorClassName?: string;
|
||
|
|
|
||
|
|
// Validation
|
||
|
|
validation?: {
|
||
|
|
required?: boolean | string;
|
||
|
|
};
|
||
|
|
|
||
|
|
// Additional props
|
||
|
|
disabled?: boolean;
|
||
|
|
size?: 'sm' | 'md' | 'lg';
|
||
|
|
loading?: boolean;
|
||
|
|
|
||
|
|
// Custom render functions
|
||
|
|
renderOption?: (option: SelectOption) => React.ReactElement;
|
||
|
|
renderTrigger?: (props: {
|
||
|
|
field: any;
|
||
|
|
fieldState: { error?: FieldError };
|
||
|
|
}) => React.ReactElement;
|
||
|
|
}
|
||
|
|
|
||
|
|
// =============================================================================
|
||
|
|
// COMPONENT
|
||
|
|
// =============================================================================
|
||
|
|
|
||
|
|
export function FormSelect<
|
||
|
|
TFieldValues extends FieldValues = FieldValues,
|
||
|
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||
|
|
>({
|
||
|
|
control,
|
||
|
|
name,
|
||
|
|
label,
|
||
|
|
placeholder = 'Select an option',
|
||
|
|
required = false,
|
||
|
|
options,
|
||
|
|
className = '',
|
||
|
|
labelClassName = '',
|
||
|
|
selectClassName = '',
|
||
|
|
errorClassName = '',
|
||
|
|
validation,
|
||
|
|
disabled = false,
|
||
|
|
size = 'md',
|
||
|
|
loading = false,
|
||
|
|
renderOption,
|
||
|
|
renderTrigger,
|
||
|
|
}: FormSelectProps<TFieldValues, TName>) {
|
||
|
|
|
||
|
|
// =============================================================================
|
||
|
|
// HELPER FUNCTIONS
|
||
|
|
// =============================================================================
|
||
|
|
|
||
|
|
const getSelectSize = (size: 'sm' | 'md' | 'lg') => {
|
||
|
|
switch (size) {
|
||
|
|
case 'sm':
|
||
|
|
return 'h-8 text-sm';
|
||
|
|
case 'lg':
|
||
|
|
return 'h-12 text-base';
|
||
|
|
default:
|
||
|
|
return 'h-10 text-sm';
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// =============================================================================
|
||
|
|
// RENDER FUNCTIONS
|
||
|
|
// =============================================================================
|
||
|
|
|
||
|
|
const renderDefaultOption = (option: SelectOption) => (
|
||
|
|
<SelectItem
|
||
|
|
key={option.value}
|
||
|
|
value={String(option.value)}
|
||
|
|
disabled={option.disabled}
|
||
|
|
>
|
||
|
|
{option.label}
|
||
|
|
</SelectItem>
|
||
|
|
);
|
||
|
|
|
||
|
|
const renderDefaultTrigger = (field: any, fieldState: { error?: FieldError }) => (
|
||
|
|
<SelectTrigger
|
||
|
|
className={cn(
|
||
|
|
'w-full transition-colors',
|
||
|
|
getSelectSize(size),
|
||
|
|
fieldState.error && 'border-destructive focus:border-destructive',
|
||
|
|
selectClassName
|
||
|
|
)}
|
||
|
|
disabled={disabled || loading}
|
||
|
|
>
|
||
|
|
<SelectValue placeholder={loading ? 'Loading...' : placeholder} />
|
||
|
|
</SelectTrigger>
|
||
|
|
);
|
||
|
|
|
||
|
|
// =============================================================================
|
||
|
|
// MAIN RENDER
|
||
|
|
// =============================================================================
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className={cn('space-y-2', className)}>
|
||
|
|
{/* Label */}
|
||
|
|
{label && (
|
||
|
|
<Label
|
||
|
|
htmlFor={name}
|
||
|
|
className={cn(
|
||
|
|
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||
|
|
required && 'after:content-["*"] after:ml-0.5 after:text-destructive',
|
||
|
|
labelClassName
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
{label}
|
||
|
|
</Label>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Controller */}
|
||
|
|
<Controller
|
||
|
|
control={control}
|
||
|
|
name={name}
|
||
|
|
rules={{
|
||
|
|
required: validation?.required || required,
|
||
|
|
}}
|
||
|
|
render={({ field, fieldState }) => (
|
||
|
|
<div className="space-y-1">
|
||
|
|
<Select
|
||
|
|
onValueChange={field.onChange}
|
||
|
|
defaultValue={field.value}
|
||
|
|
disabled={disabled || loading}
|
||
|
|
>
|
||
|
|
{renderTrigger ?
|
||
|
|
renderTrigger({ field, fieldState }) :
|
||
|
|
renderDefaultTrigger(field, fieldState)
|
||
|
|
}
|
||
|
|
|
||
|
|
<SelectContent>
|
||
|
|
{loading ? (
|
||
|
|
<SelectItem value="loading" disabled>
|
||
|
|
Loading...
|
||
|
|
</SelectItem>
|
||
|
|
) : (
|
||
|
|
options.map((option) =>
|
||
|
|
renderOption ?
|
||
|
|
renderOption(option) :
|
||
|
|
renderDefaultOption(option)
|
||
|
|
)
|
||
|
|
)}
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
|
||
|
|
{/* Error Message */}
|
||
|
|
{fieldState.error && (
|
||
|
|
<p className={cn(
|
||
|
|
'text-sm text-destructive',
|
||
|
|
errorClassName
|
||
|
|
)}>
|
||
|
|
{fieldState.error.message}
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export default FormSelect;
|