211 lines
5.0 KiB
TypeScript
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,
|
|
};
|
|
} |