Files
gh-jezweb-claude-skills-ski…/templates/custom-error-display.tsx
2025-11-30 08:25:27 +08:00

304 lines
9.7 KiB
TypeScript

/**
* Custom Error Display Example
*
* Demonstrates:
* - Custom error component
* - Error summary at top of form
* - Toast notifications for errors
* - Inline vs summary error display
* - Accessible error announcements
* - Icon-based error styling
*/
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useEffect, useState } from 'react'
const formSchema = z.object({
username: z.string().min(3, 'Username must be at least 3 characters'),
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
age: z.number().min(18, 'You must be at least 18 years old'),
})
type FormData = z.infer<typeof formSchema>
/**
* Custom Error Component
*/
function FormError({ message, icon = true }: { message: string; icon?: boolean }) {
return (
<div role="alert" className="flex items-start gap-2 text-sm text-red-600 mt-1">
{icon && (
<svg className="w-4 h-4 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
)}
<span>{message}</span>
</div>
)
}
/**
* Error Summary Component
*/
function ErrorSummary({ errors }: { errors: Record<string, any> }) {
const errorEntries = Object.entries(errors).filter(([key, value]) => value?.message)
if (errorEntries.length === 0) return null
return (
<div
role="alert"
aria-live="assertive"
className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6"
>
<div className="flex items-center gap-2 mb-2">
<svg className="w-5 h-5 text-red-600" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
<h3 className="font-medium text-red-900">
{errorEntries.length} {errorEntries.length === 1 ? 'Error' : 'Errors'} Found
</h3>
</div>
<ul className="list-disc list-inside space-y-1 text-sm text-red-700">
{errorEntries.map(([field, error]) => (
<li key={field}>
<strong className="capitalize">{field}:</strong> {error.message}
</li>
))}
</ul>
</div>
)
}
/**
* Toast Notification for Errors
*/
function ErrorToast({ message, onClose }: { message: string; onClose: () => void }) {
useEffect(() => {
const timer = setTimeout(onClose, 5000)
return () => clearTimeout(timer)
}, [onClose])
return (
<div className="fixed bottom-4 right-4 bg-red-600 text-white px-6 py-4 rounded-lg shadow-lg flex items-start gap-3 max-w-sm animate-slide-in">
<svg className="w-6 h-6 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
<div className="flex-1">
<h4 className="font-medium">Validation Error</h4>
<p className="text-sm mt-1">{message}</p>
</div>
<button
onClick={onClose}
className="text-white hover:text-gray-200"
aria-label="Close notification"
>
</button>
</div>
)
}
/**
* Form with Custom Error Display
*/
export function CustomErrorDisplayForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
username: '',
email: '',
password: '',
age: 18,
},
})
const [toastMessage, setToastMessage] = useState<string | null>(null)
const onSubmit = async (data: FormData) => {
console.log('Form data:', data)
setToastMessage('Form submitted successfully!')
}
const onError = (errors: any) => {
// Show toast on validation error
const errorCount = Object.keys(errors).length
setToastMessage(`Please fix ${errorCount} error${errorCount > 1 ? 's' : ''} before submitting`)
}
return (
<div className="max-w-2xl mx-auto">
<form onSubmit={handleSubmit(onSubmit, onError)} className="space-y-6">
<h2 className="text-2xl font-bold">Registration Form</h2>
{/* Error Summary */}
<ErrorSummary errors={errors} />
{/* Username */}
<div>
<label htmlFor="username" className="block text-sm font-medium mb-1">
Username *
</label>
<input
id="username"
{...register('username')}
aria-invalid={errors.username ? 'true' : 'false'}
aria-describedby={errors.username ? 'username-error' : undefined}
className={`w-full px-3 py-2 border rounded-md ${
errors.username ? 'border-red-500 focus:ring-red-500' : 'border-gray-300'
}`}
/>
{errors.username && (
<FormError message={errors.username.message!} />
)}
</div>
{/* Email */}
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email *
</label>
<input
id="email"
type="email"
{...register('email')}
aria-invalid={errors.email ? 'true' : 'false'}
className={`w-full px-3 py-2 border rounded-md ${
errors.email ? 'border-red-500' : 'border-gray-300'
}`}
/>
{errors.email && (
<FormError message={errors.email.message!} />
)}
</div>
{/* Password */}
<div>
<label htmlFor="password" className="block text-sm font-medium mb-1">
Password *
</label>
<input
id="password"
type="password"
{...register('password')}
aria-invalid={errors.password ? 'true' : 'false'}
className={`w-full px-3 py-2 border rounded-md ${
errors.password ? 'border-red-500' : 'border-gray-300'
}`}
/>
{errors.password && (
<FormError message={errors.password.message!} />
)}
</div>
{/* Age */}
<div>
<label htmlFor="age" className="block text-sm font-medium mb-1">
Age *
</label>
<input
id="age"
type="number"
{...register('age', { valueAsNumber: true })}
aria-invalid={errors.age ? 'true' : 'false'}
className={`w-full px-3 py-2 border rounded-md ${
errors.age ? 'border-red-500' : 'border-gray-300'
}`}
/>
{errors.age && (
<FormError message={errors.age.message!} />
)}
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400"
>
{isSubmitting ? 'Submitting...' : 'Submit'}
</button>
</form>
{/* Toast Notification */}
{toastMessage && (
<ErrorToast message={toastMessage} onClose={() => setToastMessage(null)} />
)}
<style>{`
@keyframes slide-in {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.animate-slide-in {
animation: slide-in 0.3s ease-out;
}
`}</style>
</div>
)
}
/**
* Alternative: Grouped Error Display
*/
export function GroupedErrorDisplayForm() {
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(formSchema),
})
return (
<form onSubmit={handleSubmit((data) => console.log(data))} className="max-w-2xl mx-auto space-y-6">
<h2 className="text-2xl font-bold">Grouped Error Display</h2>
{/* All errors in single container */}
{Object.keys(errors).length > 0 && (
<div className="bg-red-50 border-l-4 border-red-600 p-4">
<h3 className="font-medium text-red-900 mb-2">Please correct the following:</h3>
<div className="space-y-2">
{Object.entries(errors).map(([field, error]) => (
<div key={field} className="flex items-start gap-2 text-sm text-red-700">
<span className="font-medium capitalize">{field}:</span>
<span>{error.message}</span>
</div>
))}
</div>
</div>
)}
{/* Form fields without individual error messages */}
<input {...register('username')} placeholder="Username" className="w-full px-3 py-2 border rounded" />
<input {...register('email')} placeholder="Email" className="w-full px-3 py-2 border rounded" />
<input {...register('password')} type="password" placeholder="Password" className="w-full px-3 py-2 border rounded" />
<input {...register('age', { valueAsNumber: true })} type="number" placeholder="Age" className="w-full px-3 py-2 border rounded" />
<button type="submit" className="w-full px-4 py-2 bg-blue-600 text-white rounded">
Submit
</button>
</form>
)
}