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

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;