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

228 lines
6.0 KiB
TypeScript

/**
* FormField Component
*
* A reusable form field component that handles the common pattern of:
* - Label
* - Controller (react-hook-form)
* - Input/Select/Textarea
* - Error handling
*
* This component reduces code duplication across all form components.
*/
import React from 'react';
import { Control, Controller, FieldError, FieldPath, FieldValues } from 'react-hook-form';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
// =============================================================================
// TYPES
// =============================================================================
export interface FormFieldProps<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> {
// Form control
control: Control<TFieldValues>;
name: TName;
// Field configuration
label?: string;
placeholder?: string;
type?: 'text' | 'email' | 'password' | 'number' | 'tel' | 'url';
required?: boolean;
// Field type
fieldType?: 'input' | 'textarea' | 'select';
// Styling
className?: string;
labelClassName?: string;
inputClassName?: string;
errorClassName?: string;
// Validation
validation?: {
required?: boolean | string;
minLength?: { value: number; message: string };
maxLength?: { value: number; message: string };
pattern?: { value: RegExp; message: string };
};
// Additional props
disabled?: boolean;
readOnly?: boolean;
autoComplete?: string;
size?: 'sm' | 'md' | 'lg';
// Custom render function
render?: (props: {
field: any;
fieldState: { error?: FieldError };
}) => React.ReactElement;
}
// =============================================================================
// COMPONENT
// =============================================================================
export function FormField<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
control,
name,
label,
placeholder,
type = 'text',
required = false,
fieldType = 'input',
className = '',
labelClassName = '',
inputClassName = '',
errorClassName = '',
validation,
disabled = false,
readOnly = false,
autoComplete,
size = 'md',
render,
}: FormFieldProps<TFieldValues, TName>) {
// =============================================================================
// HELPER FUNCTIONS
// =============================================================================
const getInputSize = (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';
}
};
const getTextareaSize = (size: 'sm' | 'md' | 'lg') => {
switch (size) {
case 'sm':
return 'min-h-[80px] text-sm';
case 'lg':
return 'min-h-[120px] text-base';
default:
return 'min-h-[100px] text-sm';
}
};
// =============================================================================
// RENDER FUNCTIONS
// =============================================================================
const renderInput = (field: any, fieldState: { error?: FieldError }) => {
const inputProps = {
...field,
type,
placeholder,
disabled,
readOnly,
autoComplete,
className: cn(
'w-full transition-colors',
getInputSize(size),
fieldState.error && 'border-destructive focus:border-destructive',
inputClassName
),
};
return <Input {...inputProps} />;
};
const renderTextarea = (field: any, fieldState: { error?: FieldError }) => {
const textareaProps = {
...field,
placeholder,
disabled,
readOnly,
autoComplete,
className: cn(
'w-full resize-none transition-colors',
getTextareaSize(size),
fieldState.error && 'border-destructive focus:border-destructive',
inputClassName
),
};
return <Textarea {...textareaProps} />;
};
const renderField = (field: any, fieldState: { error?: FieldError }) => {
if (render) {
return render({ field, fieldState });
}
switch (fieldType) {
case 'textarea':
return renderTextarea(field, fieldState);
case 'select':
// Select component will be handled by FormSelect component
return renderInput(field, fieldState);
default:
return renderInput(field, fieldState);
}
};
// =============================================================================
// 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,
minLength: validation?.minLength,
maxLength: validation?.maxLength,
pattern: validation?.pattern,
}}
render={({ field, fieldState }) => (
<div className="space-y-1">
{renderField(field, fieldState)}
{/* Error Message */}
{fieldState.error && (
<p className={cn(
'text-sm text-destructive',
errorClassName
)}>
{fieldState.error.message}
</p>
)}
</div>
)}
/>
</div>
);
}
export default FormField;