244 lines
6.7 KiB
TypeScript
244 lines
6.7 KiB
TypeScript
/**
|
|
* FormCheckbox Component
|
|
*
|
|
* A reusable checkbox component that handles:
|
|
* - Single checkbox
|
|
* - Checkbox groups
|
|
* - Label
|
|
* - Controller (react-hook-form)
|
|
* - Error handling
|
|
*
|
|
* This component reduces code duplication for checkbox fields across forms.
|
|
*/
|
|
|
|
import React from 'react';
|
|
import { Control, Controller, FieldError, FieldPath, FieldValues } from 'react-hook-form';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
// =============================================================================
|
|
// TYPES
|
|
// =============================================================================
|
|
|
|
export interface CheckboxOption {
|
|
value: string | number;
|
|
label: string;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
export interface FormCheckboxProps<
|
|
TFieldValues extends FieldValues = FieldValues,
|
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
|
> {
|
|
// Form control
|
|
control: Control<TFieldValues>;
|
|
name: TName;
|
|
|
|
// Field configuration
|
|
label?: string;
|
|
required?: boolean;
|
|
|
|
// Checkbox configuration
|
|
single?: boolean; // Single checkbox vs checkbox group
|
|
options?: CheckboxOption[]; // For checkbox groups
|
|
|
|
// Styling
|
|
className?: string;
|
|
labelClassName?: string;
|
|
checkboxClassName?: 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: CheckboxOption, checked: boolean, onChange: (checked: boolean) => void) => React.ReactElement;
|
|
}
|
|
|
|
// =============================================================================
|
|
// COMPONENT
|
|
// =============================================================================
|
|
|
|
export function FormCheckbox<
|
|
TFieldValues extends FieldValues = FieldValues,
|
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
|
>({
|
|
control,
|
|
name,
|
|
label,
|
|
required = false,
|
|
single = false,
|
|
options = [],
|
|
className = '',
|
|
labelClassName = '',
|
|
checkboxClassName = '',
|
|
errorClassName = '',
|
|
groupClassName = '',
|
|
layout = 'horizontal',
|
|
columns = 1,
|
|
validation,
|
|
disabled = false,
|
|
size = 'md',
|
|
renderOption,
|
|
}: FormCheckboxProps<TFieldValues, TName>) {
|
|
|
|
// =============================================================================
|
|
// HELPER FUNCTIONS
|
|
// =============================================================================
|
|
|
|
const getCheckboxSize = (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-4';
|
|
};
|
|
|
|
// =============================================================================
|
|
// RENDER FUNCTIONS
|
|
// =============================================================================
|
|
|
|
const renderSingleCheckbox = (field: any, fieldState: { error?: FieldError }) => (
|
|
<div className={cn('flex items-center space-x-2', checkboxClassName)}>
|
|
<Checkbox
|
|
id={name}
|
|
checked={field.value}
|
|
onCheckedChange={field.onChange}
|
|
disabled={disabled}
|
|
className={getCheckboxSize(size)}
|
|
/>
|
|
<Label
|
|
htmlFor={name}
|
|
className={cn(
|
|
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
|
labelClassName
|
|
)}
|
|
>
|
|
{label}
|
|
</Label>
|
|
</div>
|
|
);
|
|
|
|
const renderCheckboxGroup = (field: any, fieldState: { error?: FieldError }) => {
|
|
const selectedValues = field.value || [];
|
|
|
|
const handleOptionChange = (optionValue: string | number, checked: boolean) => {
|
|
const newValues = checked
|
|
? [...selectedValues, optionValue]
|
|
: selectedValues.filter((value: any) => value !== optionValue);
|
|
field.onChange(newValues);
|
|
};
|
|
|
|
const renderDefaultOption = (option: CheckboxOption) => {
|
|
const checked = selectedValues.includes(option.value);
|
|
|
|
if (renderOption) {
|
|
return renderOption(option, checked, (checked) => handleOptionChange(option.value, checked));
|
|
}
|
|
|
|
return (
|
|
<div key={option.value} className="flex items-center space-x-2">
|
|
<Checkbox
|
|
id={`${name}-${option.value}`}
|
|
checked={checked}
|
|
onCheckedChange={(checked) => handleOptionChange(option.value, checked as boolean)}
|
|
disabled={disabled || option.disabled}
|
|
className={getCheckboxSize(size)}
|
|
/>
|
|
<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>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className={cn(getGroupLayout(), groupClassName)}>
|
|
{options.map(renderDefaultOption)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// =============================================================================
|
|
// MAIN RENDER
|
|
// =============================================================================
|
|
|
|
return (
|
|
<div className={cn('space-y-2', className)}>
|
|
{/* Group Label */}
|
|
{label && !single && (
|
|
<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">
|
|
{single ?
|
|
renderSingleCheckbox(field, fieldState) :
|
|
renderCheckboxGroup(field, fieldState)
|
|
}
|
|
|
|
{/* Error Message */}
|
|
{fieldState.error && (
|
|
<p className={cn(
|
|
'text-sm text-destructive',
|
|
errorClassName
|
|
)}>
|
|
{fieldState.error.message}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default FormCheckbox;
|