228 lines
6.0 KiB
TypeScript
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;
|