mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-24 12:31:14 -05:00
Enhance FormFieldWrapper with blur validation and toasts
Adds validation on blur mode to FormFieldWrapper, introduces animated validation states, and implements standardized form submission toasts via a new formToasts helper; updates ParkForm and RideForm to use the new toast system and to propagate error state with scroll-to-error support.
This commit is contained in:
@@ -75,6 +75,12 @@ interface FormFieldWrapperProps {
|
||||
|
||||
/** Hide automatic hint */
|
||||
hideHint?: boolean;
|
||||
|
||||
/** When to show validation feedback */
|
||||
validationMode?: 'realtime' | 'onBlur';
|
||||
|
||||
/** Callback when field is blurred (for onBlur mode) */
|
||||
onBlur?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -192,7 +198,10 @@ export function FormFieldWrapper({
|
||||
textareaProps,
|
||||
className,
|
||||
hideHint = false,
|
||||
validationMode = 'realtime',
|
||||
onBlur,
|
||||
}: FormFieldWrapperProps) {
|
||||
const [hasBlurred, setHasBlurred] = React.useState(false);
|
||||
const isTextarea = fieldType === 'textarea' || fieldType === 'submission-notes';
|
||||
const autoHint = getAutoHint(fieldType);
|
||||
const displayHint = hint || autoHint;
|
||||
@@ -203,12 +212,27 @@ export function FormFieldWrapper({
|
||||
const charCount = typeof value === 'string' ? value.length : 0;
|
||||
|
||||
// Determine validation state
|
||||
const shouldShowValidation = validationMode === 'realtime' || (validationMode === 'onBlur' && hasBlurred);
|
||||
const hasValue = value !== undefined && value !== null && value !== '';
|
||||
const isValid = !error && hasValue;
|
||||
const hasError = !!error;
|
||||
const isValid = shouldShowValidation && !error && hasValue;
|
||||
const hasError = shouldShowValidation && !!error;
|
||||
|
||||
// Blur handler
|
||||
const handleBlur = (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
setHasBlurred(true);
|
||||
if (validationMode === 'onBlur' && onBlur) {
|
||||
onBlur();
|
||||
}
|
||||
// Call original onBlur if provided
|
||||
if ('value' in e.target && textareaProps?.onBlur) {
|
||||
textareaProps.onBlur(e as React.FocusEvent<HTMLTextAreaElement>);
|
||||
} else if (inputProps?.onBlur) {
|
||||
inputProps.onBlur(e as React.FocusEvent<HTMLInputElement>);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2", className)}>
|
||||
<div className={cn("space-y-2", className)} data-error={hasError ? "true" : undefined}>
|
||||
{/* Label with optional terminology tooltip */}
|
||||
<Label htmlFor={id} className="flex items-center gap-2">
|
||||
{termKey ? (
|
||||
@@ -232,11 +256,13 @@ export function FormFieldWrapper({
|
||||
<Textarea
|
||||
id={id}
|
||||
className={cn(
|
||||
"pr-10",
|
||||
error && "border-destructive",
|
||||
isValid && "border-green-500/50"
|
||||
"pr-10 transition-all duration-300 ease-in-out",
|
||||
"focus:ring-2 focus:ring-primary/20 focus:border-primary",
|
||||
error && "border-destructive focus:ring-destructive/20",
|
||||
isValid && "border-green-500/50 focus:ring-green-500/20"
|
||||
)}
|
||||
maxLength={maxLength}
|
||||
onBlur={handleBlur}
|
||||
{...textareaProps}
|
||||
/>
|
||||
) : (
|
||||
@@ -244,23 +270,25 @@ export function FormFieldWrapper({
|
||||
id={id}
|
||||
type={inputType}
|
||||
className={cn(
|
||||
"pr-10",
|
||||
error && "border-destructive",
|
||||
isValid && "border-green-500/50"
|
||||
"pr-10 transition-all duration-300 ease-in-out",
|
||||
"focus:ring-2 focus:ring-primary/20 focus:border-primary",
|
||||
error && "border-destructive focus:ring-destructive/20",
|
||||
isValid && "border-green-500/50 focus:ring-green-500/20"
|
||||
)}
|
||||
maxLength={maxLength}
|
||||
onBlur={handleBlur}
|
||||
{...inputProps}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Validation icon */}
|
||||
{/* Validation icon with animation */}
|
||||
{(isValid || hasError) && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
|
||||
{isValid && (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500 animate-fade-in" />
|
||||
)}
|
||||
{hasError && (
|
||||
<AlertCircle className="h-4 w-4 text-destructive" />
|
||||
<AlertCircle className="h-4 w-4 text-destructive animate-fade-in" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -268,7 +296,7 @@ export function FormFieldWrapper({
|
||||
|
||||
{/* Hint text (if not hidden and exists) */}
|
||||
{!hideHint && displayHint && !error && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-xs text-muted-foreground animate-slide-in-down">
|
||||
{displayHint}
|
||||
{showCharCount && ` (${charCount}/${maxLength} characters)`}
|
||||
</p>
|
||||
@@ -281,9 +309,9 @@ export function FormFieldWrapper({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{/* Error message with animation */}
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">
|
||||
<p className="text-sm text-destructive animate-slide-in-down">
|
||||
{error}
|
||||
{showCharCount && ` (${charCount}/${maxLength})`}
|
||||
</p>
|
||||
@@ -299,6 +327,7 @@ export const formFieldPresets = {
|
||||
websiteUrl: (props: Partial<FormFieldWrapperProps>) => ({
|
||||
fieldType: 'url' as FormFieldType,
|
||||
label: 'Website URL',
|
||||
validationMode: 'onBlur',
|
||||
...props,
|
||||
}),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user