210 lines
5.6 KiB
TypeScript
210 lines
5.6 KiB
TypeScript
/**
|
|
* FormRadio Component
|
|
*
|
|
* A reusable radio button component that handles:
|
|
* - Radio button groups
|
|
* - Label
|
|
* - Controller (react-hook-form)
|
|
* - Error handling
|
|
*
|
|
* This component reduces code duplication for radio button fields across forms.
|
|
*/
|
|
|
|
import React from 'react';
|
|
import { Control, Controller, FieldError, FieldPath, FieldValues } from 'react-hook-form';
|
|
import { Label } from '@/components/ui/label';
|
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
// =============================================================================
|
|
// TYPES
|
|
// =============================================================================
|
|
|
|
export interface RadioOption {
|
|
value: string | number;
|
|
label: string;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
export interface FormRadioProps<
|
|
TFieldValues extends FieldValues = FieldValues,
|
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
|
> {
|
|
// Form control
|
|
control: Control<TFieldValues>;
|
|
name: TName;
|
|
|
|
// Field configuration
|
|
label?: string;
|
|
required?: boolean;
|
|
|
|
// Radio configuration
|
|
options: RadioOption[];
|
|
|
|
// Styling
|
|
className?: string;
|
|
labelClassName?: string;
|
|
radioClassName?: string;
|
|
errorClassName?: string;
|
|
groupClassName?: string;
|
|
|
|
// Layout
|
|
layout?: 'horizontal' | 'vertical';
|
|
columns?: number; // For grid layout
|
|
|
|
// Validation
|
|
validation?: {
|
|
required?: boolean | string;
|
|
};
|
|
|
|
// Additional props
|
|
disabled?: boolean;
|
|
size?: 'sm' | 'md' | 'lg';
|
|
|
|
// Custom render functions
|
|
renderOption?: (option: RadioOption, checked: boolean, onChange: (value: string) => void) => React.ReactElement;
|
|
}
|
|
|
|
// =============================================================================
|
|
// COMPONENT
|
|
// =============================================================================
|
|
|
|
export function FormRadio<
|
|
TFieldValues extends FieldValues = FieldValues,
|
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
|
>({
|
|
control,
|
|
name,
|
|
label,
|
|
required = false,
|
|
options,
|
|
className = '',
|
|
labelClassName = '',
|
|
radioClassName = '',
|
|
errorClassName = '',
|
|
groupClassName = '',
|
|
layout = 'horizontal',
|
|
columns = 1,
|
|
validation,
|
|
disabled = false,
|
|
size = 'md',
|
|
renderOption,
|
|
}: FormRadioProps<TFieldValues, TName>) {
|
|
|
|
// =============================================================================
|
|
// HELPER FUNCTIONS
|
|
// =============================================================================
|
|
|
|
const getRadioSize = (size: 'sm' | 'md' | 'lg') => {
|
|
switch (size) {
|
|
case 'sm':
|
|
return 'h-4 w-4';
|
|
case 'lg':
|
|
return 'h-6 w-6';
|
|
default:
|
|
return 'h-5 w-5';
|
|
}
|
|
};
|
|
|
|
const getGroupLayout = () => {
|
|
if (layout === 'vertical') {
|
|
return 'flex flex-col gap-2';
|
|
}
|
|
|
|
if (columns > 1) {
|
|
return `grid grid-cols-${columns} gap-2`;
|
|
}
|
|
|
|
return 'flex flex-wrap gap-3';
|
|
};
|
|
|
|
// =============================================================================
|
|
// RENDER FUNCTIONS
|
|
// =============================================================================
|
|
|
|
const renderDefaultOption = (option: RadioOption, checked: boolean, onChange: (value: string) => void) => {
|
|
if (renderOption) {
|
|
return renderOption(option, checked, onChange);
|
|
}
|
|
|
|
return (
|
|
<div key={option.value} className="flex items-center space-x-2">
|
|
<RadioGroupItem
|
|
value={String(option.value)}
|
|
id={`${name}-${option.value}`}
|
|
disabled={disabled || option.disabled}
|
|
className={cn(getRadioSize(size), radioClassName)}
|
|
/>
|
|
<Label
|
|
htmlFor={`${name}-${option.value}`}
|
|
className={cn(
|
|
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
|
labelClassName
|
|
)}
|
|
>
|
|
{option.label}
|
|
</Label>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// =============================================================================
|
|
// MAIN RENDER
|
|
// =============================================================================
|
|
|
|
return (
|
|
<div className={cn('space-y-2', className)}>
|
|
{/* Group Label */}
|
|
{label && (
|
|
<Label
|
|
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">
|
|
<RadioGroup
|
|
value={field.value}
|
|
onValueChange={field.onChange}
|
|
disabled={disabled}
|
|
className={cn(getGroupLayout(), groupClassName)}
|
|
>
|
|
{options.map((option) =>
|
|
renderDefaultOption(
|
|
option,
|
|
field.value === String(option.value),
|
|
field.onChange
|
|
)
|
|
)}
|
|
</RadioGroup>
|
|
|
|
{/* Error Message */}
|
|
{fieldState.error && (
|
|
<p className={cn(
|
|
'text-sm text-destructive',
|
|
errorClassName
|
|
)}>
|
|
{fieldState.error.message}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default FormRadio;
|