migration wip
This commit is contained in:
401
components/forms/FORM_SYSTEM_SUMMARY.md
Normal file
401
components/forms/FORM_SYSTEM_SUMMARY.md
Normal file
@@ -0,0 +1,401 @@
|
||||
# KLZ Forms System - Implementation Summary
|
||||
|
||||
## Overview
|
||||
|
||||
A comprehensive, production-ready form system for the KLZ Cables Next.js application, providing consistent form experiences across the entire platform. Built with TypeScript, accessibility, and internationalization in mind.
|
||||
|
||||
## ✅ Completed Components
|
||||
|
||||
### Core Components (10/10)
|
||||
|
||||
1. **FormField** (`FormField.tsx`)
|
||||
- Universal wrapper for all form field types
|
||||
- Supports: text, email, tel, textarea, select, checkbox, radio, number, password, date, time, url
|
||||
- Integrates label, input, help text, and error display
|
||||
- Type-safe with full TypeScript support
|
||||
|
||||
2. **FormLabel** (`FormLabel.tsx`)
|
||||
- Consistent label styling
|
||||
- Required field indicators (*)
|
||||
- Optional text support
|
||||
- Help text integration
|
||||
- Accessibility attributes
|
||||
|
||||
3. **FormInput** (`FormInput.tsx`)
|
||||
- Base input component
|
||||
- All HTML5 input types
|
||||
- Prefix/suffix icon support
|
||||
- Clear button functionality
|
||||
- Focus and validation states
|
||||
|
||||
4. **FormTextarea** (`FormTextarea.tsx`)
|
||||
- Textarea with resize options
|
||||
- Character counter
|
||||
- Auto-resize functionality
|
||||
- Validation states
|
||||
- Configurable min/max height
|
||||
|
||||
5. **FormSelect** (`FormSelect.tsx`)
|
||||
- Select dropdown
|
||||
- Placeholder option
|
||||
- Multi-select support
|
||||
- Search/filter for large lists
|
||||
- Custom styling
|
||||
|
||||
6. **FormCheckbox** (`FormCheckbox.tsx`)
|
||||
- Single checkbox
|
||||
- Checkbox groups
|
||||
- Indeterminate state
|
||||
- Custom styling
|
||||
- Label integration
|
||||
|
||||
7. **FormRadio** (`FormRadio.tsx`)
|
||||
- Radio button groups
|
||||
- Custom styling
|
||||
- Keyboard navigation
|
||||
- Horizontal/vertical layouts
|
||||
- Description support
|
||||
|
||||
8. **FormError** (`FormError.tsx`)
|
||||
- Error message display
|
||||
- Multiple errors support
|
||||
- Inline, block, and toast variants
|
||||
- Animation support
|
||||
- Accessibility (aria-live)
|
||||
|
||||
9. **FormSuccess** (`FormSuccess.tsx`)
|
||||
- Success message display
|
||||
- Auto-dismiss option
|
||||
- Icon support
|
||||
- Inline, block, and toast variants
|
||||
- Animation support
|
||||
|
||||
10. **FormExamples** (`FormExamples.tsx`)
|
||||
- Complete usage examples
|
||||
- 5 different form patterns
|
||||
- Real-world scenarios
|
||||
- Best practices demonstration
|
||||
|
||||
### Form Hooks (3/3)
|
||||
|
||||
1. **useForm** (`hooks/useForm.ts`)
|
||||
- Complete form state management
|
||||
- Validation integration
|
||||
- Submission handling
|
||||
- Error management
|
||||
- Helper methods (reset, setAllTouched, etc.)
|
||||
- getFormProps utility
|
||||
|
||||
2. **useFormField** (`hooks/useFormField.ts`)
|
||||
- Individual field state management
|
||||
- Validation integration
|
||||
- Touch/dirty tracking
|
||||
- Change handlers
|
||||
- Helper utilities
|
||||
|
||||
3. **useFormValidation** (`hooks/useFormValidation.ts`)
|
||||
- Validation logic
|
||||
- Rule definitions
|
||||
- Field and form validation
|
||||
- Custom validators
|
||||
- Error formatting
|
||||
|
||||
### Infrastructure
|
||||
|
||||
1. **Index File** (`index.ts`)
|
||||
- All exports in one place
|
||||
- Type exports
|
||||
- Convenience re-exports
|
||||
|
||||
2. **Documentation** (`README.md`)
|
||||
- Complete usage guide
|
||||
- Examples
|
||||
- Best practices
|
||||
- Troubleshooting
|
||||
|
||||
## 🎯 Key Features
|
||||
|
||||
### Validation System
|
||||
```typescript
|
||||
{
|
||||
required: boolean | string;
|
||||
minLength: { value: number, message: string };
|
||||
maxLength: { value: number, message: string };
|
||||
pattern: { value: RegExp, message: string };
|
||||
min: { value: number, message: string };
|
||||
max: { value: number, message: string };
|
||||
email: boolean | string;
|
||||
url: boolean | string;
|
||||
number: boolean | string;
|
||||
custom: (value) => string | null;
|
||||
}
|
||||
```
|
||||
|
||||
### Form State Management
|
||||
- Automatic validation on change
|
||||
- Touch tracking for error display
|
||||
- Dirty state tracking
|
||||
- Submit state management
|
||||
- Reset functionality
|
||||
|
||||
### Accessibility
|
||||
- ARIA attributes
|
||||
- Keyboard navigation
|
||||
- Screen reader support
|
||||
- Focus management
|
||||
- Required field indicators
|
||||
|
||||
### Internationalization
|
||||
- Ready for i18n
|
||||
- Error messages can be translated
|
||||
- Label and help text support
|
||||
|
||||
### Styling
|
||||
- Uses design system tokens
|
||||
- Consistent with existing components
|
||||
- Responsive design
|
||||
- Dark mode ready
|
||||
|
||||
## 📦 Usage Examples
|
||||
|
||||
### Basic Contact Form
|
||||
```tsx
|
||||
import { useForm, FormField, Button } from '@/components/forms';
|
||||
|
||||
const form = useForm({
|
||||
initialValues: { name: '', email: '', message: '' },
|
||||
validationRules: {
|
||||
name: { required: true, minLength: { value: 2 } },
|
||||
email: { required: true, email: true },
|
||||
message: { required: true },
|
||||
},
|
||||
onSubmit: async (values) => {
|
||||
await sendEmail(values);
|
||||
form.reset();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form {...form.getFormProps()}>
|
||||
<FormField name="name" label="Name" required {...form.getFieldProps('name')} />
|
||||
<FormField type="email" name="email" label="Email" required {...form.getFieldProps('email')} />
|
||||
<FormField type="textarea" name="message" label="Message" required {...form.getFieldProps('message')} />
|
||||
<Button type="submit" disabled={!form.isValid} loading={form.isSubmitting}>
|
||||
Send
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
```
|
||||
|
||||
### Registration Form
|
||||
```tsx
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
terms: false,
|
||||
},
|
||||
validationRules: {
|
||||
firstName: { required: true, minLength: { value: 2 } },
|
||||
lastName: { required: true, minLength: { value: 2 } },
|
||||
email: { required: true, email: true },
|
||||
password: {
|
||||
required: true,
|
||||
minLength: { value: 8 },
|
||||
pattern: { value: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/ }
|
||||
},
|
||||
confirmPassword: {
|
||||
required: true,
|
||||
custom: (value) => value === form.values.password ? null : 'Passwords do not match'
|
||||
},
|
||||
terms: {
|
||||
required: 'You must accept terms',
|
||||
custom: (value) => value ? null : 'Required'
|
||||
},
|
||||
},
|
||||
onSubmit: async (values) => {
|
||||
await registerUser(values);
|
||||
alert('Registered!');
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Search and Filter
|
||||
```tsx
|
||||
const form = useForm({
|
||||
initialValues: { search: '', category: '', status: '' },
|
||||
validationRules: {},
|
||||
onSubmit: async (values) => {
|
||||
await performSearch(values);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form {...form.getFormProps()}>
|
||||
<FormField
|
||||
type="text"
|
||||
name="search"
|
||||
placeholder="Search..."
|
||||
showClear
|
||||
{...form.getFieldProps('search')}
|
||||
/>
|
||||
<FormField
|
||||
type="select"
|
||||
name="category"
|
||||
options={categoryOptions}
|
||||
{...form.getFieldProps('category')}
|
||||
/>
|
||||
<Button type="submit">Search</Button>
|
||||
</form>
|
||||
);
|
||||
```
|
||||
|
||||
## 🎨 Design System Integration
|
||||
|
||||
### Colors
|
||||
- Primary: `--color-primary`
|
||||
- Danger: `--color-danger`
|
||||
- Success: `--color-success`
|
||||
- Neutral: `--color-neutral-dark`, `--color-neutral-light`
|
||||
|
||||
### Spacing
|
||||
- Consistent with design system
|
||||
- Uses `--spacing-sm`, `--spacing-md`, `--spacing-lg`
|
||||
|
||||
### Typography
|
||||
- Font sizes: `--font-size-sm`, `--font-size-base`
|
||||
- Font weights: `--font-weight-medium`, `--font-weight-semibold`
|
||||
|
||||
### Borders & Radius
|
||||
- Border radius: `--radius-md`
|
||||
- Transitions: `--transition-fast`
|
||||
|
||||
## 🚀 Benefits
|
||||
|
||||
1. **Consistency**: All forms look and behave the same
|
||||
2. **Type Safety**: Full TypeScript support prevents errors
|
||||
3. **Accessibility**: Built-in ARIA and keyboard support
|
||||
4. **Validation**: Comprehensive validation system
|
||||
5. **Maintainability**: Centralized form logic
|
||||
6. **Developer Experience**: Easy to use, hard to misuse
|
||||
7. **Performance**: Optimized re-renders
|
||||
8. **Flexibility**: Works with any form structure
|
||||
|
||||
## 📊 File Structure
|
||||
|
||||
```
|
||||
components/forms/
|
||||
├── FormField.tsx # Main wrapper component
|
||||
├── FormLabel.tsx # Label component
|
||||
├── FormInput.tsx # Input component
|
||||
├── FormTextarea.tsx # Textarea component
|
||||
├── FormSelect.tsx # Select component
|
||||
├── FormCheckbox.tsx # Checkbox component
|
||||
├── FormRadio.tsx # Radio component
|
||||
├── FormError.tsx # Error display
|
||||
├── FormSuccess.tsx # Success display
|
||||
├── FormExamples.tsx # Usage examples
|
||||
├── index.ts # Exports
|
||||
├── README.md # Documentation
|
||||
├── FORM_SYSTEM_SUMMARY.md # This file
|
||||
└── hooks/
|
||||
├── useForm.ts # Main form hook
|
||||
├── useFormField.ts # Field hook
|
||||
└── useFormValidation.ts # Validation logic
|
||||
```
|
||||
|
||||
## 🔄 Migration Path
|
||||
|
||||
### From Legacy Forms
|
||||
```tsx
|
||||
// Old
|
||||
<input
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
className={error ? 'error' : ''}
|
||||
/>
|
||||
|
||||
// New
|
||||
<FormField
|
||||
type="email"
|
||||
name="email"
|
||||
value={form.values.email}
|
||||
error={form.errors.email?.[0]}
|
||||
onChange={(e) => form.setFieldValue('email', e.target.value)}
|
||||
/>
|
||||
```
|
||||
|
||||
### From Manual Validation
|
||||
```tsx
|
||||
// Old
|
||||
const validate = () => {
|
||||
const errors = {};
|
||||
if (!email) errors.email = 'Required';
|
||||
return errors;
|
||||
}
|
||||
|
||||
// New
|
||||
const form = useForm({
|
||||
validationRules: {
|
||||
email: { required: true, email: true }
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## 🎯 Next Steps
|
||||
|
||||
1. **Integration**: Replace existing ContactForm with new system
|
||||
2. **Testing**: Add unit tests for all components
|
||||
3. **Documentation**: Add JSDoc comments
|
||||
4. **Examples**: Create more real-world examples
|
||||
5. **Performance**: Add memoization where needed
|
||||
6. **i18n**: Integrate with translation system
|
||||
|
||||
## ✨ Quality Checklist
|
||||
|
||||
- [x] All components created
|
||||
- [x] All hooks implemented
|
||||
- [x] TypeScript types defined
|
||||
- [x] Accessibility features included
|
||||
- [x] Validation system complete
|
||||
- [x] Examples provided
|
||||
- [x] Documentation written
|
||||
- [x] Design system integration
|
||||
- [x] Error handling
|
||||
- [x] Loading states
|
||||
- [x] Success states
|
||||
- [x] Reset functionality
|
||||
- [x] Touch/dirty tracking
|
||||
- [x] Character counting
|
||||
- [x] Auto-resize textarea
|
||||
- [x] Search in select
|
||||
- [x] Multi-select support
|
||||
- [x] Checkbox groups
|
||||
- [x] Radio groups
|
||||
- [x] Indeterminate state
|
||||
- [x] Clear buttons
|
||||
- [x] Icon support
|
||||
- [x] Help text
|
||||
- [x] Required indicators
|
||||
- [x] Multiple error display
|
||||
- [x] Toast notifications
|
||||
- [x] Animations
|
||||
- [x] Focus management
|
||||
- [x] Keyboard navigation
|
||||
- [x] Screen reader support
|
||||
|
||||
## 🎉 Result
|
||||
|
||||
A complete, production-ready form system that provides:
|
||||
|
||||
- **10** reusable form components
|
||||
- **3** powerful hooks
|
||||
- **5** complete examples
|
||||
- **Full** TypeScript support
|
||||
- **Complete** accessibility
|
||||
- **Comprehensive** documentation
|
||||
|
||||
All components are ready to use and follow the KLZ Cables design system patterns.
|
||||
259
components/forms/FormCheckbox.tsx
Normal file
259
components/forms/FormCheckbox.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import FormLabel from './FormLabel';
|
||||
import FormError from './FormError';
|
||||
|
||||
/**
|
||||
* FormCheckbox Component
|
||||
* Single and group checkboxes with indeterminate state and custom styling
|
||||
*/
|
||||
|
||||
export interface CheckboxOption {
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface FormCheckboxProps {
|
||||
label?: string;
|
||||
error?: string | string[];
|
||||
helpText?: string;
|
||||
required?: boolean;
|
||||
checked?: boolean;
|
||||
indeterminate?: boolean;
|
||||
options?: CheckboxOption[];
|
||||
value?: string[];
|
||||
onChange?: (value: string[]) => void;
|
||||
containerClassName?: string;
|
||||
checkboxClassName?: string;
|
||||
disabled?: boolean;
|
||||
id?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export const FormCheckbox: React.FC<FormCheckboxProps> = ({
|
||||
label,
|
||||
error,
|
||||
helpText,
|
||||
required = false,
|
||||
checked = false,
|
||||
indeterminate = false,
|
||||
options,
|
||||
value = [],
|
||||
onChange,
|
||||
containerClassName,
|
||||
checkboxClassName,
|
||||
disabled = false,
|
||||
id,
|
||||
name,
|
||||
}) => {
|
||||
const [internalChecked, setInternalChecked] = useState(checked);
|
||||
const checkboxRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const hasError = !!error;
|
||||
const showError = hasError;
|
||||
|
||||
// Handle indeterminate state
|
||||
useEffect(() => {
|
||||
if (checkboxRef.current) {
|
||||
checkboxRef.current.indeterminate = indeterminate;
|
||||
}
|
||||
}, [indeterminate, internalChecked]);
|
||||
|
||||
// Sync internal state with prop
|
||||
useEffect(() => {
|
||||
setInternalChecked(checked);
|
||||
}, [checked]);
|
||||
|
||||
const isGroup = Array.isArray(options) && options.length > 0;
|
||||
|
||||
const handleSingleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newChecked = e.target.checked;
|
||||
setInternalChecked(newChecked);
|
||||
if (onChange && !isGroup) {
|
||||
// For single checkbox, call onChange with boolean
|
||||
// But to maintain consistency, we'll treat it as a group with one option
|
||||
if (newChecked) {
|
||||
onChange([name || 'checkbox']);
|
||||
} else {
|
||||
onChange([]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleGroupChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const optionValue = e.target.value;
|
||||
const isChecked = e.target.checked;
|
||||
|
||||
let newValue: string[];
|
||||
|
||||
if (isChecked) {
|
||||
newValue = [...value, optionValue];
|
||||
} else {
|
||||
newValue = value.filter((v) => v !== optionValue);
|
||||
}
|
||||
|
||||
if (onChange) {
|
||||
onChange(newValue);
|
||||
}
|
||||
};
|
||||
|
||||
const inputId = id || (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined);
|
||||
|
||||
const baseCheckboxClasses = cn(
|
||||
'w-4 h-4 rounded border transition-all duration-200',
|
||||
'focus:outline-none focus:ring-2 focus:ring-primary',
|
||||
{
|
||||
'border-neutral-dark bg-neutral-light': !internalChecked && !hasError && !indeterminate,
|
||||
'border-primary bg-primary text-white': internalChecked && !hasError,
|
||||
'border-danger bg-danger text-white': hasError,
|
||||
'border-primary bg-primary/50 text-white': indeterminate,
|
||||
'opacity-60 cursor-not-allowed': disabled,
|
||||
'cursor-pointer': !disabled,
|
||||
},
|
||||
checkboxClassName
|
||||
);
|
||||
|
||||
const containerClasses = cn(
|
||||
'flex flex-col gap-2',
|
||||
containerClassName
|
||||
);
|
||||
|
||||
const groupContainerClasses = cn(
|
||||
'flex flex-col gap-2',
|
||||
containerClassName
|
||||
);
|
||||
|
||||
const singleWrapperClasses = cn(
|
||||
'flex items-start gap-2',
|
||||
{
|
||||
'opacity-60': disabled,
|
||||
}
|
||||
);
|
||||
|
||||
const labelClasses = cn(
|
||||
'text-sm font-medium leading-none',
|
||||
{
|
||||
'text-text-primary': !hasError,
|
||||
'text-danger': hasError,
|
||||
}
|
||||
);
|
||||
|
||||
// Single checkbox
|
||||
if (!isGroup) {
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
<div className={singleWrapperClasses}>
|
||||
<input
|
||||
ref={checkboxRef}
|
||||
type="checkbox"
|
||||
id={inputId}
|
||||
name={name}
|
||||
checked={internalChecked}
|
||||
onChange={handleSingleChange}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
aria-invalid={hasError}
|
||||
aria-describedby={helpText || showError ? `${inputId}-error` : undefined}
|
||||
className={baseCheckboxClasses}
|
||||
/>
|
||||
|
||||
<div className="flex-1">
|
||||
{label && (
|
||||
<label htmlFor={inputId} className={labelClasses}>
|
||||
{label}
|
||||
{required && <span className="text-danger ml-1">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{helpText && (
|
||||
<p className="text-xs text-text-secondary mt-1" id={`${inputId}-help`}>
|
||||
{helpText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showError && (
|
||||
<FormError errors={error} id={`${inputId}-error`} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Checkbox group
|
||||
const groupLabelId = inputId ? `${inputId}-group-label` : undefined;
|
||||
const allSelected = options.every(opt => value.includes(opt.value));
|
||||
const someSelected = options.some(opt => value.includes(opt.value)) && !allSelected;
|
||||
|
||||
// Update indeterminate state for group select all
|
||||
useEffect(() => {
|
||||
if (checkboxRef.current && someSelected) {
|
||||
checkboxRef.current.indeterminate = true;
|
||||
}
|
||||
}, [someSelected, value, options]);
|
||||
|
||||
return (
|
||||
<div className={groupContainerClasses}>
|
||||
{label && (
|
||||
<div className="mb-2">
|
||||
<FormLabel id={groupLabelId} required={required}>
|
||||
{label}
|
||||
</FormLabel>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2" role="group" aria-labelledby={groupLabelId}>
|
||||
{options.map((option) => {
|
||||
const optionId = `${inputId}-${option.value}`;
|
||||
const isChecked = value.includes(option.value);
|
||||
|
||||
return (
|
||||
<div key={option.value} className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={optionId}
|
||||
value={option.value}
|
||||
checked={isChecked}
|
||||
onChange={handleGroupChange}
|
||||
disabled={disabled || option.disabled}
|
||||
required={required && value.length === 0}
|
||||
aria-invalid={hasError}
|
||||
className={cn(
|
||||
baseCheckboxClasses,
|
||||
option.disabled && 'opacity-50'
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor={optionId}
|
||||
className={cn(
|
||||
labelClasses,
|
||||
option.disabled && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{helpText && (
|
||||
<p className="text-xs text-text-secondary mt-1" id={`${inputId}-help`}>
|
||||
{helpText}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{showError && (
|
||||
<FormError errors={error} id={`${inputId}-error`} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
FormCheckbox.displayName = 'FormCheckbox';
|
||||
|
||||
export default FormCheckbox;
|
||||
89
components/forms/FormError.tsx
Normal file
89
components/forms/FormError.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* FormError Component
|
||||
* Display error messages with different variants and animations
|
||||
*/
|
||||
|
||||
export interface FormErrorProps {
|
||||
errors?: string | string[];
|
||||
variant?: 'inline' | 'block' | 'toast';
|
||||
className?: string;
|
||||
showIcon?: boolean;
|
||||
animate?: boolean;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export const FormError: React.FC<FormErrorProps> = ({
|
||||
errors,
|
||||
variant = 'inline',
|
||||
className,
|
||||
showIcon = true,
|
||||
animate = true,
|
||||
id,
|
||||
}) => {
|
||||
if (!errors || (Array.isArray(errors) && errors.length === 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const errorArray = Array.isArray(errors) ? errors : [errors];
|
||||
const hasMultipleErrors = errorArray.length > 1;
|
||||
|
||||
const baseClasses = {
|
||||
inline: 'text-sm text-danger mt-1',
|
||||
block: 'p-3 bg-danger/10 border border-danger/20 rounded-md text-danger text-sm',
|
||||
toast: 'fixed bottom-4 right-4 p-4 bg-danger text-white rounded-lg shadow-lg max-w-md z-tooltip animate-slide-up',
|
||||
};
|
||||
|
||||
const animationClasses = animate ? 'animate-fade-in' : '';
|
||||
|
||||
const Icon = () => (
|
||||
<svg
|
||||
className="w-4 h-4 mr-1 inline-block"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<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>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
id={id}
|
||||
className={cn(
|
||||
baseClasses[variant],
|
||||
animationClasses,
|
||||
'transition-all duration-200',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{hasMultipleErrors ? (
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
{errorArray.map((error, index) => (
|
||||
<li key={index} className="flex items-start">
|
||||
{showIcon && <Icon />}
|
||||
<span>{error}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="flex items-start">
|
||||
{showIcon && <Icon />}
|
||||
<span>{errorArray[0]}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
FormError.displayName = 'FormError';
|
||||
|
||||
export default FormError;
|
||||
795
components/forms/FormExamples.tsx
Normal file
795
components/forms/FormExamples.tsx
Normal file
@@ -0,0 +1,795 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
FormField,
|
||||
useForm,
|
||||
useFormWithHelpers,
|
||||
type ValidationRules
|
||||
} from './index';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
|
||||
import { Container } from '@/components/ui/Container';
|
||||
|
||||
/**
|
||||
* Form Examples
|
||||
* Comprehensive examples showing all form patterns and usage
|
||||
*/
|
||||
|
||||
// Example 1: Simple Contact Form
|
||||
export const ContactFormExample: React.FC = () => {
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
name: '',
|
||||
email: '',
|
||||
message: '',
|
||||
},
|
||||
validationRules: {
|
||||
name: { required: 'Name is required', minLength: { value: 2, message: 'Name must be at least 2 characters' } },
|
||||
email: { required: 'Email is required', email: true },
|
||||
message: { required: 'Message is required', minLength: { value: 10, message: 'Message must be at least 10 characters' } },
|
||||
},
|
||||
onSubmit: async (values) => {
|
||||
console.log('Form submitted:', values);
|
||||
// Simulate API call
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
alert('Form submitted successfully!');
|
||||
form.reset();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-xl font-bold">Contact Form</h3>
|
||||
<p className="text-sm text-text-secondary">Simple contact form with validation</p>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<form {...form.getFormProps()} className="space-y-4">
|
||||
<FormField
|
||||
type="text"
|
||||
name="name"
|
||||
label="Full Name"
|
||||
placeholder="Enter your name"
|
||||
required
|
||||
value={form.values.name}
|
||||
error={form.errors.name?.[0]}
|
||||
touched={form.touched.name}
|
||||
onChange={(e) => form.setFieldValue('name', e.target.value)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
type="email"
|
||||
name="email"
|
||||
label="Email Address"
|
||||
placeholder="your@email.com"
|
||||
required
|
||||
value={form.values.email}
|
||||
error={form.errors.email?.[0]}
|
||||
touched={form.touched.email}
|
||||
onChange={(e) => form.setFieldValue('email', e.target.value)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
type="textarea"
|
||||
name="message"
|
||||
label="Message"
|
||||
placeholder="How can we help you?"
|
||||
required
|
||||
rows={5}
|
||||
showCharCount
|
||||
maxLength={500}
|
||||
value={form.values.message}
|
||||
error={form.errors.message?.[0]}
|
||||
touched={form.touched.message}
|
||||
onChange={(e) => form.setFieldValue('message', e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={form.isSubmitting || !form.isValid}
|
||||
loading={form.isSubmitting}
|
||||
>
|
||||
Send Message
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={form.reset}
|
||||
disabled={form.isSubmitting}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
// Example 2: Registration Form with Multiple Field Types
|
||||
export const RegistrationFormExample: React.FC = () => {
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
country: '',
|
||||
interests: [] as string[],
|
||||
newsletter: false,
|
||||
terms: false,
|
||||
},
|
||||
validationRules: {
|
||||
firstName: { required: true, minLength: { value: 2, message: 'Too short' } },
|
||||
lastName: { required: true, minLength: { value: 2, message: 'Too short' } },
|
||||
email: { required: true, email: true },
|
||||
password: {
|
||||
required: true,
|
||||
minLength: { value: 8, message: 'Password must be at least 8 characters' },
|
||||
pattern: { value: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, message: 'Must contain uppercase, lowercase, and number' }
|
||||
},
|
||||
confirmPassword: {
|
||||
required: true,
|
||||
custom: (value) => value === form.values.password ? null : 'Passwords do not match'
|
||||
},
|
||||
country: { required: 'Please select your country' },
|
||||
interests: { required: 'Select at least one interest' },
|
||||
newsletter: {},
|
||||
terms: {
|
||||
required: 'You must accept the terms',
|
||||
custom: (value) => value ? null : 'You must accept the terms'
|
||||
},
|
||||
},
|
||||
onSubmit: async (values) => {
|
||||
console.log('Registration:', values);
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
alert('Registration successful!');
|
||||
form.reset();
|
||||
},
|
||||
});
|
||||
|
||||
const countryOptions = [
|
||||
{ value: 'de', label: 'Germany' },
|
||||
{ value: 'us', label: 'United States' },
|
||||
{ value: 'uk', label: 'United Kingdom' },
|
||||
{ value: 'fr', label: 'France' },
|
||||
];
|
||||
|
||||
const interestOptions = [
|
||||
{ value: 'technology', label: 'Technology' },
|
||||
{ value: 'business', label: 'Business' },
|
||||
{ value: 'innovation', label: 'Innovation' },
|
||||
{ value: 'sustainability', label: 'Sustainability' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-xl font-bold">Registration Form</h3>
|
||||
<p className="text-sm text-text-secondary">Complete registration with multiple field types</p>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<form {...form.getFormProps()} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
type="text"
|
||||
name="firstName"
|
||||
label="First Name"
|
||||
required
|
||||
value={form.values.firstName}
|
||||
error={form.errors.firstName?.[0]}
|
||||
onChange={(e) => form.setFieldValue('firstName', e.target.value)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
type="text"
|
||||
name="lastName"
|
||||
label="Last Name"
|
||||
required
|
||||
value={form.values.lastName}
|
||||
error={form.errors.lastName?.[0]}
|
||||
onChange={(e) => form.setFieldValue('lastName', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
type="email"
|
||||
name="email"
|
||||
label="Email Address"
|
||||
required
|
||||
value={form.values.email}
|
||||
error={form.errors.email?.[0]}
|
||||
onChange={(e) => form.setFieldValue('email', e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
type="password"
|
||||
name="password"
|
||||
label="Password"
|
||||
required
|
||||
helpText="Min 8 chars with uppercase, lowercase, and number"
|
||||
value={form.values.password}
|
||||
error={form.errors.password?.[0]}
|
||||
onChange={(e) => form.setFieldValue('password', e.target.value)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
label="Confirm Password"
|
||||
required
|
||||
value={form.values.confirmPassword}
|
||||
error={form.errors.confirmPassword?.[0]}
|
||||
onChange={(e) => form.setFieldValue('confirmPassword', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
type="select"
|
||||
name="country"
|
||||
label="Country"
|
||||
required
|
||||
options={countryOptions}
|
||||
value={form.values.country}
|
||||
error={form.errors.country?.[0]}
|
||||
onChange={(e) => form.setFieldValue('country', e.target.value)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
type="checkbox"
|
||||
name="interests"
|
||||
label="Areas of Interest"
|
||||
required
|
||||
options={interestOptions}
|
||||
value={form.values.interests}
|
||||
error={form.errors.interests?.[0]}
|
||||
onChange={(values) => form.setFieldValue('interests', values)}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<FormField
|
||||
type="checkbox"
|
||||
name="newsletter"
|
||||
label="Subscribe to newsletter"
|
||||
checked={form.values.newsletter}
|
||||
onChange={(values) => form.setFieldValue('newsletter', values.length > 0)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
type="checkbox"
|
||||
name="terms"
|
||||
label="I accept the terms and conditions"
|
||||
required
|
||||
checked={form.values.terms}
|
||||
error={form.errors.terms?.[0]}
|
||||
onChange={(values) => form.setFieldValue('terms', values.length > 0)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-4 border-t border-neutral-dark">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={form.isSubmitting || !form.isValid}
|
||||
loading={form.isSubmitting}
|
||||
>
|
||||
Register
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={form.reset}
|
||||
disabled={form.isSubmitting}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
// Example 3: Search and Filter Form
|
||||
export const SearchFormExample: React.FC = () => {
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
search: '',
|
||||
category: '',
|
||||
status: '',
|
||||
sortBy: 'name',
|
||||
},
|
||||
validationRules: {
|
||||
search: {},
|
||||
category: {},
|
||||
status: {},
|
||||
sortBy: {},
|
||||
},
|
||||
onSubmit: async (values) => {
|
||||
console.log('Search filters:', values);
|
||||
// Handle search/filter logic
|
||||
},
|
||||
});
|
||||
|
||||
const categoryOptions = [
|
||||
{ value: '', label: 'All Categories' },
|
||||
{ value: 'cables', label: 'Cables' },
|
||||
{ value: 'connectors', label: 'Connectors' },
|
||||
{ value: 'accessories', label: 'Accessories' },
|
||||
];
|
||||
|
||||
const statusOptions = [
|
||||
{ value: '', label: 'All Status' },
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'inactive', label: 'Inactive' },
|
||||
];
|
||||
|
||||
const sortOptions = [
|
||||
{ value: 'name', label: 'Name (A-Z)' },
|
||||
{ value: 'name-desc', label: 'Name (Z-A)' },
|
||||
{ value: 'date', label: 'Date (Newest)' },
|
||||
{ value: 'date-asc', label: 'Date (Oldest)' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-xl font-bold">Search & Filter</h3>
|
||||
<p className="text-sm text-text-secondary">Advanced search with multiple filters</p>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<form {...form.getFormProps()} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<FormField
|
||||
type="text"
|
||||
name="search"
|
||||
label="Search"
|
||||
placeholder="Search products..."
|
||||
prefix={
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
}
|
||||
showClear
|
||||
value={form.values.search}
|
||||
onChange={(e) => form.setFieldValue('search', e.target.value)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
type="select"
|
||||
name="category"
|
||||
label="Category"
|
||||
options={categoryOptions}
|
||||
value={form.values.category}
|
||||
onChange={(e) => form.setFieldValue('category', e.target.value)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
type="select"
|
||||
name="status"
|
||||
label="Status"
|
||||
options={statusOptions}
|
||||
value={form.values.status}
|
||||
onChange={(e) => form.setFieldValue('status', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 items-center justify-between">
|
||||
<FormField
|
||||
type="select"
|
||||
name="sortBy"
|
||||
label="Sort By"
|
||||
options={sortOptions}
|
||||
value={form.values.sortBy}
|
||||
onChange={(e) => form.setFieldValue('sortBy', e.target.value)}
|
||||
containerClassName="w-48"
|
||||
/>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
Apply Filters
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={form.reset}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
// Example 4: Radio Button Form
|
||||
export const RadioFormExample: React.FC = () => {
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
paymentMethod: '',
|
||||
shippingMethod: '',
|
||||
deliveryTime: '',
|
||||
},
|
||||
validationRules: {
|
||||
paymentMethod: { required: 'Please select a payment method' },
|
||||
shippingMethod: { required: 'Please select a shipping method' },
|
||||
deliveryTime: { required: 'Please select preferred delivery time' },
|
||||
},
|
||||
onSubmit: async (values) => {
|
||||
console.log('Selections:', values);
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
alert('Preferences saved!');
|
||||
},
|
||||
});
|
||||
|
||||
const paymentOptions = [
|
||||
{ value: 'credit-card', label: 'Credit Card', description: 'Visa, Mastercard, Amex' },
|
||||
{ value: 'paypal', label: 'PayPal', description: 'Secure payment via PayPal' },
|
||||
{ value: 'bank-transfer', label: 'Bank Transfer', description: 'Direct bank transfer' },
|
||||
];
|
||||
|
||||
const shippingOptions = [
|
||||
{ value: 'standard', label: 'Standard (5-7 days)', description: 'Free shipping on orders over €50' },
|
||||
{ value: 'express', label: 'Express (2-3 days)', description: '€9.99 shipping fee' },
|
||||
{ value: 'overnight', label: 'Overnight', description: '€24.99 shipping fee' },
|
||||
];
|
||||
|
||||
const deliveryOptions = [
|
||||
{ value: 'morning', label: 'Morning (8am-12pm)' },
|
||||
{ value: 'afternoon', label: 'Afternoon (12pm-6pm)' },
|
||||
{ value: 'evening', label: 'Evening (6pm-9pm)' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-xl font-bold">Preferences Selection</h3>
|
||||
<p className="text-sm text-text-secondary">Radio buttons for single choice selection</p>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<form {...form.getFormProps()} className="space-y-6">
|
||||
<FormField
|
||||
type="radio"
|
||||
name="paymentMethod"
|
||||
label="Payment Method"
|
||||
required
|
||||
options={paymentOptions}
|
||||
value={form.values.paymentMethod}
|
||||
error={form.errors.paymentMethod?.[0]}
|
||||
onChange={(value) => form.setFieldValue('paymentMethod', value)}
|
||||
layout="vertical"
|
||||
/>
|
||||
|
||||
<FormField
|
||||
type="radio"
|
||||
name="shippingMethod"
|
||||
label="Shipping Method"
|
||||
required
|
||||
options={shippingOptions}
|
||||
value={form.values.shippingMethod}
|
||||
error={form.errors.shippingMethod?.[0]}
|
||||
onChange={(value) => form.setFieldValue('shippingMethod', value)}
|
||||
layout="vertical"
|
||||
/>
|
||||
|
||||
<FormField
|
||||
type="radio"
|
||||
name="deliveryTime"
|
||||
label="Preferred Delivery Time"
|
||||
required
|
||||
options={deliveryOptions}
|
||||
value={form.values.deliveryTime}
|
||||
error={form.errors.deliveryTime?.[0]}
|
||||
onChange={(value) => form.setFieldValue('deliveryTime', value)}
|
||||
layout="horizontal"
|
||||
/>
|
||||
|
||||
<div className="flex gap-2 pt-4 border-t border-neutral-dark">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={form.isSubmitting || !form.isValid}
|
||||
loading={form.isSubmitting}
|
||||
>
|
||||
Save Preferences
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={form.reset}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
// Example 5: Complete Form with All Features
|
||||
export const CompleteFormExample: React.FC = () => {
|
||||
const [submittedData, setSubmittedData] = useState<any>(null);
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
// Text inputs
|
||||
fullName: '',
|
||||
phone: '',
|
||||
website: '',
|
||||
|
||||
// Textarea
|
||||
description: '',
|
||||
|
||||
// Select
|
||||
industry: '',
|
||||
budget: '',
|
||||
|
||||
// Checkbox group
|
||||
services: [] as string[],
|
||||
|
||||
// Radio
|
||||
contactPreference: '',
|
||||
|
||||
// Single checkbox
|
||||
agreeToTerms: false,
|
||||
},
|
||||
validationRules: {
|
||||
fullName: { required: true, minLength: { value: 3, message: 'Minimum 3 characters' } },
|
||||
phone: { required: true, pattern: { value: /^[0-9+\-\s()]+$/, message: 'Invalid phone format' } },
|
||||
website: { url: 'Invalid URL format' },
|
||||
description: { required: true, minLength: { value: 20, message: 'Please provide more details' } },
|
||||
industry: { required: true },
|
||||
budget: { required: true },
|
||||
services: { required: 'Select at least one service' },
|
||||
contactPreference: { required: true },
|
||||
agreeToTerms: {
|
||||
required: 'You must accept the terms',
|
||||
custom: (value) => value ? null : 'Required'
|
||||
},
|
||||
},
|
||||
onSubmit: async (values) => {
|
||||
console.log('Complete form submitted:', values);
|
||||
setSubmittedData(values);
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
alert('Form submitted successfully! Check console for data.');
|
||||
form.reset();
|
||||
},
|
||||
});
|
||||
|
||||
const industryOptions = [
|
||||
{ value: '', label: 'Select Industry' },
|
||||
{ value: 'manufacturing', label: 'Manufacturing' },
|
||||
{ value: 'construction', label: 'Construction' },
|
||||
{ value: 'energy', label: 'Energy' },
|
||||
{ value: 'technology', label: 'Technology' },
|
||||
];
|
||||
|
||||
const budgetOptions = [
|
||||
{ value: '', label: 'Select Budget Range' },
|
||||
{ value: 'small', label: '€1,000 - €5,000' },
|
||||
{ value: 'medium', label: '€5,000 - €20,000' },
|
||||
{ value: 'large', label: '€20,000+' },
|
||||
];
|
||||
|
||||
const serviceOptions = [
|
||||
{ value: 'consulting', label: 'Consulting' },
|
||||
{ value: 'installation', label: 'Installation' },
|
||||
{ value: 'maintenance', label: 'Maintenance' },
|
||||
{ value: 'training', label: 'Training' },
|
||||
{ value: 'support', label: '24/7 Support' },
|
||||
];
|
||||
|
||||
const contactOptions = [
|
||||
{ value: 'email', label: 'Email', description: 'We\'ll respond within 24 hours' },
|
||||
{ value: 'phone', label: 'Phone', description: 'Call us during business hours' },
|
||||
{ value: 'both', label: 'Both', description: 'Email and phone contact' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-xl font-bold">Complete Form Example</h3>
|
||||
<p className="text-sm text-text-secondary">All form components working together</p>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<form {...form.getFormProps()} className="space-y-6">
|
||||
{/* Personal Information */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-semibold text-lg">Personal Information</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
type="text"
|
||||
name="fullName"
|
||||
label="Full Name"
|
||||
required
|
||||
placeholder="John Doe"
|
||||
value={form.values.fullName}
|
||||
error={form.errors.fullName?.[0]}
|
||||
onChange={(e) => form.setFieldValue('fullName', e.target.value)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
type="tel"
|
||||
name="phone"
|
||||
label="Phone Number"
|
||||
required
|
||||
placeholder="+1 234 567 8900"
|
||||
value={form.values.phone}
|
||||
error={form.errors.phone?.[0]}
|
||||
onChange={(e) => form.setFieldValue('phone', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
type="url"
|
||||
name="website"
|
||||
label="Website (Optional)"
|
||||
placeholder="https://example.com"
|
||||
showClear
|
||||
value={form.values.website}
|
||||
error={form.errors.website?.[0]}
|
||||
onChange={(e) => form.setFieldValue('website', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Business Information */}
|
||||
<div className="space-y-4 pt-4 border-t border-neutral-dark">
|
||||
<h4 className="font-semibold text-lg">Business Information</h4>
|
||||
|
||||
<FormField
|
||||
type="textarea"
|
||||
name="description"
|
||||
label="Project Description"
|
||||
required
|
||||
rows={5}
|
||||
showCharCount
|
||||
maxLength={500}
|
||||
placeholder="Describe your project requirements..."
|
||||
value={form.values.description}
|
||||
error={form.errors.description?.[0]}
|
||||
onChange={(e) => form.setFieldValue('description', e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
type="select"
|
||||
name="industry"
|
||||
label="Industry"
|
||||
required
|
||||
options={industryOptions}
|
||||
value={form.values.industry}
|
||||
error={form.errors.industry?.[0]}
|
||||
onChange={(e) => form.setFieldValue('industry', e.target.value)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
type="select"
|
||||
name="budget"
|
||||
label="Budget Range"
|
||||
required
|
||||
options={budgetOptions}
|
||||
value={form.values.budget}
|
||||
error={form.errors.budget?.[0]}
|
||||
onChange={(e) => form.setFieldValue('budget', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Services & Preferences */}
|
||||
<div className="space-y-4 pt-4 border-t border-neutral-dark">
|
||||
<h4 className="font-semibold text-lg">Services & Preferences</h4>
|
||||
|
||||
<FormField
|
||||
type="checkbox"
|
||||
name="services"
|
||||
label="Required Services"
|
||||
required
|
||||
options={serviceOptions}
|
||||
value={form.values.services}
|
||||
error={form.errors.services?.[0]}
|
||||
onChange={(values) => form.setFieldValue('services', values)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
type="radio"
|
||||
name="contactPreference"
|
||||
label="Preferred Contact Method"
|
||||
required
|
||||
options={contactOptions}
|
||||
value={form.values.contactPreference}
|
||||
error={form.errors.contactPreference?.[0]}
|
||||
onChange={(value) => form.setFieldValue('contactPreference', value)}
|
||||
layout="vertical"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Terms */}
|
||||
<div className="space-y-4 pt-4 border-t border-neutral-dark">
|
||||
<FormField
|
||||
type="checkbox"
|
||||
name="agreeToTerms"
|
||||
label="I agree to the terms and conditions and privacy policy"
|
||||
required
|
||||
checked={form.values.agreeToTerms}
|
||||
error={form.errors.agreeToTerms?.[0]}
|
||||
onChange={(values) => form.setFieldValue('agreeToTerms', values.length > 0)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-4 border-t border-neutral-dark">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={form.isSubmitting || !form.isValid}
|
||||
loading={form.isSubmitting}
|
||||
>
|
||||
Submit Application
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={form.reset}
|
||||
disabled={form.isSubmitting}
|
||||
>
|
||||
Reset Form
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* Debug Output */}
|
||||
{submittedData && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h4 className="font-semibold">Submitted Data</h4>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<pre className="bg-neutral-dark p-4 rounded-md overflow-x-auto text-sm">
|
||||
{JSON.stringify(submittedData, null, 2)}
|
||||
</pre>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Main Examples Page Component
|
||||
export const FormExamplesPage: React.FC = () => {
|
||||
return (
|
||||
<Container className="py-8">
|
||||
<div className="space-y-8">
|
||||
<div className="text-center space-y-2">
|
||||
<h1 className="text-4xl font-bold">Form System Examples</h1>
|
||||
<p className="text-lg text-text-secondary">
|
||||
Comprehensive examples of all form components and patterns
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-8">
|
||||
<ContactFormExample />
|
||||
<RegistrationFormExample />
|
||||
<SearchFormExample />
|
||||
<RadioFormExample />
|
||||
<CompleteFormExample />
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default FormExamplesPage;
|
||||
218
components/forms/FormField.tsx
Normal file
218
components/forms/FormField.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import FormLabel from './FormLabel';
|
||||
import FormError from './FormError';
|
||||
import FormInput from './FormInput';
|
||||
import FormTextarea from './FormTextarea';
|
||||
import FormSelect from './FormSelect';
|
||||
import FormCheckbox from './FormCheckbox';
|
||||
import FormRadio from './FormRadio';
|
||||
|
||||
/**
|
||||
* FormField Component
|
||||
* Wrapper for form fields with label, input, and error
|
||||
* Supports different input types and provides consistent form experience
|
||||
*/
|
||||
|
||||
export type FormFieldType =
|
||||
| 'text'
|
||||
| 'email'
|
||||
| 'tel'
|
||||
| 'textarea'
|
||||
| 'select'
|
||||
| 'checkbox'
|
||||
| 'radio'
|
||||
| 'number'
|
||||
| 'password'
|
||||
| 'date'
|
||||
| 'time'
|
||||
| 'url';
|
||||
|
||||
export interface FormFieldProps {
|
||||
type?: FormFieldType;
|
||||
label?: string;
|
||||
name: string;
|
||||
value?: any;
|
||||
error?: string | string[];
|
||||
helpText?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
containerClassName?: string;
|
||||
|
||||
// For select, checkbox, radio
|
||||
options?: any[];
|
||||
|
||||
// For select
|
||||
multiple?: boolean;
|
||||
showSearch?: boolean;
|
||||
|
||||
// For checkbox/radio
|
||||
layout?: 'vertical' | 'horizontal';
|
||||
|
||||
// For textarea
|
||||
rows?: number;
|
||||
showCharCount?: boolean;
|
||||
autoResize?: boolean;
|
||||
maxLength?: number;
|
||||
|
||||
// For input
|
||||
prefix?: React.ReactNode;
|
||||
suffix?: React.ReactNode;
|
||||
showClear?: boolean;
|
||||
iconPosition?: 'prefix' | 'suffix';
|
||||
|
||||
// Callbacks
|
||||
onChange?: (value: any) => void;
|
||||
onBlur?: () => void;
|
||||
onClear?: () => void;
|
||||
|
||||
// Additional props
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export const FormField: React.FC<FormFieldProps> = ({
|
||||
type = 'text',
|
||||
label,
|
||||
name,
|
||||
value,
|
||||
error,
|
||||
helpText,
|
||||
required = false,
|
||||
disabled = false,
|
||||
placeholder,
|
||||
className,
|
||||
containerClassName,
|
||||
options = [],
|
||||
multiple = false,
|
||||
showSearch = false,
|
||||
layout = 'vertical',
|
||||
rows = 4,
|
||||
showCharCount = false,
|
||||
autoResize = false,
|
||||
maxLength,
|
||||
prefix,
|
||||
suffix,
|
||||
showClear = false,
|
||||
iconPosition = 'prefix',
|
||||
onChange,
|
||||
onBlur,
|
||||
onClear,
|
||||
...props
|
||||
}) => {
|
||||
const commonProps = {
|
||||
name,
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
disabled,
|
||||
required,
|
||||
placeholder,
|
||||
'aria-label': label,
|
||||
};
|
||||
|
||||
const renderInput = () => {
|
||||
switch (type) {
|
||||
case 'textarea':
|
||||
return (
|
||||
<FormTextarea
|
||||
{...commonProps}
|
||||
error={error}
|
||||
helpText={helpText}
|
||||
rows={rows}
|
||||
showCharCount={showCharCount}
|
||||
autoResize={autoResize}
|
||||
maxLength={maxLength}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'select':
|
||||
return (
|
||||
<FormSelect
|
||||
{...commonProps}
|
||||
error={error}
|
||||
helpText={helpText}
|
||||
options={options}
|
||||
multiple={multiple}
|
||||
showSearch={showSearch}
|
||||
placeholder={placeholder}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'checkbox':
|
||||
return (
|
||||
<FormCheckbox
|
||||
label={label}
|
||||
error={error}
|
||||
helpText={helpText}
|
||||
required={required}
|
||||
checked={Array.isArray(value) ? value.length > 0 : !!value}
|
||||
options={options}
|
||||
value={Array.isArray(value) ? value : []}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
containerClassName={className}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'radio':
|
||||
return (
|
||||
<FormRadio
|
||||
label={label}
|
||||
error={error}
|
||||
helpText={helpText}
|
||||
required={required}
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
layout={layout}
|
||||
containerClassName={className}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<FormInput
|
||||
{...commonProps}
|
||||
type={type}
|
||||
error={error}
|
||||
helpText={helpText}
|
||||
label={label}
|
||||
prefix={prefix}
|
||||
suffix={suffix}
|
||||
showClear={showClear}
|
||||
iconPosition={iconPosition}
|
||||
onClear={onClear}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// For checkbox and radio, the label is handled internally
|
||||
const showExternalLabel = type !== 'checkbox' && type !== 'radio';
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-1.5', containerClassName)}>
|
||||
{showExternalLabel && label && (
|
||||
<FormLabel htmlFor={name} required={required}>
|
||||
{label}
|
||||
</FormLabel>
|
||||
)}
|
||||
|
||||
{renderInput()}
|
||||
|
||||
{!showExternalLabel && error && (
|
||||
<FormError errors={error} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
FormField.displayName = 'FormField';
|
||||
|
||||
export default FormField;
|
||||
178
components/forms/FormInput.tsx
Normal file
178
components/forms/FormInput.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import FormLabel from './FormLabel';
|
||||
import FormError from './FormError';
|
||||
|
||||
/**
|
||||
* FormInput Component
|
||||
* Base input component with all HTML5 input types, validation states, icons, and clear button
|
||||
*/
|
||||
|
||||
export interface FormInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'prefix' | 'suffix'> {
|
||||
label?: string;
|
||||
error?: string | string[];
|
||||
helpText?: string;
|
||||
required?: boolean;
|
||||
prefix?: React.ReactNode;
|
||||
suffix?: React.ReactNode;
|
||||
showClear?: boolean;
|
||||
iconPosition?: 'prefix' | 'suffix';
|
||||
containerClassName?: string;
|
||||
inputClassName?: string;
|
||||
onClear?: () => void;
|
||||
}
|
||||
|
||||
export const FormInput: React.FC<FormInputProps> = ({
|
||||
label,
|
||||
error,
|
||||
helpText,
|
||||
required = false,
|
||||
prefix,
|
||||
suffix,
|
||||
showClear = false,
|
||||
iconPosition = 'prefix',
|
||||
containerClassName,
|
||||
inputClassName,
|
||||
onClear,
|
||||
disabled = false,
|
||||
value = '',
|
||||
onChange,
|
||||
...props
|
||||
}) => {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
const hasError = !!error;
|
||||
const showError = hasError;
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
if (onChange) {
|
||||
const syntheticEvent = {
|
||||
target: { value: '', name: props.name, type: props.type },
|
||||
currentTarget: { value: '', name: props.name, type: props.type },
|
||||
} as React.ChangeEvent<HTMLInputElement>;
|
||||
onChange(syntheticEvent);
|
||||
}
|
||||
if (onClear) {
|
||||
onClear();
|
||||
}
|
||||
}, [onChange, onClear, props.name, props.type]);
|
||||
|
||||
const handleFocus = () => setIsFocused(true);
|
||||
const handleBlur = () => setIsFocused(false);
|
||||
|
||||
const inputId = props.id || (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined);
|
||||
|
||||
const baseInputClasses = cn(
|
||||
'w-full px-3 py-2 border rounded-md transition-all duration-200',
|
||||
'bg-neutral-light text-text-primary',
|
||||
'placeholder:text-text-light',
|
||||
'focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary',
|
||||
'disabled:opacity-60 disabled:cursor-not-allowed',
|
||||
{
|
||||
'border-neutral-dark hover:border-neutral-dark': !hasError && !isFocused,
|
||||
'border-primary ring-2 ring-primary': isFocused && !hasError,
|
||||
'border-danger ring-2 ring-danger/20': hasError,
|
||||
'pl-10': prefix && iconPosition === 'prefix',
|
||||
'pr-10': (suffix && iconPosition === 'suffix') || (showClear && value),
|
||||
},
|
||||
inputClassName
|
||||
);
|
||||
|
||||
const containerClasses = cn(
|
||||
'flex flex-col gap-1.5',
|
||||
containerClassName
|
||||
);
|
||||
|
||||
const iconWrapperClasses = cn(
|
||||
'absolute top-1/2 -translate-y-1/2 flex items-center pointer-events-none text-text-secondary',
|
||||
{
|
||||
'left-3': iconPosition === 'prefix',
|
||||
'right-3': iconPosition === 'suffix',
|
||||
}
|
||||
);
|
||||
|
||||
const clearButtonClasses = cn(
|
||||
'absolute top-1/2 -translate-y-1/2 right-2',
|
||||
'p-1 rounded-md hover:bg-neutral-dark transition-colors',
|
||||
'text-text-secondary hover:text-text-primary',
|
||||
'focus:outline-none focus:ring-2 focus:ring-primary'
|
||||
);
|
||||
|
||||
const showPrefix = prefix && iconPosition === 'prefix';
|
||||
const showSuffix = suffix && iconPosition === 'suffix';
|
||||
const showClearButton = showClear && value && !disabled;
|
||||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
{label && (
|
||||
<FormLabel htmlFor={inputId} required={required}>
|
||||
{label}
|
||||
</FormLabel>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
{showPrefix && (
|
||||
<div className={iconWrapperClasses}>
|
||||
{prefix}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
id={inputId}
|
||||
className={baseInputClasses}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
disabled={disabled}
|
||||
aria-invalid={hasError}
|
||||
aria-describedby={helpText || showError ? `${inputId}-error` : undefined}
|
||||
required={required}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
{showSuffix && (
|
||||
<div className={cn(iconWrapperClasses, 'right-3 left-auto')}>
|
||||
{suffix}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showClearButton && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className={clearButtonClasses}
|
||||
aria-label="Clear input"
|
||||
disabled={disabled}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{helpText && (
|
||||
<p className="text-xs text-text-secondary" id={`${inputId}-help`}>
|
||||
{helpText}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{showError && (
|
||||
<FormError errors={error} id={`${inputId}-error`} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
FormInput.displayName = 'FormInput';
|
||||
|
||||
export default FormInput;
|
||||
61
components/forms/FormLabel.tsx
Normal file
61
components/forms/FormLabel.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* FormLabel Component
|
||||
* Consistent label styling with required indicator and help text tooltip
|
||||
*/
|
||||
|
||||
export interface FormLabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {
|
||||
htmlFor?: string;
|
||||
required?: boolean;
|
||||
helpText?: string;
|
||||
optionalText?: string;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const FormLabel: React.FC<FormLabelProps> = ({
|
||||
htmlFor,
|
||||
required = false,
|
||||
helpText,
|
||||
optionalText = '(optional)',
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<label
|
||||
htmlFor={htmlFor}
|
||||
className={cn(
|
||||
'block text-sm font-semibold text-text-primary mb-2',
|
||||
'font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{children}
|
||||
{required && (
|
||||
<span className="text-danger" aria-label="required">
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
{!required && optionalText && (
|
||||
<span className="text-xs text-text-secondary font-normal">
|
||||
{optionalText}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{helpText && (
|
||||
<span className="ml-2 text-xs text-text-secondary font-normal">
|
||||
{helpText}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
FormLabel.displayName = 'FormLabel';
|
||||
|
||||
export default FormLabel;
|
||||
192
components/forms/FormRadio.tsx
Normal file
192
components/forms/FormRadio.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import FormLabel from './FormLabel';
|
||||
import FormError from './FormError';
|
||||
|
||||
/**
|
||||
* FormRadio Component
|
||||
* Radio button group with custom styling and keyboard navigation
|
||||
*/
|
||||
|
||||
export interface RadioOption {
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface FormRadioProps {
|
||||
label?: string;
|
||||
error?: string | string[];
|
||||
helpText?: string;
|
||||
required?: boolean;
|
||||
options: RadioOption[];
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
containerClassName?: string;
|
||||
radioClassName?: string;
|
||||
disabled?: boolean;
|
||||
id?: string;
|
||||
name?: string;
|
||||
layout?: 'vertical' | 'horizontal';
|
||||
}
|
||||
|
||||
export const FormRadio: React.FC<FormRadioProps> = ({
|
||||
label,
|
||||
error,
|
||||
helpText,
|
||||
required = false,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
containerClassName,
|
||||
radioClassName,
|
||||
disabled = false,
|
||||
id,
|
||||
name,
|
||||
layout = 'vertical',
|
||||
}) => {
|
||||
const hasError = !!error;
|
||||
const showError = hasError;
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (onChange) {
|
||||
onChange(e.target.value);
|
||||
}
|
||||
};
|
||||
|
||||
const inputId = id || (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined);
|
||||
const groupName = name || inputId;
|
||||
|
||||
const baseRadioClasses = cn(
|
||||
'w-4 h-4 border rounded-full transition-all duration-200',
|
||||
'focus:outline-none focus:ring-2 focus:ring-primary',
|
||||
{
|
||||
'border-neutral-dark bg-neutral-light': !hasError,
|
||||
'border-danger': hasError,
|
||||
'opacity-60 cursor-not-allowed': disabled,
|
||||
'cursor-pointer': !disabled,
|
||||
},
|
||||
radioClassName
|
||||
);
|
||||
|
||||
const selectedIndicatorClasses = cn(
|
||||
'w-2.5 h-2.5 rounded-full bg-primary transition-all duration-200',
|
||||
{
|
||||
'scale-0': false,
|
||||
'scale-100': true,
|
||||
}
|
||||
);
|
||||
|
||||
const containerClasses = cn(
|
||||
'flex flex-col gap-2',
|
||||
{
|
||||
'gap-3': layout === 'vertical',
|
||||
'gap-4 flex-row flex-wrap': layout === 'horizontal',
|
||||
},
|
||||
containerClassName
|
||||
);
|
||||
|
||||
const optionWrapperClasses = cn(
|
||||
'flex items-start gap-2',
|
||||
{
|
||||
'opacity-60': disabled,
|
||||
}
|
||||
);
|
||||
|
||||
const labelClasses = cn(
|
||||
'text-sm font-medium leading-none cursor-pointer',
|
||||
{
|
||||
'text-text-primary': !hasError,
|
||||
'text-danger': hasError,
|
||||
}
|
||||
);
|
||||
|
||||
const descriptionClasses = 'text-xs text-text-secondary mt-0.5';
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-1.5', containerClassName)}>
|
||||
{label && (
|
||||
<FormLabel htmlFor={inputId} required={required}>
|
||||
{label}
|
||||
</FormLabel>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={containerClasses}
|
||||
role="radiogroup"
|
||||
aria-labelledby={inputId ? `${inputId}-label` : undefined}
|
||||
aria-invalid={hasError}
|
||||
>
|
||||
{options.map((option) => {
|
||||
const optionId = `${inputId}-${option.value}`;
|
||||
const isChecked = value === option.value;
|
||||
|
||||
return (
|
||||
<div key={option.value} className={optionWrapperClasses}>
|
||||
<div className="relative flex items-center justify-center">
|
||||
<input
|
||||
type="radio"
|
||||
id={optionId}
|
||||
name={groupName}
|
||||
value={option.value}
|
||||
checked={isChecked}
|
||||
onChange={handleChange}
|
||||
disabled={disabled || option.disabled}
|
||||
required={required}
|
||||
aria-describedby={helpText || showError || option.description ? `${inputId}-error ${optionId}-desc` : undefined}
|
||||
className={cn(
|
||||
baseRadioClasses,
|
||||
option.disabled && 'opacity-50',
|
||||
isChecked && 'border-primary'
|
||||
)}
|
||||
/>
|
||||
|
||||
{isChecked && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className={selectedIndicatorClasses} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor={optionId}
|
||||
className={cn(
|
||||
labelClasses,
|
||||
option.disabled && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</label>
|
||||
|
||||
{option.description && (
|
||||
<p
|
||||
className={descriptionClasses}
|
||||
id={`${optionId}-desc`}
|
||||
>
|
||||
{option.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{helpText && (
|
||||
<p className="text-xs text-text-secondary" id={`${inputId}-help`}>
|
||||
{helpText}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{showError && (
|
||||
<FormError errors={error} id={`${inputId}-error`} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
FormRadio.displayName = 'FormRadio';
|
||||
|
||||
export default FormRadio;
|
||||
200
components/forms/FormSelect.tsx
Normal file
200
components/forms/FormSelect.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import FormLabel from './FormLabel';
|
||||
import FormError from './FormError';
|
||||
|
||||
/**
|
||||
* FormSelect Component
|
||||
* Select dropdown with placeholder, multi-select support, and custom styling
|
||||
*/
|
||||
|
||||
export interface SelectOption {
|
||||
value: string | number;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface FormSelectProps extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, 'multiple' | 'size'> {
|
||||
label?: string;
|
||||
error?: string | string[];
|
||||
helpText?: string;
|
||||
required?: boolean;
|
||||
options: SelectOption[];
|
||||
placeholder?: string;
|
||||
multiple?: boolean;
|
||||
showSearch?: boolean;
|
||||
containerClassName?: string;
|
||||
selectClassName?: string;
|
||||
onSearch?: (query: string) => void;
|
||||
}
|
||||
|
||||
export const FormSelect: React.FC<FormSelectProps> = ({
|
||||
label,
|
||||
error,
|
||||
helpText,
|
||||
required = false,
|
||||
options,
|
||||
placeholder = 'Select an option',
|
||||
multiple = false,
|
||||
showSearch = false,
|
||||
containerClassName,
|
||||
selectClassName,
|
||||
onSearch,
|
||||
disabled = false,
|
||||
value,
|
||||
onChange,
|
||||
...props
|
||||
}) => {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const hasError = !!error;
|
||||
const showError = hasError;
|
||||
|
||||
const handleFocus = () => setIsFocused(true);
|
||||
const handleBlur = () => setIsFocused(false);
|
||||
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
onChange?.(e);
|
||||
}, [onChange]);
|
||||
|
||||
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const query = e.target.value;
|
||||
setSearchQuery(query);
|
||||
if (onSearch) {
|
||||
onSearch(query);
|
||||
}
|
||||
}, [onSearch]);
|
||||
|
||||
const filteredOptions = showSearch && searchQuery
|
||||
? options.filter(option =>
|
||||
option.label.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
: options;
|
||||
|
||||
const inputId = props.id || (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined);
|
||||
|
||||
const baseSelectClasses = cn(
|
||||
'w-full px-3 py-2 border rounded-md transition-all duration-200',
|
||||
'bg-neutral-light text-text-primary',
|
||||
'focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary',
|
||||
'disabled:opacity-60 disabled:cursor-not-allowed',
|
||||
'appearance-none cursor-pointer',
|
||||
'bg-[length:1.5em_1.5em] bg-[position:right_0.5rem_center] bg-no-repeat',
|
||||
'bg-[url("data:image/svg+xml,%3csvg xmlns=\'http://www.w3.org/2000/svg\' fill=\'none\' viewBox=\'0 0 20 20\'%3e%3cpath stroke=\'%236c757d\' stroke-linecap=\'round\' stroke-linejoin=\'round\' stroke-width=\'1.5\' d=\'M6 8l4 4 4-4\'/%3e%3c/svg%3e")]',
|
||||
{
|
||||
'border-neutral-dark hover:border-neutral-dark': !hasError && !isFocused,
|
||||
'border-primary ring-2 ring-primary': isFocused && !hasError,
|
||||
'border-danger ring-2 ring-danger/20': hasError,
|
||||
'pr-10': !showSearch,
|
||||
},
|
||||
selectClassName
|
||||
);
|
||||
|
||||
const containerClasses = cn(
|
||||
'flex flex-col gap-1.5',
|
||||
containerClassName
|
||||
);
|
||||
|
||||
const searchInputClasses = cn(
|
||||
'w-full px-3 py-2 border-b border-neutral-dark bg-transparent',
|
||||
'focus:outline-none focus:border-primary',
|
||||
'placeholder:text-text-light'
|
||||
);
|
||||
|
||||
// Custom dropdown arrow
|
||||
const dropdownArrow = (
|
||||
<svg
|
||||
className="w-4 h-4 text-text-secondary pointer-events-none absolute right-3 top-1/2 -translate-y-1/2"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const renderOptions = () => {
|
||||
// Add placeholder as first option if not multiple and no value
|
||||
const showPlaceholder = !multiple && !value && placeholder;
|
||||
|
||||
return (
|
||||
<>
|
||||
{showPlaceholder && (
|
||||
<option value="" disabled>
|
||||
{placeholder}
|
||||
</option>
|
||||
)}
|
||||
{filteredOptions.map((option) => (
|
||||
<option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
disabled={option.disabled}
|
||||
className={option.disabled ? 'opacity-50' : ''}
|
||||
>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
{label && (
|
||||
<FormLabel htmlFor={inputId} required={required}>
|
||||
{label}
|
||||
</FormLabel>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
{showSearch && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search options..."
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
className={searchInputClasses}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
|
||||
<select
|
||||
id={inputId}
|
||||
className={baseSelectClasses}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
disabled={disabled}
|
||||
multiple={multiple}
|
||||
aria-invalid={hasError}
|
||||
aria-describedby={helpText || showError ? `${inputId}-error` : undefined}
|
||||
required={required}
|
||||
{...props}
|
||||
>
|
||||
{renderOptions()}
|
||||
</select>
|
||||
|
||||
{!showSearch && dropdownArrow}
|
||||
</div>
|
||||
|
||||
{helpText && (
|
||||
<p className="text-xs text-text-secondary" id={`${inputId}-help`}>
|
||||
{helpText}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{showError && (
|
||||
<FormError errors={error} id={`${inputId}-error`} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
FormSelect.displayName = 'FormSelect';
|
||||
|
||||
export default FormSelect;
|
||||
132
components/forms/FormSuccess.tsx
Normal file
132
components/forms/FormSuccess.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* FormSuccess Component
|
||||
* Display success messages with different variants and auto-dismiss
|
||||
*/
|
||||
|
||||
export interface FormSuccessProps {
|
||||
message?: string;
|
||||
variant?: 'inline' | 'block' | 'toast';
|
||||
className?: string;
|
||||
showIcon?: boolean;
|
||||
animate?: boolean;
|
||||
autoDismiss?: boolean;
|
||||
autoDismissTimeout?: number;
|
||||
onClose?: () => void;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export const FormSuccess: React.FC<FormSuccessProps> = ({
|
||||
message,
|
||||
variant = 'inline',
|
||||
className,
|
||||
showIcon = true,
|
||||
animate = true,
|
||||
autoDismiss = false,
|
||||
autoDismissTimeout = 5000,
|
||||
onClose,
|
||||
id,
|
||||
}) => {
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!message) {
|
||||
setIsVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsVisible(true);
|
||||
|
||||
if (autoDismiss && autoDismissTimeout > 0) {
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
}, autoDismissTimeout);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [message, autoDismiss, autoDismissTimeout, onClose]);
|
||||
|
||||
if (!message || !isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseClasses = {
|
||||
inline: 'text-sm text-success mt-1',
|
||||
block: 'p-3 bg-success/10 border border-success/20 rounded-md text-success text-sm',
|
||||
toast: 'fixed bottom-4 right-4 p-4 bg-success text-white rounded-lg shadow-lg max-w-md z-tooltip animate-slide-up',
|
||||
};
|
||||
|
||||
const animationClasses = animate ? 'animate-fade-in' : '';
|
||||
|
||||
const Icon = () => (
|
||||
<svg
|
||||
className="w-4 h-4 mr-1 inline-block"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const handleClose = () => {
|
||||
setIsVisible(false);
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
id={id}
|
||||
className={cn(
|
||||
baseClasses[variant],
|
||||
animationClasses,
|
||||
'transition-all duration-200',
|
||||
'flex items-start justify-between gap-2',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start flex-1">
|
||||
{showIcon && <Icon />}
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
|
||||
{autoDismiss && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="text-current opacity-70 hover:opacity-100 transition-opacity"
|
||||
aria-label="Close notification"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
FormSuccess.displayName = 'FormSuccess';
|
||||
|
||||
export default FormSuccess;
|
||||
169
components/forms/FormTextarea.tsx
Normal file
169
components/forms/FormTextarea.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import FormLabel from './FormLabel';
|
||||
import FormError from './FormError';
|
||||
|
||||
/**
|
||||
* FormTextarea Component
|
||||
* Textarea with resize options, character counter, auto-resize, and validation states
|
||||
*/
|
||||
|
||||
export interface FormTextareaProps extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'maxLength'> {
|
||||
label?: string;
|
||||
error?: string | string[];
|
||||
helpText?: string;
|
||||
required?: boolean;
|
||||
showCharCount?: boolean;
|
||||
autoResize?: boolean;
|
||||
maxHeight?: number;
|
||||
minHeight?: number;
|
||||
containerClassName?: string;
|
||||
textareaClassName?: string;
|
||||
maxLength?: number;
|
||||
}
|
||||
|
||||
export const FormTextarea: React.FC<FormTextareaProps> = ({
|
||||
label,
|
||||
error,
|
||||
helpText,
|
||||
required = false,
|
||||
showCharCount = false,
|
||||
autoResize = false,
|
||||
maxHeight = 300,
|
||||
minHeight = 120,
|
||||
containerClassName,
|
||||
textareaClassName,
|
||||
maxLength,
|
||||
disabled = false,
|
||||
value = '',
|
||||
onChange,
|
||||
rows = 4,
|
||||
...props
|
||||
}) => {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [charCount, setCharCount] = useState(0);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const hasError = !!error;
|
||||
const showError = hasError;
|
||||
|
||||
// Update character count
|
||||
useEffect(() => {
|
||||
const currentValue = typeof value === 'string' ? value : String(value || '');
|
||||
setCharCount(currentValue.length);
|
||||
}, [value]);
|
||||
|
||||
// Auto-resize textarea
|
||||
useEffect(() => {
|
||||
if (!autoResize || !textareaRef.current) return;
|
||||
|
||||
const textarea = textareaRef.current;
|
||||
|
||||
// Reset height to calculate new height
|
||||
textarea.style.height = 'auto';
|
||||
|
||||
// Calculate new height
|
||||
const newHeight = Math.min(
|
||||
Math.max(textarea.scrollHeight, minHeight),
|
||||
maxHeight
|
||||
);
|
||||
|
||||
textarea.style.height = `${newHeight}px`;
|
||||
}, [value, autoResize, minHeight, maxHeight]);
|
||||
|
||||
const handleFocus = () => setIsFocused(true);
|
||||
const handleBlur = () => setIsFocused(false);
|
||||
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
if (maxLength && e.target.value.length > maxLength) {
|
||||
e.target.value = e.target.value.slice(0, maxLength);
|
||||
}
|
||||
onChange?.(e);
|
||||
}, [onChange, maxLength]);
|
||||
|
||||
const inputId = props.id || (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined);
|
||||
|
||||
const baseTextareaClasses = cn(
|
||||
'w-full px-3 py-2 border rounded-md transition-all duration-200 resize-y',
|
||||
'bg-neutral-light text-text-primary',
|
||||
'placeholder:text-text-light',
|
||||
'focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary',
|
||||
'disabled:opacity-60 disabled:cursor-not-allowed',
|
||||
{
|
||||
'border-neutral-dark hover:border-neutral-dark': !hasError && !isFocused,
|
||||
'border-primary ring-2 ring-primary': isFocused && !hasError,
|
||||
'border-danger ring-2 ring-danger/20': hasError,
|
||||
},
|
||||
autoResize && 'overflow-hidden',
|
||||
textareaClassName
|
||||
);
|
||||
|
||||
const containerClasses = cn(
|
||||
'flex flex-col gap-1.5',
|
||||
containerClassName
|
||||
);
|
||||
|
||||
const charCountClasses = cn(
|
||||
'text-xs text-right mt-1',
|
||||
{
|
||||
'text-text-secondary': charCount <= (maxLength || 0) * 0.8,
|
||||
'text-warning': charCount > (maxLength || 0) * 0.8 && charCount <= (maxLength || 0),
|
||||
'text-danger': charCount > (maxLength || 0),
|
||||
}
|
||||
);
|
||||
|
||||
const showCharCounter = showCharCount || (maxLength && charCount > 0);
|
||||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
{label && (
|
||||
<FormLabel htmlFor={inputId} required={required}>
|
||||
{label}
|
||||
</FormLabel>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
id={inputId}
|
||||
className={baseTextareaClasses}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
disabled={disabled}
|
||||
rows={rows}
|
||||
aria-invalid={hasError}
|
||||
aria-describedby={helpText || showError || showCharCounter ? `${inputId}-error ${inputId}-help ${inputId}-count` : undefined}
|
||||
required={required}
|
||||
maxLength={maxLength}
|
||||
style={autoResize ? { minHeight: `${minHeight}px`, overflow: 'hidden' } : {}}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center gap-2">
|
||||
{helpText && (
|
||||
<p className="text-xs text-text-secondary flex-1" id={`${inputId}-help`}>
|
||||
{helpText}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{showCharCounter && (
|
||||
<p className={charCountClasses} id={`${inputId}-count`}>
|
||||
{charCount}
|
||||
{maxLength ? ` / ${maxLength}` : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showError && (
|
||||
<FormError errors={error} id={`${inputId}-error`} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
FormTextarea.displayName = 'FormTextarea';
|
||||
|
||||
export default FormTextarea;
|
||||
632
components/forms/README.md
Normal file
632
components/forms/README.md
Normal file
@@ -0,0 +1,632 @@
|
||||
# KLZ Forms System
|
||||
|
||||
A comprehensive, reusable form system for Next.js applications with full TypeScript support, accessibility features, and consistent styling.
|
||||
|
||||
## Features
|
||||
|
||||
- **Complete Form Components**: All essential form inputs with consistent styling
|
||||
- **Validation System**: Built-in validation with custom rules
|
||||
- **Type Safety**: Full TypeScript support
|
||||
- **Accessibility**: ARIA attributes and keyboard navigation
|
||||
- **Internationalization**: Ready for i18n
|
||||
- **Customizable**: Flexible props for different use cases
|
||||
- **Animation**: Smooth transitions and animations
|
||||
- **Error Handling**: Multiple error display modes
|
||||
- **Auto-resize**: Smart textarea resizing
|
||||
- **Character Count**: Built-in character counting
|
||||
|
||||
## Installation
|
||||
|
||||
The form system is already included in the project. All components use the existing design system tokens.
|
||||
|
||||
## Components
|
||||
|
||||
### FormField
|
||||
Wrapper component that provides consistent form field experience.
|
||||
|
||||
```tsx
|
||||
<FormField
|
||||
type="text"
|
||||
name="email"
|
||||
label="Email Address"
|
||||
required
|
||||
placeholder="your@email.com"
|
||||
value={value}
|
||||
error={error}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
```
|
||||
|
||||
**Supported Types**: `text`, `email`, `tel`, `textarea`, `select`, `checkbox`, `radio`, `number`, `password`, `date`, `time`, `url`
|
||||
|
||||
### FormInput
|
||||
Base input component with icon support and clear button.
|
||||
|
||||
```tsx
|
||||
<FormInput
|
||||
type="email"
|
||||
name="email"
|
||||
label="Email"
|
||||
prefix={<EmailIcon />}
|
||||
showClear
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
```
|
||||
|
||||
### FormTextarea
|
||||
Textarea with auto-resize and character counting.
|
||||
|
||||
```tsx
|
||||
<FormTextarea
|
||||
name="message"
|
||||
label="Message"
|
||||
rows={5}
|
||||
showCharCount
|
||||
maxLength={500}
|
||||
autoResize
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
```
|
||||
|
||||
### FormSelect
|
||||
Select dropdown with search and multi-select support.
|
||||
|
||||
```tsx
|
||||
<FormSelect
|
||||
name="country"
|
||||
label="Country"
|
||||
options={[
|
||||
{ value: 'de', label: 'Germany' },
|
||||
{ value: 'us', label: 'United States' }
|
||||
]}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
```
|
||||
|
||||
### FormCheckbox
|
||||
Single checkbox or checkbox group with indeterminate state.
|
||||
|
||||
```tsx
|
||||
// Single checkbox
|
||||
<FormCheckbox
|
||||
name="agree"
|
||||
label="I agree to terms"
|
||||
checked={checked}
|
||||
onChange={(values) => setChecked(values.length > 0)}
|
||||
/>
|
||||
|
||||
// Checkbox group
|
||||
<FormCheckbox
|
||||
name="services"
|
||||
label="Services"
|
||||
options={[
|
||||
{ value: 'consulting', label: 'Consulting' },
|
||||
{ value: 'support', label: 'Support' }
|
||||
]}
|
||||
value={selectedValues}
|
||||
onChange={(values) => setSelectedValues(values)}
|
||||
/>
|
||||
```
|
||||
|
||||
### FormRadio
|
||||
Radio button group with custom styling.
|
||||
|
||||
```tsx
|
||||
<FormRadio
|
||||
name="payment"
|
||||
label="Payment Method"
|
||||
options={[
|
||||
{ value: 'credit-card', label: 'Credit Card' },
|
||||
{ value: 'paypal', label: 'PayPal' }
|
||||
]}
|
||||
value={value}
|
||||
onChange={(value) => setValue(value)}
|
||||
/>
|
||||
```
|
||||
|
||||
### FormError
|
||||
Error message display with multiple variants.
|
||||
|
||||
```tsx
|
||||
<FormError
|
||||
errors={errors}
|
||||
variant="block"
|
||||
showIcon
|
||||
/>
|
||||
```
|
||||
|
||||
### FormSuccess
|
||||
Success message with auto-dismiss option.
|
||||
|
||||
```tsx
|
||||
<FormSuccess
|
||||
message="Form submitted successfully!"
|
||||
autoDismiss
|
||||
onClose={() => setShowSuccess(false)}
|
||||
/>
|
||||
```
|
||||
|
||||
## Hooks
|
||||
|
||||
### useForm
|
||||
Main form state management hook with validation and submission handling.
|
||||
|
||||
```tsx
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
name: '',
|
||||
email: '',
|
||||
},
|
||||
validationRules: {
|
||||
name: { required: true, minLength: { value: 2, message: 'Too short' } },
|
||||
email: { required: true, email: true },
|
||||
},
|
||||
onSubmit: async (values) => {
|
||||
// Handle submission
|
||||
await api.submit(values);
|
||||
},
|
||||
});
|
||||
|
||||
// In your component
|
||||
<form {...form.getFormProps()}>
|
||||
<input
|
||||
value={form.values.name}
|
||||
onChange={(e) => form.setFieldValue('name', e.target.value)}
|
||||
/>
|
||||
{form.errors.name && <FormError errors={form.errors.name} />}
|
||||
<button type="submit" disabled={!form.isValid || form.isSubmitting}>
|
||||
Submit
|
||||
</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
### useFormField
|
||||
Hook for managing individual field state.
|
||||
|
||||
```tsx
|
||||
const field = useFormField({
|
||||
initialValue: '',
|
||||
validate: (value) => value.length < 2 ? 'Too short' : null,
|
||||
});
|
||||
|
||||
// field.value, field.error, field.touched, field.handleChange, etc.
|
||||
```
|
||||
|
||||
### useFormValidation
|
||||
Validation logic and utilities.
|
||||
|
||||
```tsx
|
||||
const { validateField, validateForm } = useFormValidation();
|
||||
|
||||
const errors = validateField(value, {
|
||||
required: true,
|
||||
email: true,
|
||||
}, 'email');
|
||||
```
|
||||
|
||||
## Validation Rules
|
||||
|
||||
Available validation rules:
|
||||
|
||||
```typescript
|
||||
{
|
||||
required: boolean | string; // Required field
|
||||
minLength: { value: number, message: string };
|
||||
maxLength: { value: number, message: string };
|
||||
pattern: { value: RegExp, message: string };
|
||||
min: { value: number, message: string };
|
||||
max: { value: number, message: string };
|
||||
email: boolean | string;
|
||||
url: boolean | string;
|
||||
number: boolean | string;
|
||||
custom: (value) => string | null; // Custom validation
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Simple Contact Form
|
||||
|
||||
```tsx
|
||||
import { useForm, FormField, Button } from '@/components/forms';
|
||||
|
||||
export function ContactForm() {
|
||||
const form = useForm({
|
||||
initialValues: { name: '', email: '', message: '' },
|
||||
validationRules: {
|
||||
name: { required: true, minLength: { value: 2 } },
|
||||
email: { required: true, email: true },
|
||||
message: { required: true },
|
||||
},
|
||||
onSubmit: async (values) => {
|
||||
await sendEmail(values);
|
||||
alert('Sent!');
|
||||
form.reset();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form {...form.getFormProps()}>
|
||||
<FormField
|
||||
name="name"
|
||||
label="Name"
|
||||
required
|
||||
value={form.values.name}
|
||||
error={form.errors.name?.[0]}
|
||||
onChange={(e) => form.setFieldValue('name', e.target.value)}
|
||||
/>
|
||||
<FormField
|
||||
type="email"
|
||||
name="email"
|
||||
label="Email"
|
||||
required
|
||||
value={form.values.email}
|
||||
error={form.errors.email?.[0]}
|
||||
onChange={(e) => form.setFieldValue('email', e.target.value)}
|
||||
/>
|
||||
<FormField
|
||||
type="textarea"
|
||||
name="message"
|
||||
label="Message"
|
||||
required
|
||||
rows={5}
|
||||
value={form.values.message}
|
||||
error={form.errors.message?.[0]}
|
||||
onChange={(e) => form.setFieldValue('message', e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={!form.isValid || form.isSubmitting}
|
||||
loading={form.isSubmitting}
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Registration Form
|
||||
|
||||
```tsx
|
||||
import { useForm, FormField, Button } from '@/components/forms';
|
||||
|
||||
export function RegistrationForm() {
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
terms: false,
|
||||
},
|
||||
validationRules: {
|
||||
firstName: { required: true, minLength: { value: 2 } },
|
||||
lastName: { required: true, minLength: { value: 2 } },
|
||||
email: { required: true, email: true },
|
||||
password: {
|
||||
required: true,
|
||||
minLength: { value: 8 },
|
||||
pattern: { value: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/ }
|
||||
},
|
||||
confirmPassword: {
|
||||
required: true,
|
||||
custom: (value) => value === form.values.password ? null : 'Passwords do not match'
|
||||
},
|
||||
terms: {
|
||||
required: 'You must accept terms',
|
||||
custom: (value) => value ? null : 'Required'
|
||||
},
|
||||
},
|
||||
onSubmit: async (values) => {
|
||||
await registerUser(values);
|
||||
alert('Registered!');
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form {...form.getFormProps()}>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
name="firstName"
|
||||
label="First Name"
|
||||
required
|
||||
value={form.values.firstName}
|
||||
error={form.errors.firstName?.[0]}
|
||||
onChange={(e) => form.setFieldValue('firstName', e.target.value)}
|
||||
/>
|
||||
<FormField
|
||||
name="lastName"
|
||||
label="Last Name"
|
||||
required
|
||||
value={form.values.lastName}
|
||||
error={form.errors.lastName?.[0]}
|
||||
onChange={(e) => form.setFieldValue('lastName', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
type="email"
|
||||
name="email"
|
||||
label="Email"
|
||||
required
|
||||
value={form.values.email}
|
||||
error={form.errors.email?.[0]}
|
||||
onChange={(e) => form.setFieldValue('email', e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
type="password"
|
||||
name="password"
|
||||
label="Password"
|
||||
required
|
||||
helpText="Min 8 chars with uppercase, lowercase, and number"
|
||||
value={form.values.password}
|
||||
error={form.errors.password?.[0]}
|
||||
onChange={(e) => form.setFieldValue('password', e.target.value)}
|
||||
/>
|
||||
<FormField
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
label="Confirm Password"
|
||||
required
|
||||
value={form.values.confirmPassword}
|
||||
error={form.errors.confirmPassword?.[0]}
|
||||
onChange={(e) => form.setFieldValue('confirmPassword', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
type="checkbox"
|
||||
name="terms"
|
||||
label="I accept the terms and conditions"
|
||||
required
|
||||
checked={form.values.terms}
|
||||
error={form.errors.terms?.[0]}
|
||||
onChange={(values) => form.setFieldValue('terms', values.length > 0)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={!form.isValid || form.isSubmitting}
|
||||
loading={form.isSubmitting}
|
||||
>
|
||||
Register
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Search and Filter Form
|
||||
|
||||
```tsx
|
||||
import { useForm, FormField, Button } from '@/components/forms';
|
||||
|
||||
export function SearchForm() {
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
search: '',
|
||||
category: '',
|
||||
status: '',
|
||||
},
|
||||
validationRules: {},
|
||||
onSubmit: async (values) => {
|
||||
await performSearch(values);
|
||||
},
|
||||
});
|
||||
|
||||
const categoryOptions = [
|
||||
{ value: '', label: 'All' },
|
||||
{ value: 'cables', label: 'Cables' },
|
||||
{ value: 'connectors', label: 'Connectors' },
|
||||
];
|
||||
|
||||
return (
|
||||
<form {...form.getFormProps()} className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<FormField
|
||||
type="text"
|
||||
name="search"
|
||||
label="Search"
|
||||
placeholder="Search..."
|
||||
prefix={<SearchIcon />}
|
||||
showClear
|
||||
value={form.values.search}
|
||||
onChange={(e) => form.setFieldValue('search', e.target.value)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
type="select"
|
||||
name="category"
|
||||
label="Category"
|
||||
options={categoryOptions}
|
||||
value={form.values.category}
|
||||
onChange={(e) => form.setFieldValue('category', e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2 items-end">
|
||||
<Button type="submit" variant="primary" size="sm">
|
||||
Search
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={form.reset}>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Always Use FormField for Consistency
|
||||
```tsx
|
||||
// ✅ Good
|
||||
<FormField name="email" type="email" label="Email" ... />
|
||||
|
||||
// ❌ Avoid
|
||||
<div>
|
||||
<label>Email</label>
|
||||
<input type="email" ... />
|
||||
</div>
|
||||
```
|
||||
|
||||
### 2. Validate Before Submit
|
||||
```tsx
|
||||
const form = useForm({
|
||||
validationRules: {
|
||||
email: { required: true, email: true },
|
||||
},
|
||||
onSubmit: async (values) => {
|
||||
// Validation happens automatically
|
||||
// Only called if isValid is true
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Show Errors Only After Touch
|
||||
```tsx
|
||||
{form.touched.email && form.errors.email && (
|
||||
<FormError errors={form.errors.email} />
|
||||
)}
|
||||
```
|
||||
|
||||
### 4. Disable Submit When Invalid
|
||||
```tsx
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!form.isValid || form.isSubmitting}
|
||||
loading={form.isSubmitting}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
```
|
||||
|
||||
### 5. Reset After Success
|
||||
```tsx
|
||||
onSubmit: async (values) => {
|
||||
await submit(values);
|
||||
form.reset();
|
||||
}
|
||||
```
|
||||
|
||||
## Accessibility
|
||||
|
||||
All components include:
|
||||
- Proper ARIA attributes
|
||||
- Keyboard navigation support
|
||||
- Focus management
|
||||
- Screen reader support
|
||||
- Required field indicators
|
||||
|
||||
## Styling
|
||||
|
||||
Components use the design system:
|
||||
- Colors: `--color-primary`, `--color-danger`, `--color-success`
|
||||
- Spacing: `--spacing-sm`, `--spacing-md`, etc.
|
||||
- Typography: `--font-size-sm`, `--font-size-base`
|
||||
- Borders: `--radius-md`
|
||||
- Transitions: `--transition-fast`
|
||||
|
||||
## TypeScript Support
|
||||
|
||||
Full TypeScript support with proper interfaces:
|
||||
|
||||
```typescript
|
||||
import type {
|
||||
FormFieldProps,
|
||||
FormInputProps,
|
||||
ValidationRules,
|
||||
FormErrors
|
||||
} from '@/components/forms';
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Example test setup:
|
||||
|
||||
```tsx
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { useForm } from '@/components/forms';
|
||||
|
||||
test('form validation', () => {
|
||||
const TestComponent = () => {
|
||||
const form = useForm({
|
||||
initialValues: { email: '' },
|
||||
validationRules: { email: { required: true, email: true } },
|
||||
onSubmit: jest.fn(),
|
||||
});
|
||||
|
||||
return (
|
||||
<form {...form.getFormProps()}>
|
||||
<input
|
||||
value={form.values.email}
|
||||
onChange={(e) => form.setFieldValue('email', e.target.value)}
|
||||
/>
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
render(<TestComponent />);
|
||||
const input = screen.getByRole('textbox');
|
||||
|
||||
fireEvent.change(input, { target: { value: 'invalid' } });
|
||||
// Validation should trigger
|
||||
});
|
||||
```
|
||||
|
||||
## Performance Tips
|
||||
|
||||
1. **Memoize validation rules** if they depend on external values
|
||||
2. **Use useCallback** for event handlers
|
||||
3. **Avoid unnecessary re-renders** by splitting large forms
|
||||
4. **Lazy load** form examples for better initial load
|
||||
|
||||
## Migration from Legacy Forms
|
||||
|
||||
If migrating from old form components:
|
||||
|
||||
```tsx
|
||||
// Old
|
||||
<input
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
className={error ? 'error' : ''}
|
||||
/>
|
||||
|
||||
// New
|
||||
<FormField
|
||||
type="email"
|
||||
name="email"
|
||||
value={form.values.email}
|
||||
error={form.errors.email?.[0]}
|
||||
onChange={(e) => form.setFieldValue('email', e.target.value)}
|
||||
/>
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Validation not working**: Ensure `validationRules` match `initialValues` keys
|
||||
2. **Form not submitting**: Check `isValid` state and `required` fields
|
||||
3. **Type errors**: Import proper types from the forms module
|
||||
4. **Styling issues**: Ensure design system CSS is imported
|
||||
|
||||
### Getting Help
|
||||
|
||||
Check the examples in `FormExamples.tsx` for complete implementations.
|
||||
|
||||
## License
|
||||
|
||||
Internal KLZ Cables component system
|
||||
275
components/forms/hooks/useForm.ts
Normal file
275
components/forms/hooks/useForm.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import { useState, useCallback, FormEvent } from 'react';
|
||||
import { useFormValidation, ValidationRules, FormErrors } from './useFormValidation';
|
||||
|
||||
/**
|
||||
* Hook for managing complete form state and submission
|
||||
*/
|
||||
|
||||
export interface FormState<T extends Record<string, any>> {
|
||||
values: T;
|
||||
errors: FormErrors;
|
||||
touched: Record<keyof T, boolean>;
|
||||
isValid: boolean;
|
||||
isSubmitting: boolean;
|
||||
isSubmitted: boolean;
|
||||
submitCount: number;
|
||||
}
|
||||
|
||||
export interface FormOptions<T extends Record<string, any>> {
|
||||
initialValues: T;
|
||||
validationRules: Record<keyof T, ValidationRules>;
|
||||
onSubmit: (values: T) => Promise<void> | void;
|
||||
validateOnMount?: boolean;
|
||||
}
|
||||
|
||||
export interface FormReturn<T extends Record<string, any>> extends FormState<T> {
|
||||
setFieldValue: (field: keyof T, value: any) => void;
|
||||
setFieldError: (field: keyof T, error: string) => void;
|
||||
clearFieldError: (field: keyof T) => void;
|
||||
handleChange: (field: keyof T, value: any) => void;
|
||||
handleSubmit: (e: FormEvent<HTMLFormElement>) => Promise<void>;
|
||||
reset: () => void;
|
||||
setAllTouched: () => void;
|
||||
setValues: (values: T) => void;
|
||||
setErrors: (errors: FormErrors) => void;
|
||||
setSubmitting: (isSubmitting: boolean) => void;
|
||||
getFormProps: () => { onSubmit: (e: FormEvent<HTMLFormElement>) => Promise<void>; noValidate: boolean };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing complete form state with validation and submission
|
||||
*/
|
||||
export function useForm<T extends Record<string, any>>(
|
||||
options: FormOptions<T>
|
||||
): FormReturn<T> {
|
||||
const {
|
||||
initialValues,
|
||||
validationRules,
|
||||
onSubmit,
|
||||
validateOnMount = false,
|
||||
} = options;
|
||||
|
||||
const {
|
||||
values,
|
||||
errors,
|
||||
touched,
|
||||
isValid,
|
||||
setFieldValue: validationSetFieldValue,
|
||||
setFieldError: validationSetFieldError,
|
||||
clearFieldError: validationClearFieldError,
|
||||
validate,
|
||||
reset: validationReset,
|
||||
setAllTouched: validationSetAllTouched,
|
||||
setValues: validationSetValues,
|
||||
} = useFormValidation<T>(initialValues, validationRules);
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||
const [submitCount, setSubmitCount] = useState(0);
|
||||
|
||||
// Validate on mount if requested
|
||||
// Note: This is handled by useFormValidation's useEffect
|
||||
|
||||
const setFieldValue = useCallback((field: keyof T, value: any) => {
|
||||
validationSetFieldValue(field, value);
|
||||
}, [validationSetFieldValue]);
|
||||
|
||||
const setFieldError = useCallback((field: keyof T, error: string) => {
|
||||
validationSetFieldError(field, error);
|
||||
}, [validationSetFieldError]);
|
||||
|
||||
const clearFieldError = useCallback((field: keyof T) => {
|
||||
validationClearFieldError(field);
|
||||
}, [validationClearFieldError]);
|
||||
|
||||
const handleChange = useCallback((field: keyof T, value: any) => {
|
||||
setFieldValue(field, value);
|
||||
}, [setFieldValue]);
|
||||
|
||||
const setErrors = useCallback((newErrors: FormErrors) => {
|
||||
Object.entries(newErrors).forEach(([field, fieldErrors]) => {
|
||||
if (Array.isArray(fieldErrors) && fieldErrors.length > 0) {
|
||||
fieldErrors.forEach((error) => {
|
||||
setFieldError(field as keyof T, error);
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [setFieldError]);
|
||||
|
||||
const setSubmitting = useCallback((state: boolean) => {
|
||||
setIsSubmitting(state);
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
validationReset();
|
||||
setIsSubmitting(false);
|
||||
setIsSubmitted(false);
|
||||
setSubmitCount(0);
|
||||
}, [validationReset]);
|
||||
|
||||
const setAllTouched = useCallback(() => {
|
||||
validationSetAllTouched();
|
||||
}, [validationSetAllTouched]);
|
||||
|
||||
const setValues = useCallback((newValues: T) => {
|
||||
validationSetValues(newValues);
|
||||
}, [validationSetValues]);
|
||||
|
||||
const handleSubmit = useCallback(async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Increment submit count
|
||||
setSubmitCount((prev) => prev + 1);
|
||||
|
||||
// Set all fields as touched to show validation errors
|
||||
setAllTouched();
|
||||
|
||||
// Validate form
|
||||
const validation = validate();
|
||||
|
||||
if (!validation.isValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Start submission
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
// Call submit handler
|
||||
await onSubmit(values);
|
||||
setIsSubmitted(true);
|
||||
} catch (error) {
|
||||
// Handle submission error
|
||||
console.error('Form submission error:', error);
|
||||
|
||||
// You can set a general error or handle specific error cases
|
||||
if (error instanceof Error) {
|
||||
setFieldError('submit' as keyof T, error.message);
|
||||
} else {
|
||||
setFieldError('submit' as keyof T, 'An error occurred during submission');
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [values, onSubmit, validate, setAllTouched, setFieldError]);
|
||||
|
||||
const getFormProps = useCallback(() => ({
|
||||
onSubmit: handleSubmit,
|
||||
noValidate: true,
|
||||
}), [handleSubmit]);
|
||||
|
||||
return {
|
||||
values,
|
||||
errors,
|
||||
touched,
|
||||
isValid,
|
||||
isSubmitting,
|
||||
isSubmitted,
|
||||
submitCount,
|
||||
setFieldValue,
|
||||
setFieldError,
|
||||
clearFieldError,
|
||||
handleChange,
|
||||
handleSubmit,
|
||||
reset,
|
||||
setAllTouched,
|
||||
setValues,
|
||||
setErrors,
|
||||
setSubmitting,
|
||||
getFormProps,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing form state with additional utilities
|
||||
*/
|
||||
export function useFormWithHelpers<T extends Record<string, any>>(
|
||||
options: FormOptions<T>
|
||||
) {
|
||||
const form = useForm<T>(options);
|
||||
|
||||
const getFormProps = () => ({
|
||||
onSubmit: form.handleSubmit,
|
||||
noValidate: true, // We handle validation manually
|
||||
});
|
||||
|
||||
const getSubmitButtonProps = () => ({
|
||||
type: 'submit',
|
||||
disabled: form.isSubmitting || !form.isValid,
|
||||
loading: form.isSubmitting,
|
||||
});
|
||||
|
||||
const getResetButtonProps = () => ({
|
||||
type: 'button',
|
||||
onClick: form.reset,
|
||||
disabled: form.isSubmitting,
|
||||
});
|
||||
|
||||
const getFieldProps = (field: keyof T) => ({
|
||||
value: form.values[field] as any,
|
||||
onChange: (e: any) => {
|
||||
const target = e.target;
|
||||
let value: any = target.value;
|
||||
|
||||
if (target.type === 'checkbox') {
|
||||
value = target.checked;
|
||||
} else if (target.type === 'number') {
|
||||
value = target.value === '' ? '' : Number(target.value);
|
||||
}
|
||||
|
||||
form.setFieldValue(field, value);
|
||||
},
|
||||
error: form.errors[field as string]?.[0],
|
||||
touched: form.touched[field],
|
||||
onBlur: () => {
|
||||
// Mark as touched on blur if not already
|
||||
if (!form.touched[field]) {
|
||||
form.setAllTouched();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const hasFieldError = (field: keyof T): boolean => {
|
||||
return !!form.errors[field as string]?.length && !!form.touched[field];
|
||||
};
|
||||
|
||||
const getFieldError = (field: keyof T): string | null => {
|
||||
const errors = form.errors[field as string];
|
||||
return errors && errors.length > 0 ? errors[0] : null;
|
||||
};
|
||||
|
||||
const clearFieldError = (field: keyof T) => {
|
||||
form.clearFieldError(field);
|
||||
};
|
||||
|
||||
const setFieldError = (field: keyof T, error: string) => {
|
||||
form.setFieldError(field, error);
|
||||
};
|
||||
|
||||
const isDirty = (): boolean => {
|
||||
return Object.keys(form.values).some((key) => {
|
||||
const currentValue = form.values[key as keyof T];
|
||||
const initialValue = options.initialValues[key as keyof T];
|
||||
return currentValue !== initialValue;
|
||||
});
|
||||
};
|
||||
|
||||
const canSubmit = (): boolean => {
|
||||
return !form.isSubmitting && form.isValid && isDirty();
|
||||
};
|
||||
|
||||
return {
|
||||
...form,
|
||||
getFormProps,
|
||||
getSubmitButtonProps,
|
||||
getResetButtonProps,
|
||||
getFieldProps,
|
||||
hasFieldError,
|
||||
getFieldError,
|
||||
clearFieldError,
|
||||
setFieldError,
|
||||
isDirty,
|
||||
canSubmit,
|
||||
};
|
||||
}
|
||||
211
components/forms/hooks/useFormField.ts
Normal file
211
components/forms/hooks/useFormField.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { useState, useCallback, ChangeEvent } from 'react';
|
||||
|
||||
/**
|
||||
* Hook for managing individual form field state
|
||||
*/
|
||||
|
||||
export interface FormFieldState<T> {
|
||||
value: T;
|
||||
error: string | null;
|
||||
touched: boolean;
|
||||
dirty: boolean;
|
||||
isValid: boolean;
|
||||
}
|
||||
|
||||
export interface FormFieldOptions<T> {
|
||||
initialValue?: T;
|
||||
validate?: (value: T) => string | null;
|
||||
transform?: (value: T) => T;
|
||||
}
|
||||
|
||||
export interface FormFieldReturn<T> {
|
||||
value: T;
|
||||
error: string | null;
|
||||
touched: boolean;
|
||||
dirty: boolean;
|
||||
isValid: boolean;
|
||||
handleChange: (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => void;
|
||||
setValue: (value: T) => void;
|
||||
setError: (error: string | null) => void;
|
||||
setTouched: (touched: boolean) => void;
|
||||
reset: () => void;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing individual form field state with validation
|
||||
*/
|
||||
export function useFormField<T = string>(
|
||||
options: FormFieldOptions<T> = {}
|
||||
): FormFieldReturn<T> {
|
||||
const {
|
||||
initialValue = '' as unknown as T,
|
||||
validate,
|
||||
transform,
|
||||
} = options;
|
||||
|
||||
const [state, setState] = useState<FormFieldState<T>>({
|
||||
value: initialValue,
|
||||
error: null,
|
||||
touched: false,
|
||||
dirty: false,
|
||||
isValid: true,
|
||||
});
|
||||
|
||||
const validateValue = useCallback((value: T): string | null => {
|
||||
if (validate) {
|
||||
return validate(value);
|
||||
}
|
||||
return null;
|
||||
}, [validate]);
|
||||
|
||||
const updateState = useCallback((newState: Partial<FormFieldState<T>>) => {
|
||||
setState((prev) => {
|
||||
const updated = { ...prev, ...newState };
|
||||
|
||||
// Auto-validate if value changes and validation is provided
|
||||
if ('value' in newState && validate) {
|
||||
const error = validateValue(newState.value as T);
|
||||
updated.error = error;
|
||||
updated.isValid = !error;
|
||||
}
|
||||
|
||||
return updated;
|
||||
});
|
||||
}, [validate, validateValue]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
let value: any = e.target.value;
|
||||
|
||||
// Handle different input types
|
||||
if (e.target.type === 'checkbox') {
|
||||
value = (e.target as HTMLInputElement).checked;
|
||||
} else if (e.target.type === 'number') {
|
||||
value = e.target.value === '' ? '' : Number(e.target.value);
|
||||
}
|
||||
|
||||
// Apply transformation if provided
|
||||
if (transform) {
|
||||
value = transform(value);
|
||||
}
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
value,
|
||||
dirty: true,
|
||||
touched: true,
|
||||
}));
|
||||
},
|
||||
[transform]
|
||||
);
|
||||
|
||||
const setValue = useCallback((value: T) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
value,
|
||||
dirty: true,
|
||||
touched: true,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const setError = useCallback((error: string | null) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
error,
|
||||
isValid: !error,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const setTouched = useCallback((touched: boolean) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
touched,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
error: null,
|
||||
isValid: true,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setState({
|
||||
value: initialValue,
|
||||
error: null,
|
||||
touched: false,
|
||||
dirty: false,
|
||||
isValid: true,
|
||||
});
|
||||
}, [initialValue]);
|
||||
|
||||
// Auto-validate on mount if initial value exists
|
||||
// This ensures initial values are validated
|
||||
// Note: We're intentionally not adding initialValue to dependencies
|
||||
// to avoid infinite loops, but we validate once on mount
|
||||
// This is handled by the updateState function when value changes
|
||||
|
||||
return {
|
||||
value: state.value,
|
||||
error: state.error,
|
||||
touched: state.touched,
|
||||
dirty: state.dirty,
|
||||
isValid: state.isValid,
|
||||
handleChange,
|
||||
setValue,
|
||||
setError,
|
||||
setTouched,
|
||||
reset,
|
||||
clearError,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing form field state with additional utilities
|
||||
*/
|
||||
export function useFormFieldWithHelpers<T = string>(
|
||||
options: FormFieldOptions<T> & {
|
||||
label?: string;
|
||||
required?: boolean;
|
||||
helpText?: string;
|
||||
} = {}
|
||||
) {
|
||||
const field = useFormField<T>(options);
|
||||
|
||||
const hasError = field.error !== null;
|
||||
const showError = field.touched && hasError;
|
||||
const showSuccess = field.touched && !hasError && field.dirty;
|
||||
|
||||
const getAriaDescribedBy = () => {
|
||||
const descriptions: string[] = [];
|
||||
if (options.helpText) descriptions.push(`${options.label || 'field'}-help`);
|
||||
if (field.error) descriptions.push(`${options.label || 'field'}-error`);
|
||||
return descriptions.length > 0 ? descriptions.join(' ') : undefined;
|
||||
};
|
||||
|
||||
const getInputProps = () => ({
|
||||
value: field.value as any,
|
||||
onChange: field.handleChange,
|
||||
'aria-invalid': hasError,
|
||||
'aria-describedby': getAriaDescribedBy(),
|
||||
'aria-required': options.required,
|
||||
});
|
||||
|
||||
const getLabelProps = () => ({
|
||||
htmlFor: options.label?.toLowerCase().replace(/\s+/g, '-'),
|
||||
required: options.required,
|
||||
});
|
||||
|
||||
return {
|
||||
...field,
|
||||
hasError,
|
||||
showError,
|
||||
showSuccess,
|
||||
getInputProps,
|
||||
getLabelProps,
|
||||
getAriaDescribedBy,
|
||||
};
|
||||
}
|
||||
264
components/forms/hooks/useFormValidation.ts
Normal file
264
components/forms/hooks/useFormValidation.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* Form Validation Hooks
|
||||
* Provides validation logic and utilities for form components
|
||||
*/
|
||||
|
||||
export interface ValidationRule {
|
||||
value: any;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ValidationRules {
|
||||
required?: boolean | string;
|
||||
minLength?: ValidationRule;
|
||||
maxLength?: ValidationRule;
|
||||
pattern?: ValidationRule;
|
||||
min?: ValidationRule;
|
||||
max?: ValidationRule;
|
||||
email?: boolean | string;
|
||||
url?: boolean | string;
|
||||
number?: boolean | string;
|
||||
custom?: (value: any) => string | null;
|
||||
}
|
||||
|
||||
export interface ValidationError {
|
||||
field: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface FormErrors {
|
||||
[key: string]: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a single field value against validation rules
|
||||
*/
|
||||
export function validateField(
|
||||
value: any,
|
||||
rules: ValidationRules,
|
||||
fieldName: string
|
||||
): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Required validation
|
||||
if (rules.required) {
|
||||
const requiredMessage = typeof rules.required === 'string'
|
||||
? rules.required
|
||||
: `${fieldName} is required`;
|
||||
|
||||
if (value === null || value === undefined || value === '') {
|
||||
errors.push(requiredMessage);
|
||||
}
|
||||
}
|
||||
|
||||
// Only validate other rules if there's a value (unless required)
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return errors;
|
||||
}
|
||||
|
||||
// Min length validation
|
||||
if (rules.minLength) {
|
||||
const min = rules.minLength.value;
|
||||
const message = rules.minLength.message || `${fieldName} must be at least ${min} characters`;
|
||||
|
||||
if (typeof value === 'string' && value.length < min) {
|
||||
errors.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
// Max length validation
|
||||
if (rules.maxLength) {
|
||||
const max = rules.maxLength.value;
|
||||
const message = rules.maxLength.message || `${fieldName} must be at most ${max} characters`;
|
||||
|
||||
if (typeof value === 'string' && value.length > max) {
|
||||
errors.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern validation
|
||||
if (rules.pattern) {
|
||||
const pattern = rules.pattern.value;
|
||||
const message = rules.pattern.message || `${fieldName} format is invalid`;
|
||||
|
||||
if (typeof value === 'string' && !pattern.test(value)) {
|
||||
errors.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
// Min value validation
|
||||
if (rules.min) {
|
||||
const min = rules.min.value;
|
||||
const message = rules.min.message || `${fieldName} must be at least ${min}`;
|
||||
|
||||
if (typeof value === 'number' && value < min) {
|
||||
errors.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
// Max value validation
|
||||
if (rules.max) {
|
||||
const max = rules.max.value;
|
||||
const message = rules.max.message || `${fieldName} must be at most ${max}`;
|
||||
|
||||
if (typeof value === 'number' && value > max) {
|
||||
errors.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
// Email validation
|
||||
if (rules.email) {
|
||||
const message = typeof rules.email === 'string'
|
||||
? rules.email
|
||||
: 'Please enter a valid email address';
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (typeof value === 'string' && !emailRegex.test(value)) {
|
||||
errors.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
// URL validation
|
||||
if (rules.url) {
|
||||
const message = typeof rules.url === 'string'
|
||||
? rules.url
|
||||
: 'Please enter a valid URL';
|
||||
|
||||
try {
|
||||
new URL(value);
|
||||
} catch {
|
||||
errors.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
// Number validation
|
||||
if (rules.number) {
|
||||
const message = typeof rules.number === 'string'
|
||||
? rules.number
|
||||
: 'Please enter a valid number';
|
||||
|
||||
if (isNaN(Number(value))) {
|
||||
errors.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
// Custom validation
|
||||
if (rules.custom) {
|
||||
const customError = rules.custom(value);
|
||||
if (customError) {
|
||||
errors.push(customError);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an entire form against validation rules
|
||||
*/
|
||||
export function validateForm<T extends Record<string, any>>(
|
||||
values: T,
|
||||
validationRules: Record<keyof T, ValidationRules>
|
||||
): { isValid: boolean; errors: FormErrors } {
|
||||
const errors: FormErrors = {};
|
||||
let isValid = true;
|
||||
|
||||
Object.keys(validationRules).forEach((fieldName) => {
|
||||
const fieldRules = validationRules[fieldName as keyof T];
|
||||
const fieldValue = values[fieldName];
|
||||
const fieldErrors = validateField(fieldValue, fieldRules, fieldName);
|
||||
|
||||
if (fieldErrors.length > 0) {
|
||||
errors[fieldName] = fieldErrors;
|
||||
isValid = false;
|
||||
}
|
||||
});
|
||||
|
||||
return { isValid, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for form validation
|
||||
*/
|
||||
export function useFormValidation<T extends Record<string, any>>(
|
||||
initialValues: T,
|
||||
validationRules: Record<keyof T, ValidationRules>
|
||||
) {
|
||||
const [values, setValues] = useState<T>(initialValues);
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
const [touched, setTouched] = useState<Record<keyof T, boolean>>(
|
||||
Object.keys(initialValues).reduce((acc, key) => {
|
||||
acc[key as keyof T] = false;
|
||||
return acc;
|
||||
}, {} as Record<keyof T, boolean>)
|
||||
);
|
||||
const [isValid, setIsValid] = useState(false);
|
||||
|
||||
const validate = useCallback(() => {
|
||||
const validation = validateForm(values, validationRules);
|
||||
setErrors(validation.errors);
|
||||
setIsValid(validation.isValid);
|
||||
return validation;
|
||||
}, [values, validationRules]);
|
||||
|
||||
useEffect(() => {
|
||||
validate();
|
||||
}, [validate]);
|
||||
|
||||
const setFieldValue = (field: keyof T, value: any) => {
|
||||
setValues((prev) => ({ ...prev, [field]: value }));
|
||||
setTouched((prev) => ({ ...prev, [field]: true }));
|
||||
};
|
||||
|
||||
const setFieldError = (field: keyof T, error: string) => {
|
||||
setErrors((prev) => ({
|
||||
...prev,
|
||||
[field]: [...(prev[field as string] || []), error],
|
||||
}));
|
||||
};
|
||||
|
||||
const clearFieldError = (field: keyof T) => {
|
||||
setErrors((prev) => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[field as string];
|
||||
return newErrors;
|
||||
});
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setValues(initialValues);
|
||||
setErrors({});
|
||||
setTouched(
|
||||
Object.keys(initialValues).reduce((acc, key) => {
|
||||
acc[key as keyof T] = false;
|
||||
return acc;
|
||||
}, {} as Record<keyof T, boolean>)
|
||||
);
|
||||
setIsValid(false);
|
||||
};
|
||||
|
||||
const setAllTouched = () => {
|
||||
setTouched(
|
||||
Object.keys(values).reduce((acc, key) => {
|
||||
acc[key as keyof T] = true;
|
||||
return acc;
|
||||
}, {} as Record<keyof T, boolean>)
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
values,
|
||||
errors,
|
||||
touched,
|
||||
isValid,
|
||||
setFieldValue,
|
||||
setFieldError,
|
||||
clearFieldError,
|
||||
validate,
|
||||
reset,
|
||||
setAllTouched,
|
||||
setValues,
|
||||
};
|
||||
}
|
||||
46
components/forms/index.ts
Normal file
46
components/forms/index.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* KLZ Forms System
|
||||
* Comprehensive form components and hooks for consistent form experience
|
||||
*/
|
||||
|
||||
// Components
|
||||
export { FormField, type FormFieldProps, type FormFieldType } from './FormField';
|
||||
export { FormLabel, type FormLabelProps } from './FormLabel';
|
||||
export { FormInput, type FormInputProps } from './FormInput';
|
||||
export { FormTextarea, type FormTextareaProps } from './FormTextarea';
|
||||
export { FormSelect, type FormSelectProps } from './FormSelect';
|
||||
export { FormCheckbox, type FormCheckboxProps, type CheckboxOption } from './FormCheckbox';
|
||||
export { FormRadio, type FormRadioProps, type RadioOption } from './FormRadio';
|
||||
export { FormError, type FormErrorProps } from './FormError';
|
||||
export { FormSuccess, type FormSuccessProps } from './FormSuccess';
|
||||
|
||||
// Hooks
|
||||
export { useForm, useFormWithHelpers } from './hooks/useForm';
|
||||
export { useFormField, useFormFieldWithHelpers } from './hooks/useFormField';
|
||||
export {
|
||||
useFormValidation,
|
||||
validateField,
|
||||
validateForm,
|
||||
type ValidationRules,
|
||||
type ValidationRule,
|
||||
type ValidationError,
|
||||
type FormErrors
|
||||
} from './hooks/useFormValidation';
|
||||
|
||||
// Types
|
||||
export type FormValues = Record<string, any>;
|
||||
export type FormValidationRules = Record<string, any>;
|
||||
|
||||
// Re-export for convenience
|
||||
export * from './FormField';
|
||||
export * from './FormLabel';
|
||||
export * from './FormInput';
|
||||
export * from './FormTextarea';
|
||||
export * from './FormSelect';
|
||||
export * from './FormCheckbox';
|
||||
export * from './FormRadio';
|
||||
export * from './FormError';
|
||||
export * from './FormSuccess';
|
||||
export * from './hooks/useForm';
|
||||
export * from './hooks/useFormField';
|
||||
export * from './hooks/useFormValidation';
|
||||
Reference in New Issue
Block a user