mediahub-fe/components/form/shared/form-select.tsx

215 lines
5.6 KiB
TypeScript
Raw Normal View History

/**
* 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;