Files
klz-cables.com/components/forms/hooks/useFormField.ts
2025-12-29 18:18:48 +01:00

211 lines
5.0 KiB
TypeScript

import { useState, useCallback, ChangeEvent } from 'react';
/**
* Hook for managing individual form field state
*/
export interface FormFieldState<T> {
value: T;
error: string | null;
touched: boolean;
dirty: boolean;
isValid: boolean;
}
export interface FormFieldOptions<T> {
initialValue?: T;
validate?: (value: T) => string | null;
transform?: (value: T) => T;
}
export interface FormFieldReturn<T> {
value: T;
error: string | null;
touched: boolean;
dirty: boolean;
isValid: boolean;
handleChange: (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => void;
setValue: (value: T) => void;
setError: (error: string | null) => void;
setTouched: (touched: boolean) => void;
reset: () => void;
clearError: () => void;
}
/**
* Hook for managing individual form field state with validation
*/
export function useFormField<T = string>(
options: FormFieldOptions<T> = {}
): FormFieldReturn<T> {
const {
initialValue = '' as unknown as T,
validate,
transform,
} = options;
const [state, setState] = useState<FormFieldState<T>>({
value: initialValue,
error: null,
touched: false,
dirty: false,
isValid: true,
});
const validateValue = useCallback((value: T): string | null => {
if (validate) {
return validate(value);
}
return null;
}, [validate]);
const updateState = useCallback((newState: Partial<FormFieldState<T>>) => {
setState((prev) => {
const updated = { ...prev, ...newState };
// Auto-validate if value changes and validation is provided
if ('value' in newState && validate) {
const error = validateValue(newState.value as T);
updated.error = error;
updated.isValid = !error;
}
return updated;
});
}, [validate, validateValue]);
const handleChange = useCallback(
(e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
let value: any = e.target.value;
// Handle different input types
if (e.target.type === 'checkbox') {
value = (e.target as HTMLInputElement).checked;
} else if (e.target.type === 'number') {
value = e.target.value === '' ? '' : Number(e.target.value);
}
// Apply transformation if provided
if (transform) {
value = transform(value);
}
setState((prev) => ({
...prev,
value,
dirty: true,
touched: true,
}));
},
[transform]
);
const setValue = useCallback((value: T) => {
setState((prev) => ({
...prev,
value,
dirty: true,
touched: true,
}));
}, []);
const setError = useCallback((error: string | null) => {
setState((prev) => ({
...prev,
error,
isValid: !error,
}));
}, []);
const setTouched = useCallback((touched: boolean) => {
setState((prev) => ({
...prev,
touched,
}));
}, []);
const clearError = useCallback(() => {
setState((prev) => ({
...prev,
error: null,
isValid: true,
}));
}, []);
const reset = useCallback(() => {
setState({
value: initialValue,
error: null,
touched: false,
dirty: false,
isValid: true,
});
}, [initialValue]);
// Auto-validate on mount if initial value exists
// This ensures initial values are validated
// Note: We're intentionally not adding initialValue to dependencies
// to avoid infinite loops, but we validate once on mount
// This is handled by the updateState function when value changes
return {
value: state.value,
error: state.error,
touched: state.touched,
dirty: state.dirty,
isValid: state.isValid,
handleChange,
setValue,
setError,
setTouched,
reset,
clearError,
};
}
/**
* Hook for managing form field state with additional utilities
*/
export function useFormFieldWithHelpers<T = string>(
options: FormFieldOptions<T> & {
label?: string;
required?: boolean;
helpText?: string;
} = {}
) {
const field = useFormField<T>(options);
const hasError = field.error !== null;
const showError = field.touched && hasError;
const showSuccess = field.touched && !hasError && field.dirty;
const getAriaDescribedBy = () => {
const descriptions: string[] = [];
if (options.helpText) descriptions.push(`${options.label || 'field'}-help`);
if (field.error) descriptions.push(`${options.label || 'field'}-error`);
return descriptions.length > 0 ? descriptions.join(' ') : undefined;
};
const getInputProps = () => ({
value: field.value as any,
onChange: field.handleChange,
'aria-invalid': hasError,
'aria-describedby': getAriaDescribedBy(),
'aria-required': options.required,
});
const getLabelProps = () => ({
htmlFor: options.label?.toLowerCase().replace(/\s+/g, '-'),
required: options.required,
});
return {
...field,
hasError,
showError,
showSuccess,
getInputProps,
getLabelProps,
getAriaDescribedBy,
};
}