Initial commit
This commit is contained in:
427
templates/advanced-form.tsx
Normal file
427
templates/advanced-form.tsx
Normal file
@@ -0,0 +1,427 @@
|
||||
/**
|
||||
* Advanced Form Example - User Profile with Nested Objects and Arrays
|
||||
*
|
||||
* Demonstrates:
|
||||
* - Nested object validation (address)
|
||||
* - Array field validation (skills)
|
||||
* - Conditional field validation
|
||||
* - Complex Zod schemas with refinements
|
||||
* - Type-safe nested error handling
|
||||
*/
|
||||
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
|
||||
// Define nested schemas
|
||||
const addressSchema = z.object({
|
||||
street: z.string().min(1, 'Street is required'),
|
||||
city: z.string().min(1, 'City is required'),
|
||||
state: z.string().min(2, 'State must be at least 2 characters'),
|
||||
zipCode: z.string().regex(/^\d{5}(-\d{4})?$/, 'Invalid ZIP code format'),
|
||||
country: z.string().min(1, 'Country is required'),
|
||||
})
|
||||
|
||||
// Complex schema with nested objects and arrays
|
||||
const profileSchema = z.object({
|
||||
// Basic fields
|
||||
firstName: z.string().min(2, 'First name must be at least 2 characters'),
|
||||
lastName: z.string().min(2, 'Last name must be at least 2 characters'),
|
||||
email: z.string().email('Invalid email address'),
|
||||
phone: z.string().regex(/^\+?[1-9]\d{1,14}$/, 'Invalid phone number').optional(),
|
||||
|
||||
// Nested object
|
||||
address: addressSchema,
|
||||
|
||||
// Array of strings
|
||||
skills: z.array(z.string().min(1, 'Skill cannot be empty'))
|
||||
.min(1, 'At least one skill is required')
|
||||
.max(10, 'Maximum 10 skills allowed'),
|
||||
|
||||
// Conditional fields
|
||||
isStudent: z.boolean(),
|
||||
school: z.string().optional(),
|
||||
graduationYear: z.number().int().min(1900).max(2100).optional(),
|
||||
|
||||
// Enum
|
||||
experience: z.enum(['junior', 'mid', 'senior', 'lead'], {
|
||||
errorMap: () => ({ message: 'Please select experience level' }),
|
||||
}),
|
||||
|
||||
// Number with constraints
|
||||
yearsOfExperience: z.number()
|
||||
.int('Must be a whole number')
|
||||
.min(0, 'Cannot be negative')
|
||||
.max(50, 'Must be 50 or less'),
|
||||
|
||||
// Date
|
||||
availableFrom: z.date().optional(),
|
||||
|
||||
// Boolean
|
||||
agreedToTerms: z.boolean().refine((val) => val === true, {
|
||||
message: 'You must agree to the terms and conditions',
|
||||
}),
|
||||
})
|
||||
.refine((data) => {
|
||||
// Conditional validation: if isStudent is true, school is required
|
||||
if (data.isStudent && !data.school) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}, {
|
||||
message: 'School is required for students',
|
||||
path: ['school'],
|
||||
})
|
||||
.refine((data) => {
|
||||
// Experience level should match years of experience
|
||||
if (data.experience === 'senior' && data.yearsOfExperience < 5) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}, {
|
||||
message: 'Senior level requires at least 5 years of experience',
|
||||
path: ['yearsOfExperience'],
|
||||
})
|
||||
|
||||
type ProfileFormData = z.infer<typeof profileSchema>
|
||||
|
||||
export function AdvancedProfileForm() {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
formState: { errors, isSubmitting },
|
||||
setValue,
|
||||
} = useForm<ProfileFormData>({
|
||||
resolver: zodResolver(profileSchema),
|
||||
defaultValues: {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
address: {
|
||||
street: '',
|
||||
city: '',
|
||||
state: '',
|
||||
zipCode: '',
|
||||
country: 'USA',
|
||||
},
|
||||
skills: [''], // Start with one empty skill
|
||||
isStudent: false,
|
||||
school: '',
|
||||
experience: 'junior',
|
||||
yearsOfExperience: 0,
|
||||
agreedToTerms: false,
|
||||
},
|
||||
})
|
||||
|
||||
// Watch isStudent to conditionally show school field
|
||||
const isStudent = watch('isStudent')
|
||||
|
||||
const onSubmit = async (data: ProfileFormData) => {
|
||||
console.log('Profile data:', data)
|
||||
// API call
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6 max-w-2xl mx-auto">
|
||||
<h2 className="text-3xl font-bold">User Profile</h2>
|
||||
|
||||
{/* Basic Information */}
|
||||
<section className="space-y-4">
|
||||
<h3 className="text-xl font-semibold">Basic Information</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="firstName" className="block text-sm font-medium mb-1">
|
||||
First Name *
|
||||
</label>
|
||||
<input
|
||||
id="firstName"
|
||||
{...register('firstName')}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
/>
|
||||
{errors.firstName && (
|
||||
<span role="alert" className="text-sm text-red-600 mt-1 block">
|
||||
{errors.firstName.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="lastName" className="block text-sm font-medium mb-1">
|
||||
Last Name *
|
||||
</label>
|
||||
<input
|
||||
id="lastName"
|
||||
{...register('lastName')}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
/>
|
||||
{errors.lastName && (
|
||||
<span role="alert" className="text-sm text-red-600 mt-1 block">
|
||||
{errors.lastName.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium mb-1">
|
||||
Email *
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
{...register('email')}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
/>
|
||||
{errors.email && (
|
||||
<span role="alert" className="text-sm text-red-600 mt-1 block">
|
||||
{errors.email.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="phone" className="block text-sm font-medium mb-1">
|
||||
Phone (Optional)
|
||||
</label>
|
||||
<input
|
||||
id="phone"
|
||||
type="tel"
|
||||
{...register('phone')}
|
||||
placeholder="+1234567890"
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
/>
|
||||
{errors.phone && (
|
||||
<span role="alert" className="text-sm text-red-600 mt-1 block">
|
||||
{errors.phone.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Address (Nested Object) */}
|
||||
<section className="space-y-4">
|
||||
<h3 className="text-xl font-semibold">Address</h3>
|
||||
|
||||
<div>
|
||||
<label htmlFor="street" className="block text-sm font-medium mb-1">
|
||||
Street *
|
||||
</label>
|
||||
<input
|
||||
id="street"
|
||||
{...register('address.street')}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
/>
|
||||
{errors.address?.street && (
|
||||
<span role="alert" className="text-sm text-red-600 mt-1 block">
|
||||
{errors.address.street.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="city" className="block text-sm font-medium mb-1">
|
||||
City *
|
||||
</label>
|
||||
<input
|
||||
id="city"
|
||||
{...register('address.city')}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
/>
|
||||
{errors.address?.city && (
|
||||
<span role="alert" className="text-sm text-red-600 mt-1 block">
|
||||
{errors.address.city.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="state" className="block text-sm font-medium mb-1">
|
||||
State *
|
||||
</label>
|
||||
<input
|
||||
id="state"
|
||||
{...register('address.state')}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
/>
|
||||
{errors.address?.state && (
|
||||
<span role="alert" className="text-sm text-red-600 mt-1 block">
|
||||
{errors.address.state.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="zipCode" className="block text-sm font-medium mb-1">
|
||||
ZIP Code *
|
||||
</label>
|
||||
<input
|
||||
id="zipCode"
|
||||
{...register('address.zipCode')}
|
||||
placeholder="12345 or 12345-6789"
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
/>
|
||||
{errors.address?.zipCode && (
|
||||
<span role="alert" className="text-sm text-red-600 mt-1 block">
|
||||
{errors.address.zipCode.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="country" className="block text-sm font-medium mb-1">
|
||||
Country *
|
||||
</label>
|
||||
<input
|
||||
id="country"
|
||||
{...register('address.country')}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
/>
|
||||
{errors.address?.country && (
|
||||
<span role="alert" className="text-sm text-red-600 mt-1 block">
|
||||
{errors.address.country.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Skills (Array - simplified for advanced-form, see dynamic-fields.tsx for full array handling) */}
|
||||
<section className="space-y-4">
|
||||
<h3 className="text-xl font-semibold">Skills</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Enter skills separated by commas (handled as string for simplicity in this example)
|
||||
</p>
|
||||
<div>
|
||||
<label htmlFor="skills" className="block text-sm font-medium mb-1">
|
||||
Skills (comma-separated) *
|
||||
</label>
|
||||
<input
|
||||
id="skills"
|
||||
{...register('skills.0')} // Simplified - see dynamic-fields.tsx for proper array handling
|
||||
placeholder="React, TypeScript, Node.js"
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
/>
|
||||
{errors.skills && (
|
||||
<span role="alert" className="text-sm text-red-600 mt-1 block">
|
||||
{errors.skills.message || errors.skills[0]?.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Experience */}
|
||||
<section className="space-y-4">
|
||||
<h3 className="text-xl font-semibold">Experience</h3>
|
||||
|
||||
<div>
|
||||
<label htmlFor="experience" className="block text-sm font-medium mb-1">
|
||||
Experience Level *
|
||||
</label>
|
||||
<select
|
||||
id="experience"
|
||||
{...register('experience')}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
>
|
||||
<option value="junior">Junior</option>
|
||||
<option value="mid">Mid-Level</option>
|
||||
<option value="senior">Senior</option>
|
||||
<option value="lead">Lead</option>
|
||||
</select>
|
||||
{errors.experience && (
|
||||
<span role="alert" className="text-sm text-red-600 mt-1 block">
|
||||
{errors.experience.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="yearsOfExperience" className="block text-sm font-medium mb-1">
|
||||
Years of Experience *
|
||||
</label>
|
||||
<input
|
||||
id="yearsOfExperience"
|
||||
type="number"
|
||||
{...register('yearsOfExperience', { valueAsNumber: true })}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
/>
|
||||
{errors.yearsOfExperience && (
|
||||
<span role="alert" className="text-sm text-red-600 mt-1 block">
|
||||
{errors.yearsOfExperience.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Conditional Fields */}
|
||||
<section className="space-y-4">
|
||||
<h3 className="text-xl font-semibold">Education</h3>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="isStudent"
|
||||
type="checkbox"
|
||||
{...register('isStudent')}
|
||||
className="h-4 w-4 rounded"
|
||||
/>
|
||||
<label htmlFor="isStudent" className="ml-2 text-sm">
|
||||
I am currently a student
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Conditional field - only show if isStudent is true */}
|
||||
{isStudent && (
|
||||
<div>
|
||||
<label htmlFor="school" className="block text-sm font-medium mb-1">
|
||||
School Name *
|
||||
</label>
|
||||
<input
|
||||
id="school"
|
||||
{...register('school')}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
/>
|
||||
{errors.school && (
|
||||
<span role="alert" className="text-sm text-red-600 mt-1 block">
|
||||
{errors.school.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Terms and Conditions */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-start">
|
||||
<input
|
||||
id="agreedToTerms"
|
||||
type="checkbox"
|
||||
{...register('agreedToTerms')}
|
||||
className="h-4 w-4 rounded mt-1"
|
||||
/>
|
||||
<label htmlFor="agreedToTerms" className="ml-2 text-sm">
|
||||
I agree to the terms and conditions *
|
||||
</label>
|
||||
</div>
|
||||
{errors.agreedToTerms && (
|
||||
<span role="alert" className="text-sm text-red-600 block">
|
||||
{errors.agreedToTerms.message}
|
||||
</span>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Submit Button */}
|
||||
<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 ? 'Saving...' : 'Save Profile'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
402
templates/async-validation.tsx
Normal file
402
templates/async-validation.tsx
Normal file
@@ -0,0 +1,402 @@
|
||||
/**
|
||||
* Async Validation Example
|
||||
*
|
||||
* Demonstrates:
|
||||
* - Async validation with API calls
|
||||
* - Debouncing to prevent excessive requests
|
||||
* - Loading states
|
||||
* - Error handling for async validation
|
||||
* - Request cancellation
|
||||
*/
|
||||
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
|
||||
/**
|
||||
* Pattern 1: Async Validation in Zod Schema
|
||||
*/
|
||||
const usernameSchema = z.string()
|
||||
.min(3, 'Username must be at least 3 characters')
|
||||
.max(20, 'Username must not exceed 20 characters')
|
||||
.regex(/^[a-zA-Z0-9_]+$/, 'Username can only contain letters, numbers, and underscores')
|
||||
.refine(async (username) => {
|
||||
// Check if username is available via API
|
||||
const response = await fetch(`/api/check-username?username=${encodeURIComponent(username)}`)
|
||||
const { available } = await response.json()
|
||||
return available
|
||||
}, {
|
||||
message: 'Username is already taken',
|
||||
})
|
||||
|
||||
const signupSchemaWithAsync = z.object({
|
||||
username: usernameSchema,
|
||||
email: z.string().email('Invalid email address'),
|
||||
password: z.string().min(8, 'Password must be at least 8 characters'),
|
||||
})
|
||||
|
||||
type SignupFormData = z.infer<typeof signupSchemaWithAsync>
|
||||
|
||||
export function AsyncValidationForm() {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting, isValidating },
|
||||
} = useForm<SignupFormData>({
|
||||
resolver: zodResolver(signupSchemaWithAsync),
|
||||
mode: 'onBlur', // Validate on blur to avoid validating on every keystroke
|
||||
defaultValues: {
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = async (data: SignupFormData) => {
|
||||
console.log('Form data:', data)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 max-w-md mx-auto">
|
||||
<h2 className="text-2xl font-bold">Sign Up</h2>
|
||||
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium mb-1">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
{...register('username')}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
/>
|
||||
{isValidating && (
|
||||
<span className="text-sm text-blue-600 mt-1 block">
|
||||
Checking availability...
|
||||
</span>
|
||||
)}
|
||||
{errors.username && (
|
||||
<span role="alert" className="text-sm text-red-600 mt-1 block">
|
||||
{errors.username.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
{...register('email')}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
/>
|
||||
{errors.email && (
|
||||
<span role="alert" className="text-sm text-red-600 mt-1 block">
|
||||
{errors.email.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
{...register('password')}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
/>
|
||||
{errors.password && (
|
||||
<span role="alert" className="text-sm text-red-600 mt-1 block">
|
||||
{errors.password.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || isValidating}
|
||||
className="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400"
|
||||
>
|
||||
{isSubmitting ? 'Signing up...' : 'Sign Up'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pattern 2: Manual Async Validation with Debouncing and Cancellation
|
||||
* Better performance - more control over when validation happens
|
||||
*/
|
||||
const manualValidationSchema = z.object({
|
||||
username: z.string()
|
||||
.min(3, 'Username must be at least 3 characters')
|
||||
.max(20, 'Username must not exceed 20 characters')
|
||||
.regex(/^[a-zA-Z0-9_]+$/, 'Username can only contain letters, numbers, and underscores'),
|
||||
email: z.string().email('Invalid email address'),
|
||||
})
|
||||
|
||||
type ManualValidationData = z.infer<typeof manualValidationSchema>
|
||||
|
||||
export function DebouncedAsyncValidationForm() {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
setError,
|
||||
clearErrors,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<ManualValidationData>({
|
||||
resolver: zodResolver(manualValidationSchema),
|
||||
defaultValues: {
|
||||
username: '',
|
||||
email: '',
|
||||
},
|
||||
})
|
||||
|
||||
const [isCheckingUsername, setIsCheckingUsername] = useState(false)
|
||||
const [isCheckingEmail, setIsCheckingEmail] = useState(false)
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
const username = watch('username')
|
||||
const email = watch('email')
|
||||
|
||||
// Debounced username validation
|
||||
useEffect(() => {
|
||||
// Clear previous timeout
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
|
||||
// Skip if username is too short (already handled by Zod)
|
||||
if (!username || username.length < 3) {
|
||||
setIsCheckingUsername(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Debounce: wait 500ms after user stops typing
|
||||
timeoutRef.current = setTimeout(async () => {
|
||||
// Cancel previous request
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort()
|
||||
}
|
||||
|
||||
// Create new abort controller
|
||||
abortControllerRef.current = new AbortController()
|
||||
|
||||
setIsCheckingUsername(true)
|
||||
clearErrors('username')
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/check-username?username=${encodeURIComponent(username)}`,
|
||||
{ signal: abortControllerRef.current.signal }
|
||||
)
|
||||
|
||||
const { available } = await response.json()
|
||||
|
||||
if (!available) {
|
||||
setError('username', {
|
||||
type: 'async',
|
||||
message: 'Username is already taken',
|
||||
})
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.name !== 'AbortError') {
|
||||
console.error('Username check error:', error)
|
||||
}
|
||||
} finally {
|
||||
setIsCheckingUsername(false)
|
||||
}
|
||||
}, 500) // 500ms debounce
|
||||
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [username, setError, clearErrors])
|
||||
|
||||
// Debounced email validation
|
||||
useEffect(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
|
||||
// Basic email validation first (handled by Zod)
|
||||
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
setIsCheckingEmail(false)
|
||||
return
|
||||
}
|
||||
|
||||
timeoutRef.current = setTimeout(async () => {
|
||||
setIsCheckingEmail(true)
|
||||
clearErrors('email')
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/check-email?email=${encodeURIComponent(email)}`
|
||||
)
|
||||
|
||||
const { available } = await response.json()
|
||||
|
||||
if (!available) {
|
||||
setError('email', {
|
||||
type: 'async',
|
||||
message: 'Email is already registered',
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Email check error:', error)
|
||||
} finally {
|
||||
setIsCheckingEmail(false)
|
||||
}
|
||||
}, 500)
|
||||
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [email, setError, clearErrors])
|
||||
|
||||
const onSubmit = async (data: ManualValidationData) => {
|
||||
// Final check before submission
|
||||
if (isCheckingUsername || isCheckingEmail) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log('Form data:', data)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 max-w-md mx-auto">
|
||||
<h2 className="text-2xl font-bold">Create Account</h2>
|
||||
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium mb-1">
|
||||
Username
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="username"
|
||||
{...register('username')}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
/>
|
||||
{isCheckingUsername && (
|
||||
<div className="absolute right-3 top-2.5">
|
||||
<svg
|
||||
className="animate-spin h-5 w-5 text-blue-600"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{errors.username && (
|
||||
<span role="alert" className="text-sm text-red-600 mt-1 block">
|
||||
{errors.username.message}
|
||||
</span>
|
||||
)}
|
||||
{!errors.username && username.length >= 3 && !isCheckingUsername && (
|
||||
<span className="text-sm text-green-600 mt-1 block">
|
||||
Username is available ✓
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium mb-1">
|
||||
Email
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
{...register('email')}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
/>
|
||||
{isCheckingEmail && (
|
||||
<div className="absolute right-3 top-2.5">
|
||||
<svg
|
||||
className="animate-spin h-5 w-5 text-blue-600"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{errors.email && (
|
||||
<span role="alert" className="text-sm text-red-600 mt-1 block">
|
||||
{errors.email.message}
|
||||
</span>
|
||||
)}
|
||||
{!errors.email && email && !isCheckingEmail && (
|
||||
<span className="text-sm text-green-600 mt-1 block">
|
||||
Email is available ✓
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || isCheckingUsername || isCheckingEmail}
|
||||
className="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400"
|
||||
>
|
||||
{isSubmitting ? 'Creating account...' : 'Create Account'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock API endpoints for testing
|
||||
*/
|
||||
export async function checkUsernameAvailability(username: string): Promise<boolean> {
|
||||
// Simulate API delay
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
// Mock: usernames starting with 'test' are taken
|
||||
return !username.toLowerCase().startsWith('test')
|
||||
}
|
||||
|
||||
export async function checkEmailAvailability(email: string): Promise<boolean> {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
// Mock: emails with 'test' are taken
|
||||
return !email.toLowerCase().includes('test')
|
||||
}
|
||||
285
templates/basic-form.tsx
Normal file
285
templates/basic-form.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Basic Form Example - Login/Signup Form
|
||||
*
|
||||
* Demonstrates:
|
||||
* - Simple form with email and password validation
|
||||
* - useForm hook with zodResolver
|
||||
* - Error display
|
||||
* - Type-safe form data with z.infer
|
||||
* - Accessible error messages
|
||||
*/
|
||||
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
|
||||
// 1. Define Zod validation schema
|
||||
const loginSchema = z.object({
|
||||
email: z.string()
|
||||
.min(1, 'Email is required')
|
||||
.email('Invalid email address'),
|
||||
password: z.string()
|
||||
.min(8, 'Password must be at least 8 characters')
|
||||
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
|
||||
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
|
||||
.regex(/[0-9]/, 'Password must contain at least one number'),
|
||||
rememberMe: z.boolean().optional(),
|
||||
})
|
||||
|
||||
// 2. Infer TypeScript type from schema
|
||||
type LoginFormData = z.infer<typeof loginSchema>
|
||||
|
||||
export function BasicLoginForm() {
|
||||
// 3. Initialize form with zodResolver
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting, isValid },
|
||||
reset,
|
||||
} = useForm<LoginFormData>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
mode: 'onBlur', // Validate on blur for better UX
|
||||
defaultValues: {
|
||||
email: '',
|
||||
password: '',
|
||||
rememberMe: false,
|
||||
},
|
||||
})
|
||||
|
||||
// 4. Handle form submission
|
||||
const onSubmit = async (data: LoginFormData) => {
|
||||
try {
|
||||
console.log('Form data:', data)
|
||||
|
||||
// Make API call
|
||||
const response = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Login failed')
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
console.log('Login successful:', result)
|
||||
|
||||
// Reset form after successful submission
|
||||
reset()
|
||||
} catch (error) {
|
||||
console.error('Login error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 max-w-md mx-auto">
|
||||
<h2 className="text-2xl font-bold">Login</h2>
|
||||
|
||||
{/* Email Field */}
|
||||
<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'}
|
||||
aria-describedby={errors.email ? 'email-error' : undefined}
|
||||
className={`w-full px-3 py-2 border rounded-md ${
|
||||
errors.email ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
{errors.email && (
|
||||
<span
|
||||
id="email-error"
|
||||
role="alert"
|
||||
className="text-sm text-red-600 mt-1 block"
|
||||
>
|
||||
{errors.email.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password Field */}
|
||||
<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'}
|
||||
aria-describedby={errors.password ? 'password-error' : undefined}
|
||||
className={`w-full px-3 py-2 border rounded-md ${
|
||||
errors.password ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
{errors.password && (
|
||||
<span
|
||||
id="password-error"
|
||||
role="alert"
|
||||
className="text-sm text-red-600 mt-1 block"
|
||||
>
|
||||
{errors.password.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Remember Me Checkbox */}
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="rememberMe"
|
||||
type="checkbox"
|
||||
{...register('rememberMe')}
|
||||
className="h-4 w-4 rounded"
|
||||
/>
|
||||
<label htmlFor="rememberMe" className="ml-2 text-sm">
|
||||
Remember me
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<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 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSubmitting ? 'Logging in...' : 'Login'}
|
||||
</button>
|
||||
|
||||
{/* Form Status */}
|
||||
<div className="text-sm text-gray-600">
|
||||
{isValid && !isSubmitting && (
|
||||
<span className="text-green-600">Form is valid ✓</span>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Signup Form Variant
|
||||
*/
|
||||
const signupSchema = loginSchema.extend({
|
||||
confirmPassword: z.string(),
|
||||
name: z.string().min(2, 'Name must be at least 2 characters'),
|
||||
}).refine((data) => data.password === data.confirmPassword, {
|
||||
message: "Passwords don't match",
|
||||
path: ['confirmPassword'],
|
||||
})
|
||||
|
||||
type SignupFormData = z.infer<typeof signupSchema>
|
||||
|
||||
export function BasicSignupForm() {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<SignupFormData>({
|
||||
resolver: zodResolver(signupSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
rememberMe: false,
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = async (data: SignupFormData) => {
|
||||
console.log('Signup data:', data)
|
||||
// API call
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 max-w-md mx-auto">
|
||||
<h2 className="text-2xl font-bold">Sign Up</h2>
|
||||
|
||||
{/* Name Field */}
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium mb-1">
|
||||
Full Name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
{...register('name')}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
placeholder="John Doe"
|
||||
/>
|
||||
{errors.name && (
|
||||
<span role="alert" className="text-sm text-red-600 mt-1 block">
|
||||
{errors.name.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email Field */}
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
{...register('email')}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
{errors.email && (
|
||||
<span role="alert" className="text-sm text-red-600 mt-1 block">
|
||||
{errors.email.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password Field */}
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
{...register('password')}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
/>
|
||||
{errors.password && (
|
||||
<span role="alert" className="text-sm text-red-600 mt-1 block">
|
||||
{errors.password.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirm Password Field */}
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium mb-1">
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
{...register('confirmPassword')}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
/>
|
||||
{errors.confirmPassword && (
|
||||
<span role="alert" className="text-sm text-red-600 mt-1 block">
|
||||
{errors.confirmPassword.message}
|
||||
</span>
|
||||
)}
|
||||
</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 ? 'Creating account...' : 'Sign Up'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
303
templates/custom-error-display.tsx
Normal file
303
templates/custom-error-display.tsx
Normal file
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* 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>
|
||||
)
|
||||
}
|
||||
308
templates/dynamic-fields.tsx
Normal file
308
templates/dynamic-fields.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* Dynamic Form Fields Example - useFieldArray
|
||||
*
|
||||
* Demonstrates:
|
||||
* - useFieldArray for dynamic add/remove functionality
|
||||
* - Array validation with Zod
|
||||
* - Proper key usage (field.id, not index)
|
||||
* - Nested field error handling
|
||||
* - Add, remove, update, insert operations
|
||||
*/
|
||||
|
||||
import { useForm, useFieldArray } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
|
||||
// Schema for contact list
|
||||
const contactSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
email: z.string().email('Invalid email'),
|
||||
phone: z.string().regex(/^\+?[1-9]\d{1,14}$/, 'Invalid phone number').optional(),
|
||||
isPrimary: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const contactListSchema = z.object({
|
||||
contacts: z.array(contactSchema)
|
||||
.min(1, 'At least one contact is required')
|
||||
.max(10, 'Maximum 10 contacts allowed'),
|
||||
})
|
||||
|
||||
type ContactListData = z.infer<typeof contactListSchema>
|
||||
|
||||
export function DynamicContactList() {
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<ContactListData>({
|
||||
resolver: zodResolver(contactListSchema),
|
||||
defaultValues: {
|
||||
contacts: [{ name: '', email: '', phone: '', isPrimary: false }],
|
||||
},
|
||||
})
|
||||
|
||||
const { fields, append, remove, insert, update } = useFieldArray({
|
||||
control,
|
||||
name: 'contacts',
|
||||
})
|
||||
|
||||
const onSubmit = (data: ContactListData) => {
|
||||
console.log('Contacts:', data)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6 max-w-2xl mx-auto">
|
||||
<h2 className="text-2xl font-bold">Contact List</h2>
|
||||
|
||||
{/* Array error (min/max length) */}
|
||||
{errors.contacts && !Array.isArray(errors.contacts) && (
|
||||
<div role="alert" className="text-sm text-red-600 bg-red-50 p-3 rounded">
|
||||
{errors.contacts.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{fields.map((field, index) => (
|
||||
<div
|
||||
key={field.id} // IMPORTANT: Use field.id, not index
|
||||
className="border rounded-lg p-4 space-y-3"
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="font-medium">Contact {index + 1}</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(index)}
|
||||
className="text-red-600 hover:text-red-800 text-sm"
|
||||
disabled={fields.length === 1} // Require at least one contact
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label htmlFor={`contacts.${index}.name`} className="block text-sm font-medium mb-1">
|
||||
Name *
|
||||
</label>
|
||||
<input
|
||||
id={`contacts.${index}.name`}
|
||||
{...register(`contacts.${index}.name` as const)}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
/>
|
||||
{errors.contacts?.[index]?.name && (
|
||||
<span role="alert" className="text-sm text-red-600 mt-1 block">
|
||||
{errors.contacts[index]?.name?.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label htmlFor={`contacts.${index}.email`} className="block text-sm font-medium mb-1">
|
||||
Email *
|
||||
</label>
|
||||
<input
|
||||
id={`contacts.${index}.email`}
|
||||
type="email"
|
||||
{...register(`contacts.${index}.email` as const)}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
/>
|
||||
{errors.contacts?.[index]?.email && (
|
||||
<span role="alert" className="text-sm text-red-600 mt-1 block">
|
||||
{errors.contacts[index]?.email?.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Phone */}
|
||||
<div>
|
||||
<label htmlFor={`contacts.${index}.phone`} className="block text-sm font-medium mb-1">
|
||||
Phone (Optional)
|
||||
</label>
|
||||
<input
|
||||
id={`contacts.${index}.phone`}
|
||||
type="tel"
|
||||
{...register(`contacts.${index}.phone` as const)}
|
||||
placeholder="+1234567890"
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
/>
|
||||
{errors.contacts?.[index]?.phone && (
|
||||
<span role="alert" className="text-sm text-red-600 mt-1 block">
|
||||
{errors.contacts[index]?.phone?.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Primary Contact Checkbox */}
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id={`contacts.${index}.isPrimary`}
|
||||
type="checkbox"
|
||||
{...register(`contacts.${index}.isPrimary` as const)}
|
||||
className="h-4 w-4 rounded"
|
||||
/>
|
||||
<label htmlFor={`contacts.${index}.isPrimary`} className="ml-2 text-sm">
|
||||
Primary contact
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add Contact Button */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => append({ name: '', email: '', phone: '', isPrimary: false })}
|
||||
disabled={fields.length >= 10}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:bg-gray-400"
|
||||
>
|
||||
Add Contact
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => insert(0, { name: '', email: '', phone: '', isPrimary: false })}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||
>
|
||||
Add at Top
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<button type="submit" className="w-full px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700">
|
||||
Save Contacts
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Advanced Example: Skills with Custom Add
|
||||
*/
|
||||
const skillSchema = z.object({
|
||||
name: z.string().min(1, 'Skill name is required'),
|
||||
level: z.enum(['beginner', 'intermediate', 'advanced', 'expert']),
|
||||
yearsOfExperience: z.number().int().min(0).max(50),
|
||||
})
|
||||
|
||||
const skillsFormSchema = z.object({
|
||||
skills: z.array(skillSchema).min(1, 'Add at least one skill'),
|
||||
})
|
||||
|
||||
type SkillsFormData = z.infer<typeof skillsFormSchema>
|
||||
|
||||
export function DynamicSkillsForm() {
|
||||
const { register, control, handleSubmit, formState: { errors } } = useForm<SkillsFormData>({
|
||||
resolver: zodResolver(skillsFormSchema),
|
||||
defaultValues: {
|
||||
skills: [],
|
||||
},
|
||||
})
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: 'skills',
|
||||
})
|
||||
|
||||
// Preset skill templates
|
||||
const addPresetSkill = (skillName: string) => {
|
||||
append({
|
||||
name: skillName,
|
||||
level: 'intermediate',
|
||||
yearsOfExperience: 1,
|
||||
})
|
||||
}
|
||||
|
||||
const onSubmit = (data: SkillsFormData) => {
|
||||
console.log('Skills:', data)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6 max-w-2xl mx-auto">
|
||||
<h2 className="text-2xl font-bold">Your Skills</h2>
|
||||
|
||||
{errors.skills && !Array.isArray(errors.skills) && (
|
||||
<div role="alert" className="text-sm text-red-600">
|
||||
{errors.skills.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preset Skills */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">Quick Add:</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{['React', 'TypeScript', 'Node.js', 'Python', 'SQL'].map((skill) => (
|
||||
<button
|
||||
key={skill}
|
||||
type="button"
|
||||
onClick={() => addPresetSkill(skill)}
|
||||
className="px-3 py-1 bg-gray-200 rounded-full text-sm hover:bg-gray-300"
|
||||
>
|
||||
+ {skill}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Skills List */}
|
||||
<div className="space-y-3">
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="border rounded p-3 flex gap-3 items-start">
|
||||
<div className="flex-1 space-y-2">
|
||||
<input
|
||||
{...register(`skills.${index}.name` as const)}
|
||||
placeholder="Skill name"
|
||||
className="w-full px-2 py-1 border rounded text-sm"
|
||||
/>
|
||||
{errors.skills?.[index]?.name && (
|
||||
<span className="text-xs text-red-600">{errors.skills[index]?.name?.message}</span>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<select
|
||||
{...register(`skills.${index}.level` as const)}
|
||||
className="px-2 py-1 border rounded text-sm"
|
||||
>
|
||||
<option value="beginner">Beginner</option>
|
||||
<option value="intermediate">Intermediate</option>
|
||||
<option value="advanced">Advanced</option>
|
||||
<option value="expert">Expert</option>
|
||||
</select>
|
||||
|
||||
<input
|
||||
type="number"
|
||||
{...register(`skills.${index}.yearsOfExperience` as const, { valueAsNumber: true })}
|
||||
placeholder="Years"
|
||||
className="px-2 py-1 border rounded text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(index)}
|
||||
className="text-red-600 hover:text-red-800 text-sm px-2"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Custom Add */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => append({ name: '', level: 'beginner', yearsOfExperience: 0 })}
|
||||
className="w-full px-4 py-2 border-2 border-dashed border-gray-300 rounded-md hover:border-gray-400 text-gray-600"
|
||||
>
|
||||
+ Add Custom Skill
|
||||
</button>
|
||||
|
||||
<button type="submit" className="w-full px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700">
|
||||
Save Skills
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
367
templates/multi-step-form.tsx
Normal file
367
templates/multi-step-form.tsx
Normal file
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* Multi-Step Form Example (Wizard)
|
||||
*
|
||||
* Demonstrates:
|
||||
* - Multi-step form with per-step validation
|
||||
* - Progress tracking
|
||||
* - Step navigation (next, previous)
|
||||
* - Partial schema validation
|
||||
* - Combined schema for final submission
|
||||
* - Preserving form state across steps
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
|
||||
// Step 1: Personal Information
|
||||
const step1Schema = z.object({
|
||||
firstName: z.string().min(2, 'First name must be at least 2 characters'),
|
||||
lastName: z.string().min(2, 'Last name must be at least 2 characters'),
|
||||
email: z.string().email('Invalid email address'),
|
||||
phone: z.string().regex(/^\+?[1-9]\d{1,14}$/, 'Invalid phone number'),
|
||||
})
|
||||
|
||||
// Step 2: Address
|
||||
const step2Schema = z.object({
|
||||
street: z.string().min(1, 'Street is required'),
|
||||
city: z.string().min(1, 'City is required'),
|
||||
state: z.string().min(2, 'State must be at least 2 characters'),
|
||||
zipCode: z.string().regex(/^\d{5}(-\d{4})?$/, 'Invalid ZIP code'),
|
||||
})
|
||||
|
||||
// Step 3: Account
|
||||
const step3Schema = z.object({
|
||||
username: z.string()
|
||||
.min(3, 'Username must be at least 3 characters')
|
||||
.regex(/^[a-zA-Z0-9_]+$/, 'Username can only contain letters, numbers, and underscores'),
|
||||
password: z.string()
|
||||
.min(8, 'Password must be at least 8 characters')
|
||||
.regex(/[A-Z]/, 'Password must contain uppercase letter')
|
||||
.regex(/[0-9]/, 'Password must contain number'),
|
||||
confirmPassword: z.string(),
|
||||
}).refine((data) => data.password === data.confirmPassword, {
|
||||
message: "Passwords don't match",
|
||||
path: ['confirmPassword'],
|
||||
})
|
||||
|
||||
// Combined schema for final validation
|
||||
const fullFormSchema = step1Schema.merge(step2Schema).merge(step3Schema)
|
||||
|
||||
type FormData = z.infer<typeof fullFormSchema>
|
||||
type Step1Data = z.infer<typeof step1Schema>
|
||||
type Step2Data = z.infer<typeof step2Schema>
|
||||
type Step3Data = z.infer<typeof step3Schema>
|
||||
|
||||
const TOTAL_STEPS = 3
|
||||
|
||||
export function MultiStepRegistrationForm() {
|
||||
const [currentStep, setCurrentStep] = useState(1)
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
trigger,
|
||||
getValues,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(fullFormSchema),
|
||||
mode: 'onChange', // Validate as user types
|
||||
defaultValues: {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
street: '',
|
||||
city: '',
|
||||
state: '',
|
||||
zipCode: '',
|
||||
username: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
},
|
||||
})
|
||||
|
||||
// Navigate to next step
|
||||
const nextStep = async () => {
|
||||
let fieldsToValidate: (keyof FormData)[] = []
|
||||
|
||||
if (currentStep === 1) {
|
||||
fieldsToValidate = ['firstName', 'lastName', 'email', 'phone']
|
||||
} else if (currentStep === 2) {
|
||||
fieldsToValidate = ['street', 'city', 'state', 'zipCode']
|
||||
}
|
||||
|
||||
// Trigger validation for current step fields
|
||||
const isValid = await trigger(fieldsToValidate)
|
||||
|
||||
if (isValid) {
|
||||
setCurrentStep((prev) => Math.min(prev + 1, TOTAL_STEPS))
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate to previous step
|
||||
const prevStep = () => {
|
||||
setCurrentStep((prev) => Math.max(prev - 1, 1))
|
||||
}
|
||||
|
||||
// Final form submission
|
||||
const onSubmit = async (data: FormData) => {
|
||||
console.log('Complete form data:', data)
|
||||
// Make API call
|
||||
alert('Form submitted successfully!')
|
||||
}
|
||||
|
||||
// Calculate progress percentage
|
||||
const progressPercentage = (currentStep / TOTAL_STEPS) * 100
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
{/* Progress Bar */}
|
||||
<div className="mb-8">
|
||||
<div className="flex justify-between mb-2">
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
Step {currentStep} of {TOTAL_STEPS}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{Math.round(progressPercentage)}% Complete
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${progressPercentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step Indicators */}
|
||||
<div className="flex justify-between mt-4">
|
||||
{[1, 2, 3].map((step) => (
|
||||
<div
|
||||
key={step}
|
||||
className={`flex items-center ${
|
||||
step < TOTAL_STEPS ? 'flex-1' : ''
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
|
||||
step < currentStep
|
||||
? 'bg-green-600 text-white'
|
||||
: step === currentStep
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-300 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{step < currentStep ? '✓' : step}
|
||||
</div>
|
||||
{step < TOTAL_STEPS && (
|
||||
<div
|
||||
className={`flex-1 h-1 mx-2 ${
|
||||
step < currentStep ? 'bg-green-600' : 'bg-gray-300'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
{/* Step 1: Personal Information */}
|
||||
{currentStep === 1 && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-2xl font-bold">Personal Information</h2>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">First Name *</label>
|
||||
<input
|
||||
{...register('firstName')}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
/>
|
||||
{errors.firstName && (
|
||||
<span className="text-sm text-red-600">{errors.firstName.message}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Last Name *</label>
|
||||
<input
|
||||
{...register('lastName')}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
/>
|
||||
{errors.lastName && (
|
||||
<span className="text-sm text-red-600">{errors.lastName.message}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Email *</label>
|
||||
<input
|
||||
type="email"
|
||||
{...register('email')}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
/>
|
||||
{errors.email && (
|
||||
<span className="text-sm text-red-600">{errors.email.message}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Phone *</label>
|
||||
<input
|
||||
type="tel"
|
||||
{...register('phone')}
|
||||
placeholder="+1234567890"
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
/>
|
||||
{errors.phone && (
|
||||
<span className="text-sm text-red-600">{errors.phone.message}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Address */}
|
||||
{currentStep === 2 && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-2xl font-bold">Address</h2>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Street Address *</label>
|
||||
<input
|
||||
{...register('street')}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
/>
|
||||
{errors.street && (
|
||||
<span className="text-sm text-red-600">{errors.street.message}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">City *</label>
|
||||
<input
|
||||
{...register('city')}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
/>
|
||||
{errors.city && (
|
||||
<span className="text-sm text-red-600">{errors.city.message}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">State *</label>
|
||||
<input
|
||||
{...register('state')}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
/>
|
||||
{errors.state && (
|
||||
<span className="text-sm text-red-600">{errors.state.message}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">ZIP Code *</label>
|
||||
<input
|
||||
{...register('zipCode')}
|
||||
placeholder="12345 or 12345-6789"
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
/>
|
||||
{errors.zipCode && (
|
||||
<span className="text-sm text-red-600">{errors.zipCode.message}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Account */}
|
||||
{currentStep === 3 && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-2xl font-bold">Create Account</h2>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Username *</label>
|
||||
<input
|
||||
{...register('username')}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
/>
|
||||
{errors.username && (
|
||||
<span className="text-sm text-red-600">{errors.username.message}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Password *</label>
|
||||
<input
|
||||
type="password"
|
||||
{...register('password')}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
/>
|
||||
{errors.password && (
|
||||
<span className="text-sm text-red-600">{errors.password.message}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Confirm Password *</label>
|
||||
<input
|
||||
type="password"
|
||||
{...register('confirmPassword')}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
/>
|
||||
{errors.confirmPassword && (
|
||||
<span className="text-sm text-red-600">{errors.confirmPassword.message}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="mt-6 p-4 bg-gray-50 rounded-md">
|
||||
<h3 className="font-medium mb-2">Review Your Information:</h3>
|
||||
<div className="text-sm space-y-1">
|
||||
<p><strong>Name:</strong> {getValues('firstName')} {getValues('lastName')}</p>
|
||||
<p><strong>Email:</strong> {getValues('email')}</p>
|
||||
<p><strong>Phone:</strong> {getValues('phone')}</p>
|
||||
<p><strong>Address:</strong> {getValues('street')}, {getValues('city')}, {getValues('state')} {getValues('zipCode')}</p>
|
||||
<p><strong>Username:</strong> {getValues('username')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation Buttons */}
|
||||
<div className="flex justify-between pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={prevStep}
|
||||
disabled={currentStep === 1}
|
||||
className="px-6 py-2 border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
|
||||
{currentStep < TOTAL_STEPS ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={nextStep}
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="px-6 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:bg-gray-400"
|
||||
>
|
||||
{isSubmitting ? 'Submitting...' : 'Complete Registration'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
35
templates/package.json
Normal file
35
templates/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "react-hook-form-zod-example",
|
||||
"version": "1.0.0",
|
||||
"description": "Example project demonstrating React Hook Form + Zod validation",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.66.1",
|
||||
"zod": "^4.1.12",
|
||||
"@hookform/resolvers": "^5.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"keywords": [
|
||||
"react",
|
||||
"react-hook-form",
|
||||
"zod",
|
||||
"validation",
|
||||
"forms",
|
||||
"typescript"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT"
|
||||
}
|
||||
313
templates/server-validation.ts
Normal file
313
templates/server-validation.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* Server-Side Validation Example
|
||||
*
|
||||
* Demonstrates:
|
||||
* - Using the SAME Zod schema on server
|
||||
* - Single source of truth for validation
|
||||
* - Error mapping from server to client
|
||||
* - Type-safe validation on both sides
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
/**
|
||||
* SHARED SCHEMA - Use this exact schema on both client and server
|
||||
* Define it in a shared file (e.g., schemas/user.ts) and import on both sides
|
||||
*/
|
||||
export const userRegistrationSchema = z.object({
|
||||
username: z.string()
|
||||
.min(3, 'Username must be at least 3 characters')
|
||||
.max(20, 'Username must not exceed 20 characters')
|
||||
.regex(/^[a-zA-Z0-9_]+$/, 'Username can only contain letters, numbers, and underscores'),
|
||||
email: z.string()
|
||||
.email('Invalid email address'),
|
||||
password: z.string()
|
||||
.min(8, 'Password must be at least 8 characters')
|
||||
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
|
||||
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
|
||||
.regex(/[0-9]/, 'Password must contain at least one number'),
|
||||
age: z.number()
|
||||
.int('Age must be a whole number')
|
||||
.min(13, 'You must be at least 13 years old')
|
||||
.max(120, 'Invalid age'),
|
||||
}).refine((data) => {
|
||||
// Custom validation: check if username is blacklisted
|
||||
const blacklistedUsernames = ['admin', 'root', 'system']
|
||||
return !blacklistedUsernames.includes(data.username.toLowerCase())
|
||||
}, {
|
||||
message: 'This username is not allowed',
|
||||
path: ['username'],
|
||||
})
|
||||
|
||||
type UserRegistrationData = z.infer<typeof userRegistrationSchema>
|
||||
|
||||
/**
|
||||
* SERVER-SIDE VALIDATION (Next.js API Route Example)
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
// 1. Parse and validate with Zod
|
||||
const validatedData = userRegistrationSchema.parse(body)
|
||||
|
||||
// 2. Additional server-only validation (database checks, etc.)
|
||||
const usernameExists = await checkUsernameExists(validatedData.username)
|
||||
if (usernameExists) {
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
errors: {
|
||||
username: 'Username is already taken',
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const emailExists = await checkEmailExists(validatedData.email)
|
||||
if (emailExists) {
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
errors: {
|
||||
email: 'Email is already registered',
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 3. Proceed with registration
|
||||
const user = await createUser(validatedData)
|
||||
|
||||
return Response.json({
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
},
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
// 4. Handle Zod validation errors
|
||||
if (error instanceof z.ZodError) {
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
errors: error.flatten().fieldErrors,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 5. Handle other errors
|
||||
console.error('Registration error:', error)
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'An unexpected error occurred',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SERVER-SIDE VALIDATION (Node.js/Express Example)
|
||||
*/
|
||||
import express from 'express'
|
||||
|
||||
const app = express()
|
||||
|
||||
app.post('/api/register', async (req, res) => {
|
||||
try {
|
||||
// Parse and validate
|
||||
const validatedData = userRegistrationSchema.parse(req.body)
|
||||
|
||||
// Server-only checks
|
||||
const usernameExists = await checkUsernameExists(validatedData.username)
|
||||
if (usernameExists) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
errors: {
|
||||
username: 'Username is already taken',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Create user
|
||||
const user = await createUser(validatedData)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
user,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
errors: error.flatten().fieldErrors,
|
||||
})
|
||||
}
|
||||
|
||||
console.error('Registration error:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'An unexpected error occurred',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* SERVER-SIDE VALIDATION (Cloudflare Workers + Hono Example)
|
||||
*/
|
||||
import { Hono } from 'hono'
|
||||
import { zValidator } from '@hono/zod-validator'
|
||||
|
||||
const app = new Hono()
|
||||
|
||||
app.post('/api/register', zValidator('json', userRegistrationSchema), async (c) => {
|
||||
// Data is already validated by zValidator middleware
|
||||
const validatedData = c.req.valid('json')
|
||||
|
||||
// Server-only checks
|
||||
const usernameExists = await checkUsernameExists(validatedData.username)
|
||||
if (usernameExists) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
errors: {
|
||||
username: 'Username is already taken',
|
||||
},
|
||||
},
|
||||
400
|
||||
)
|
||||
}
|
||||
|
||||
// Create user
|
||||
const user = await createUser(validatedData)
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
user,
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* CLIENT-SIDE INTEGRATION WITH SERVER ERRORS
|
||||
*/
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
|
||||
function RegistrationForm() {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<UserRegistrationData>({
|
||||
resolver: zodResolver(userRegistrationSchema),
|
||||
defaultValues: {
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
age: 18,
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = async (data: UserRegistrationData) => {
|
||||
try {
|
||||
const response = await fetch('/api/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!result.success) {
|
||||
// Map server errors to form fields
|
||||
if (result.errors) {
|
||||
Object.entries(result.errors).forEach(([field, message]) => {
|
||||
setError(field as keyof UserRegistrationData, {
|
||||
type: 'server',
|
||||
message: Array.isArray(message) ? message[0] : message as string,
|
||||
})
|
||||
})
|
||||
} else {
|
||||
// Generic error
|
||||
setError('root', {
|
||||
type: 'server',
|
||||
message: result.message || 'Registration failed',
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Success - redirect or show success message
|
||||
console.log('Registration successful:', result.user)
|
||||
} catch (error) {
|
||||
setError('root', {
|
||||
type: 'server',
|
||||
message: 'Network error. Please try again.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
{errors.root && (
|
||||
<div role="alert" className="error-banner">
|
||||
{errors.root.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form fields */}
|
||||
<input {...register('username')} />
|
||||
{errors.username && <span>{errors.username.message}</span>}
|
||||
|
||||
<input type="email" {...register('email')} />
|
||||
{errors.email && <span>{errors.email.message}</span>}
|
||||
|
||||
<input type="password" {...register('password')} />
|
||||
{errors.password && <span>{errors.password.message}</span>}
|
||||
|
||||
<input type="number" {...register('age', { valueAsNumber: true })} />
|
||||
{errors.age && <span>{errors.age.message}</span>}
|
||||
|
||||
<button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Registering...' : 'Register'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper functions (implement according to your database)
|
||||
*/
|
||||
async function checkUsernameExists(username: string): Promise<boolean> {
|
||||
// Database query
|
||||
return false
|
||||
}
|
||||
|
||||
async function checkEmailExists(email: string): Promise<boolean> {
|
||||
// Database query
|
||||
return false
|
||||
}
|
||||
|
||||
async function createUser(data: UserRegistrationData) {
|
||||
// Create user in database
|
||||
return { id: '1', ...data }
|
||||
}
|
||||
|
||||
/**
|
||||
* KEY BENEFITS OF SERVER-SIDE VALIDATION:
|
||||
*
|
||||
* 1. Security - Client validation can be bypassed, server validation cannot
|
||||
* 2. Single Source of Truth - Same schema on client and server
|
||||
* 3. Type Safety - TypeScript types automatically inferred from schema
|
||||
* 4. Consistency - Same validation rules applied everywhere
|
||||
* 5. Database Checks - Server can validate against database (unique username, etc.)
|
||||
*/
|
||||
351
templates/shadcn-form.tsx
Normal file
351
templates/shadcn-form.tsx
Normal file
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* shadcn/ui Form Component Integration
|
||||
*
|
||||
* Demonstrates:
|
||||
* - shadcn/ui Form component with React Hook Form + Zod
|
||||
* - FormField, FormItem, FormLabel, FormControl, FormMessage components
|
||||
* - Type-safe form with proper error handling
|
||||
* - Accessible form structure
|
||||
*
|
||||
* Installation:
|
||||
* npx shadcn@latest add form input button
|
||||
*/
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
|
||||
// Define schema
|
||||
const profileFormSchema = z.object({
|
||||
username: z.string()
|
||||
.min(2, { message: 'Username must be at least 2 characters.' })
|
||||
.max(30, { message: 'Username must not be longer than 30 characters.' }),
|
||||
email: z.string()
|
||||
.email({ message: 'Please enter a valid email address.' }),
|
||||
bio: z.string()
|
||||
.max(160, { message: 'Bio must not be longer than 160 characters.' })
|
||||
.optional(),
|
||||
role: z.enum(['admin', 'user', 'guest'], {
|
||||
required_error: 'Please select a role.',
|
||||
}),
|
||||
notifications: z.boolean().default(false).optional(),
|
||||
})
|
||||
|
||||
type ProfileFormValues = z.infer<typeof profileFormSchema>
|
||||
|
||||
export function ShadcnProfileForm() {
|
||||
const form = useForm<ProfileFormValues>({
|
||||
resolver: zodResolver(profileFormSchema),
|
||||
defaultValues: {
|
||||
username: '',
|
||||
email: '',
|
||||
bio: '',
|
||||
notifications: false,
|
||||
},
|
||||
})
|
||||
|
||||
function onSubmit(data: ProfileFormValues) {
|
||||
console.log('Form submitted:', data)
|
||||
// Make API call
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8 max-w-2xl mx-auto">
|
||||
<h2 className="text-3xl font-bold">Profile Settings</h2>
|
||||
|
||||
{/* Username Field */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="shadcn" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
This is your public display name.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Email Field */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="email" placeholder="you@example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
We'll never share your email with anyone.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Bio Field (Textarea) */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="bio"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Bio</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Tell us a little bit about yourself"
|
||||
className="resize-none"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
You can write up to 160 characters.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Role Field (Select) */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="role"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Role</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a role" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
<SelectItem value="user">User</SelectItem>
|
||||
<SelectItem value="guest">Guest</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Choose your account type.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Notifications Field (Checkbox) */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="notifications"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel>
|
||||
Email notifications
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
Receive email notifications about your account activity.
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? 'Saving...' : 'Update profile'}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiple Field Types Example
|
||||
*/
|
||||
const settingsFormSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
language: z.string(),
|
||||
theme: z.enum(['light', 'dark', 'system']),
|
||||
emailPreferences: z.object({
|
||||
marketing: z.boolean().default(false),
|
||||
updates: z.boolean().default(true),
|
||||
security: z.boolean().default(true),
|
||||
}),
|
||||
})
|
||||
|
||||
type SettingsFormValues = z.infer<typeof settingsFormSchema>
|
||||
|
||||
export function ShadcnSettingsForm() {
|
||||
const form = useForm<SettingsFormValues>({
|
||||
resolver: zodResolver(settingsFormSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
language: 'en',
|
||||
theme: 'system',
|
||||
emailPreferences: {
|
||||
marketing: false,
|
||||
updates: true,
|
||||
security: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
function onSubmit(data: SettingsFormValues) {
|
||||
console.log('Settings updated:', data)
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8 max-w-2xl mx-auto">
|
||||
<h2 className="text-3xl font-bold">Settings</h2>
|
||||
|
||||
{/* Name */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Display Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Theme */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="theme"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Theme</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="light">Light</SelectItem>
|
||||
<SelectItem value="dark">Dark</SelectItem>
|
||||
<SelectItem value="system">System</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Email Preferences (Nested Object with Checkboxes) */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">Email Preferences</h3>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="emailPreferences.marketing"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel>Marketing emails</FormLabel>
|
||||
<FormDescription>
|
||||
Receive emails about new products and features.
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="emailPreferences.updates"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel>Update emails</FormLabel>
|
||||
<FormDescription>
|
||||
Receive emails about your account updates.
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="emailPreferences.security"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel>Security emails</FormLabel>
|
||||
<FormDescription>
|
||||
Receive emails about your account security (recommended).
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit">Save settings</Button>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* NOTE: shadcn/ui states "We are not actively developing the Form component anymore."
|
||||
* They recommend using the Field component for new implementations.
|
||||
* Check https://ui.shadcn.com/docs/components/form for latest guidance.
|
||||
*/
|
||||
Reference in New Issue
Block a user