ux-toolkit 0.1.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +113 -7
- package/agents/card-reviewer.md +173 -0
- package/agents/comparison-reviewer.md +143 -0
- package/agents/density-reviewer.md +207 -0
- package/agents/detail-page-reviewer.md +143 -0
- package/agents/editor-reviewer.md +165 -0
- package/agents/form-reviewer.md +156 -0
- package/agents/game-ui-reviewer.md +181 -0
- package/agents/list-page-reviewer.md +132 -0
- package/agents/navigation-reviewer.md +145 -0
- package/agents/panel-reviewer.md +182 -0
- package/agents/replay-reviewer.md +174 -0
- package/agents/settings-reviewer.md +166 -0
- package/agents/ux-auditor.md +145 -45
- package/agents/ux-engineer.md +211 -38
- package/dist/cli.js +172 -5
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +172 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +128 -4
- package/dist/index.d.ts +128 -4
- package/dist/index.js +172 -5
- package/dist/index.js.map +1 -1
- package/package.json +6 -4
- package/skills/canvas-grid-patterns/SKILL.md +367 -0
- package/skills/comparison-patterns/SKILL.md +354 -0
- package/skills/data-density-patterns/SKILL.md +493 -0
- package/skills/detail-page-patterns/SKILL.md +522 -0
- package/skills/drag-drop-patterns/SKILL.md +406 -0
- package/skills/editor-workspace-patterns/SKILL.md +552 -0
- package/skills/event-timeline-patterns/SKILL.md +542 -0
- package/skills/form-patterns/SKILL.md +608 -0
- package/skills/info-card-patterns/SKILL.md +531 -0
- package/skills/keyboard-shortcuts-patterns/SKILL.md +365 -0
- package/skills/list-page-patterns/SKILL.md +351 -0
- package/skills/modal-patterns/SKILL.md +750 -0
- package/skills/navigation-patterns/SKILL.md +476 -0
- package/skills/page-structure-patterns/SKILL.md +271 -0
- package/skills/playback-replay-patterns/SKILL.md +695 -0
- package/skills/react-ux-patterns/SKILL.md +434 -0
- package/skills/split-panel-patterns/SKILL.md +609 -0
- package/skills/status-visualization-patterns/SKILL.md +635 -0
- package/skills/toast-notification-patterns/SKILL.md +207 -0
- package/skills/turn-based-ui-patterns/SKILL.md +506 -0
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: form-patterns
|
|
3
|
+
description: Form UX patterns including validation timing, field layouts, error handling, and multi-step wizard forms
|
|
4
|
+
license: MIT
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Form UX Patterns
|
|
8
|
+
|
|
9
|
+
Forms are the primary way users input data. Good form UX reduces friction and errors.
|
|
10
|
+
|
|
11
|
+
## Form Field Component
|
|
12
|
+
|
|
13
|
+
### Standard Field Structure
|
|
14
|
+
```tsx
|
|
15
|
+
interface FormFieldProps {
|
|
16
|
+
label: string;
|
|
17
|
+
htmlFor: string;
|
|
18
|
+
error?: string;
|
|
19
|
+
hint?: string;
|
|
20
|
+
required?: boolean;
|
|
21
|
+
children: ReactNode;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function FormField({ label, htmlFor, error, hint, required, children }: FormFieldProps) {
|
|
25
|
+
return (
|
|
26
|
+
<div className="space-y-1.5">
|
|
27
|
+
<label
|
|
28
|
+
htmlFor={htmlFor}
|
|
29
|
+
className="block text-sm font-medium text-text-primary"
|
|
30
|
+
>
|
|
31
|
+
{label}
|
|
32
|
+
{required && <span className="text-red-400 ml-1">*</span>}
|
|
33
|
+
</label>
|
|
34
|
+
|
|
35
|
+
{children}
|
|
36
|
+
|
|
37
|
+
{hint && !error && (
|
|
38
|
+
<p className="text-xs text-text-muted">{hint}</p>
|
|
39
|
+
)}
|
|
40
|
+
|
|
41
|
+
{error && (
|
|
42
|
+
<p className="text-xs text-red-400 flex items-center gap-1" role="alert">
|
|
43
|
+
<AlertIcon className="w-3 h-3" />
|
|
44
|
+
{error}
|
|
45
|
+
</p>
|
|
46
|
+
)}
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Input Components
|
|
53
|
+
|
|
54
|
+
### Text Input
|
|
55
|
+
```tsx
|
|
56
|
+
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
|
57
|
+
error?: boolean;
|
|
58
|
+
icon?: ReactNode;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function Input({ error, icon, className, ...props }: InputProps) {
|
|
62
|
+
return (
|
|
63
|
+
<div className="relative">
|
|
64
|
+
{icon && (
|
|
65
|
+
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted">
|
|
66
|
+
{icon}
|
|
67
|
+
</div>
|
|
68
|
+
)}
|
|
69
|
+
<input
|
|
70
|
+
className={`
|
|
71
|
+
w-full px-3 py-2 bg-surface-deep border rounded-lg text-white text-sm
|
|
72
|
+
placeholder:text-text-muted
|
|
73
|
+
focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent
|
|
74
|
+
disabled:opacity-50 disabled:cursor-not-allowed
|
|
75
|
+
${icon ? 'pl-10' : ''}
|
|
76
|
+
${error ? 'border-red-500 focus:ring-red-500/50' : 'border-border'}
|
|
77
|
+
${className || ''}
|
|
78
|
+
`}
|
|
79
|
+
{...props}
|
|
80
|
+
/>
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Textarea
|
|
87
|
+
```tsx
|
|
88
|
+
function Textarea({ error, className, ...props }: TextareaProps) {
|
|
89
|
+
return (
|
|
90
|
+
<textarea
|
|
91
|
+
className={`
|
|
92
|
+
w-full px-3 py-2 bg-surface-deep border rounded-lg text-white text-sm
|
|
93
|
+
placeholder:text-text-muted resize-y min-h-[100px]
|
|
94
|
+
focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent
|
|
95
|
+
${error ? 'border-red-500' : 'border-border'}
|
|
96
|
+
${className || ''}
|
|
97
|
+
`}
|
|
98
|
+
{...props}
|
|
99
|
+
/>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Select
|
|
105
|
+
```tsx
|
|
106
|
+
interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
|
107
|
+
options: { value: string; label: string }[];
|
|
108
|
+
placeholder?: string;
|
|
109
|
+
error?: boolean;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function Select({ options, placeholder, error, className, ...props }: SelectProps) {
|
|
113
|
+
return (
|
|
114
|
+
<select
|
|
115
|
+
className={`
|
|
116
|
+
w-full px-3 py-2 bg-surface-deep border rounded-lg text-white text-sm
|
|
117
|
+
focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent
|
|
118
|
+
${error ? 'border-red-500' : 'border-border'}
|
|
119
|
+
${className || ''}
|
|
120
|
+
`}
|
|
121
|
+
{...props}
|
|
122
|
+
>
|
|
123
|
+
{placeholder && (
|
|
124
|
+
<option value="" disabled>
|
|
125
|
+
{placeholder}
|
|
126
|
+
</option>
|
|
127
|
+
)}
|
|
128
|
+
{options.map((opt) => (
|
|
129
|
+
<option key={opt.value} value={opt.value}>
|
|
130
|
+
{opt.label}
|
|
131
|
+
</option>
|
|
132
|
+
))}
|
|
133
|
+
</select>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### Checkbox / Radio
|
|
139
|
+
```tsx
|
|
140
|
+
function Checkbox({ label, checked, onChange, disabled }: CheckboxProps) {
|
|
141
|
+
return (
|
|
142
|
+
<label className={`
|
|
143
|
+
flex items-center gap-2 cursor-pointer
|
|
144
|
+
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
|
|
145
|
+
`}>
|
|
146
|
+
<input
|
|
147
|
+
type="checkbox"
|
|
148
|
+
checked={checked}
|
|
149
|
+
onChange={onChange}
|
|
150
|
+
disabled={disabled}
|
|
151
|
+
className="
|
|
152
|
+
w-4 h-4 rounded border-border bg-surface-deep
|
|
153
|
+
text-accent focus:ring-accent focus:ring-offset-0
|
|
154
|
+
checked:bg-accent checked:border-accent
|
|
155
|
+
"
|
|
156
|
+
/>
|
|
157
|
+
<span className="text-sm text-text-primary">{label}</span>
|
|
158
|
+
</label>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## Validation Timing
|
|
164
|
+
|
|
165
|
+
### When to Validate
|
|
166
|
+
|
|
167
|
+
| Timing | Use For | Example |
|
|
168
|
+
|--------|---------|---------|
|
|
169
|
+
| On blur | Most fields | Email format after leaving field |
|
|
170
|
+
| On change (debounced) | Real-time feedback | Password strength |
|
|
171
|
+
| On submit | Final validation | All fields before submit |
|
|
172
|
+
| Async | Server validation | Username availability |
|
|
173
|
+
|
|
174
|
+
### Validation Pattern
|
|
175
|
+
```tsx
|
|
176
|
+
function useFormValidation<T extends Record<string, any>>(
|
|
177
|
+
initialData: T,
|
|
178
|
+
validators: Record<keyof T, (value: any, data: T) => string | undefined>
|
|
179
|
+
) {
|
|
180
|
+
const [data, setData] = useState<T>(initialData);
|
|
181
|
+
const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({});
|
|
182
|
+
const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({});
|
|
183
|
+
|
|
184
|
+
const validateField = useCallback((field: keyof T) => {
|
|
185
|
+
const validator = validators[field];
|
|
186
|
+
if (validator) {
|
|
187
|
+
const error = validator(data[field], data);
|
|
188
|
+
setErrors(prev => ({ ...prev, [field]: error }));
|
|
189
|
+
return !error;
|
|
190
|
+
}
|
|
191
|
+
return true;
|
|
192
|
+
}, [data, validators]);
|
|
193
|
+
|
|
194
|
+
const handleBlur = useCallback((field: keyof T) => {
|
|
195
|
+
setTouched(prev => ({ ...prev, [field]: true }));
|
|
196
|
+
validateField(field);
|
|
197
|
+
}, [validateField]);
|
|
198
|
+
|
|
199
|
+
const handleChange = useCallback((field: keyof T, value: any) => {
|
|
200
|
+
setData(prev => ({ ...prev, [field]: value }));
|
|
201
|
+
// Clear error on change if field was touched
|
|
202
|
+
if (touched[field]) {
|
|
203
|
+
setErrors(prev => ({ ...prev, [field]: undefined }));
|
|
204
|
+
}
|
|
205
|
+
}, [touched]);
|
|
206
|
+
|
|
207
|
+
const validateAll = useCallback(() => {
|
|
208
|
+
const newErrors: Partial<Record<keyof T, string>> = {};
|
|
209
|
+
let isValid = true;
|
|
210
|
+
|
|
211
|
+
for (const field of Object.keys(validators) as (keyof T)[]) {
|
|
212
|
+
const error = validators[field](data[field], data);
|
|
213
|
+
if (error) {
|
|
214
|
+
newErrors[field] = error;
|
|
215
|
+
isValid = false;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
setErrors(newErrors);
|
|
220
|
+
setTouched(Object.keys(data).reduce((acc, key) => ({ ...acc, [key]: true }), {}));
|
|
221
|
+
return isValid;
|
|
222
|
+
}, [data, validators]);
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
data,
|
|
226
|
+
errors,
|
|
227
|
+
touched,
|
|
228
|
+
handleChange,
|
|
229
|
+
handleBlur,
|
|
230
|
+
validateField,
|
|
231
|
+
validateAll,
|
|
232
|
+
setData,
|
|
233
|
+
reset: () => {
|
|
234
|
+
setData(initialData);
|
|
235
|
+
setErrors({});
|
|
236
|
+
setTouched({});
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Usage
|
|
242
|
+
const { data, errors, handleChange, handleBlur, validateAll } = useFormValidation(
|
|
243
|
+
{ email: '', password: '' },
|
|
244
|
+
{
|
|
245
|
+
email: (value) => {
|
|
246
|
+
if (!value) return 'Email is required';
|
|
247
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return 'Invalid email format';
|
|
248
|
+
},
|
|
249
|
+
password: (value) => {
|
|
250
|
+
if (!value) return 'Password is required';
|
|
251
|
+
if (value.length < 8) return 'Password must be at least 8 characters';
|
|
252
|
+
},
|
|
253
|
+
}
|
|
254
|
+
);
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
## Form Layout Patterns
|
|
258
|
+
|
|
259
|
+
### Single Column Form
|
|
260
|
+
```tsx
|
|
261
|
+
// Default for most forms - stack fields vertically
|
|
262
|
+
<form className="space-y-4 max-w-md">
|
|
263
|
+
<FormField label="Name" htmlFor="name" required>
|
|
264
|
+
<Input id="name" />
|
|
265
|
+
</FormField>
|
|
266
|
+
|
|
267
|
+
<FormField label="Email" htmlFor="email" required>
|
|
268
|
+
<Input id="email" type="email" />
|
|
269
|
+
</FormField>
|
|
270
|
+
|
|
271
|
+
<FormField label="Message" htmlFor="message">
|
|
272
|
+
<Textarea id="message" />
|
|
273
|
+
</FormField>
|
|
274
|
+
</form>
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
### Two Column Form
|
|
278
|
+
```tsx
|
|
279
|
+
// For related fields that fit together
|
|
280
|
+
<form className="space-y-4">
|
|
281
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
282
|
+
<FormField label="First Name" htmlFor="firstName">
|
|
283
|
+
<Input id="firstName" />
|
|
284
|
+
</FormField>
|
|
285
|
+
<FormField label="Last Name" htmlFor="lastName">
|
|
286
|
+
<Input id="lastName" />
|
|
287
|
+
</FormField>
|
|
288
|
+
</div>
|
|
289
|
+
|
|
290
|
+
<FormField label="Email" htmlFor="email">
|
|
291
|
+
<Input id="email" type="email" />
|
|
292
|
+
</FormField>
|
|
293
|
+
</form>
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
### Sectioned Form
|
|
297
|
+
```tsx
|
|
298
|
+
// For long forms with logical groupings
|
|
299
|
+
<form className="space-y-8">
|
|
300
|
+
<FormSection title="Personal Information" description="Your basic details">
|
|
301
|
+
<FormField label="Name" htmlFor="name">
|
|
302
|
+
<Input id="name" />
|
|
303
|
+
</FormField>
|
|
304
|
+
</FormSection>
|
|
305
|
+
|
|
306
|
+
<FormSection title="Contact Information" description="How we can reach you">
|
|
307
|
+
<FormField label="Email" htmlFor="email">
|
|
308
|
+
<Input id="email" type="email" />
|
|
309
|
+
</FormField>
|
|
310
|
+
</FormSection>
|
|
311
|
+
</form>
|
|
312
|
+
|
|
313
|
+
function FormSection({ title, description, children }) {
|
|
314
|
+
return (
|
|
315
|
+
<div className="border-b border-border pb-6">
|
|
316
|
+
<h3 className="text-lg font-semibold text-white mb-1">{title}</h3>
|
|
317
|
+
{description && (
|
|
318
|
+
<p className="text-sm text-text-secondary mb-4">{description}</p>
|
|
319
|
+
)}
|
|
320
|
+
<div className="space-y-4">{children}</div>
|
|
321
|
+
</div>
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
## Form Submission States
|
|
327
|
+
|
|
328
|
+
### Submit Button States
|
|
329
|
+
```tsx
|
|
330
|
+
interface SubmitButtonProps {
|
|
331
|
+
isLoading: boolean;
|
|
332
|
+
isDisabled: boolean;
|
|
333
|
+
loadingText?: string;
|
|
334
|
+
children: ReactNode;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function SubmitButton({ isLoading, isDisabled, loadingText = 'Saving...', children }: SubmitButtonProps) {
|
|
338
|
+
return (
|
|
339
|
+
<Button
|
|
340
|
+
type="submit"
|
|
341
|
+
variant="primary"
|
|
342
|
+
disabled={isLoading || isDisabled}
|
|
343
|
+
className="w-full sm:w-auto"
|
|
344
|
+
>
|
|
345
|
+
{isLoading ? (
|
|
346
|
+
<>
|
|
347
|
+
<Spinner className="w-4 h-4 mr-2" />
|
|
348
|
+
{loadingText}
|
|
349
|
+
</>
|
|
350
|
+
) : (
|
|
351
|
+
children
|
|
352
|
+
)}
|
|
353
|
+
</Button>
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
### Form Action Bar
|
|
359
|
+
```tsx
|
|
360
|
+
function FormActionBar({ onCancel, isLoading, isDirty, submitLabel = 'Save' }) {
|
|
361
|
+
return (
|
|
362
|
+
<div className="flex items-center justify-end gap-3 pt-6 border-t border-border mt-6">
|
|
363
|
+
<Button
|
|
364
|
+
type="button"
|
|
365
|
+
variant="ghost"
|
|
366
|
+
onClick={onCancel}
|
|
367
|
+
disabled={isLoading}
|
|
368
|
+
>
|
|
369
|
+
Cancel
|
|
370
|
+
</Button>
|
|
371
|
+
<SubmitButton isLoading={isLoading} isDisabled={!isDirty}>
|
|
372
|
+
{submitLabel}
|
|
373
|
+
</SubmitButton>
|
|
374
|
+
</div>
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
## Error Handling
|
|
380
|
+
|
|
381
|
+
### Form-Level Error
|
|
382
|
+
```tsx
|
|
383
|
+
{formError && (
|
|
384
|
+
<div className="p-4 bg-red-900/20 border border-red-600/30 rounded-lg" role="alert">
|
|
385
|
+
<div className="flex items-start gap-3">
|
|
386
|
+
<AlertCircleIcon className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" />
|
|
387
|
+
<div>
|
|
388
|
+
<p className="text-sm font-medium text-red-400">
|
|
389
|
+
There was a problem with your submission
|
|
390
|
+
</p>
|
|
391
|
+
<p className="text-sm text-red-400/80 mt-1">{formError}</p>
|
|
392
|
+
</div>
|
|
393
|
+
</div>
|
|
394
|
+
</div>
|
|
395
|
+
)}
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
### Field Error Summary
|
|
399
|
+
```tsx
|
|
400
|
+
// Show at top of form when there are errors
|
|
401
|
+
{Object.keys(errors).length > 0 && (
|
|
402
|
+
<div className="p-4 bg-red-900/20 border border-red-600/30 rounded-lg" role="alert">
|
|
403
|
+
<p className="text-sm font-medium text-red-400 mb-2">
|
|
404
|
+
Please fix the following errors:
|
|
405
|
+
</p>
|
|
406
|
+
<ul className="list-disc list-inside text-sm text-red-400/80 space-y-1">
|
|
407
|
+
{Object.entries(errors).map(([field, error]) => (
|
|
408
|
+
<li key={field}>{error}</li>
|
|
409
|
+
))}
|
|
410
|
+
</ul>
|
|
411
|
+
</div>
|
|
412
|
+
)}
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
## Success States
|
|
416
|
+
|
|
417
|
+
### Inline Success
|
|
418
|
+
```tsx
|
|
419
|
+
{isSuccess && (
|
|
420
|
+
<div className="p-4 bg-emerald-900/20 border border-emerald-600/30 rounded-lg" role="status">
|
|
421
|
+
<div className="flex items-center gap-3">
|
|
422
|
+
<CheckCircleIcon className="w-5 h-5 text-emerald-400" />
|
|
423
|
+
<p className="text-sm text-emerald-400">
|
|
424
|
+
Changes saved successfully!
|
|
425
|
+
</p>
|
|
426
|
+
</div>
|
|
427
|
+
</div>
|
|
428
|
+
)}
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
## Multi-Step Form (Wizard)
|
|
432
|
+
|
|
433
|
+
### Step Progress Indicator
|
|
434
|
+
```tsx
|
|
435
|
+
function StepIndicator({ steps, currentStep }: { steps: string[]; currentStep: number }) {
|
|
436
|
+
return (
|
|
437
|
+
<div className="flex items-center justify-between mb-8">
|
|
438
|
+
{steps.map((step, index) => {
|
|
439
|
+
const status = index < currentStep ? 'complete' : index === currentStep ? 'current' : 'upcoming';
|
|
440
|
+
return (
|
|
441
|
+
<div key={step} className="flex items-center">
|
|
442
|
+
{/* Step circle */}
|
|
443
|
+
<div className={`
|
|
444
|
+
w-10 h-10 rounded-full flex items-center justify-center font-medium
|
|
445
|
+
${status === 'complete' ? 'bg-accent text-white' :
|
|
446
|
+
status === 'current' ? 'bg-accent/20 border-2 border-accent text-accent' :
|
|
447
|
+
'bg-surface-raised text-text-muted'}
|
|
448
|
+
`}>
|
|
449
|
+
{status === 'complete' ? <CheckIcon className="w-5 h-5" /> : index + 1}
|
|
450
|
+
</div>
|
|
451
|
+
|
|
452
|
+
{/* Step label */}
|
|
453
|
+
<span className={`ml-3 text-sm font-medium hidden sm:block
|
|
454
|
+
${status === 'current' ? 'text-white' : 'text-text-secondary'}
|
|
455
|
+
`}>
|
|
456
|
+
{step}
|
|
457
|
+
</span>
|
|
458
|
+
|
|
459
|
+
{/* Connector line */}
|
|
460
|
+
{index < steps.length - 1 && (
|
|
461
|
+
<div className={`w-12 sm:w-24 h-0.5 mx-4
|
|
462
|
+
${index < currentStep ? 'bg-accent' : 'bg-border'}
|
|
463
|
+
`} />
|
|
464
|
+
)}
|
|
465
|
+
</div>
|
|
466
|
+
);
|
|
467
|
+
})}
|
|
468
|
+
</div>
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
### Wizard Form Component
|
|
474
|
+
```tsx
|
|
475
|
+
function WizardForm<T>({
|
|
476
|
+
steps,
|
|
477
|
+
initialData,
|
|
478
|
+
onComplete,
|
|
479
|
+
renderStep,
|
|
480
|
+
validateStep,
|
|
481
|
+
}: WizardFormProps<T>) {
|
|
482
|
+
const [currentStep, setCurrentStep] = useState(0);
|
|
483
|
+
const [data, setData] = useState<T>(initialData);
|
|
484
|
+
const [stepErrors, setStepErrors] = useState<Record<string, string>>({});
|
|
485
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
486
|
+
|
|
487
|
+
const isFirstStep = currentStep === 0;
|
|
488
|
+
const isLastStep = currentStep === steps.length - 1;
|
|
489
|
+
|
|
490
|
+
const handleNext = async () => {
|
|
491
|
+
const errors = validateStep?.(currentStep, data);
|
|
492
|
+
if (errors && Object.keys(errors).length > 0) {
|
|
493
|
+
setStepErrors(errors);
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
setStepErrors({});
|
|
497
|
+
setCurrentStep(prev => prev + 1);
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
const handleBack = () => {
|
|
501
|
+
setStepErrors({});
|
|
502
|
+
setCurrentStep(prev => prev - 1);
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
const handleSubmit = async () => {
|
|
506
|
+
const errors = validateStep?.(currentStep, data);
|
|
507
|
+
if (errors && Object.keys(errors).length > 0) {
|
|
508
|
+
setStepErrors(errors);
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
setIsSubmitting(true);
|
|
513
|
+
try {
|
|
514
|
+
await onComplete(data);
|
|
515
|
+
} finally {
|
|
516
|
+
setIsSubmitting(false);
|
|
517
|
+
}
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
return (
|
|
521
|
+
<div>
|
|
522
|
+
<StepIndicator steps={steps.map(s => s.title)} currentStep={currentStep} />
|
|
523
|
+
|
|
524
|
+
<div className="min-h-[300px]">
|
|
525
|
+
{renderStep(currentStep, data, setData, stepErrors)}
|
|
526
|
+
</div>
|
|
527
|
+
|
|
528
|
+
<div className="flex items-center justify-between pt-6 border-t border-border mt-6">
|
|
529
|
+
<Button
|
|
530
|
+
variant="ghost"
|
|
531
|
+
onClick={handleBack}
|
|
532
|
+
disabled={isFirstStep || isSubmitting}
|
|
533
|
+
className={isFirstStep ? 'invisible' : ''}
|
|
534
|
+
>
|
|
535
|
+
Back
|
|
536
|
+
</Button>
|
|
537
|
+
|
|
538
|
+
{isLastStep ? (
|
|
539
|
+
<Button
|
|
540
|
+
variant="primary"
|
|
541
|
+
onClick={handleSubmit}
|
|
542
|
+
disabled={isSubmitting}
|
|
543
|
+
>
|
|
544
|
+
{isSubmitting ? 'Submitting...' : 'Complete'}
|
|
545
|
+
</Button>
|
|
546
|
+
) : (
|
|
547
|
+
<Button variant="primary" onClick={handleNext}>
|
|
548
|
+
Continue
|
|
549
|
+
</Button>
|
|
550
|
+
)}
|
|
551
|
+
</div>
|
|
552
|
+
</div>
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
## Accessibility Requirements
|
|
558
|
+
|
|
559
|
+
### Required Field Indication
|
|
560
|
+
```tsx
|
|
561
|
+
// Announce required fields to screen readers
|
|
562
|
+
<label>
|
|
563
|
+
Email
|
|
564
|
+
<span className="text-red-400 ml-1" aria-label="required">*</span>
|
|
565
|
+
</label>
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
### Error Association
|
|
569
|
+
```tsx
|
|
570
|
+
<input
|
|
571
|
+
id="email"
|
|
572
|
+
aria-invalid={!!error}
|
|
573
|
+
aria-describedby={error ? 'email-error' : hint ? 'email-hint' : undefined}
|
|
574
|
+
/>
|
|
575
|
+
{error && <p id="email-error" role="alert">{error}</p>}
|
|
576
|
+
{hint && !error && <p id="email-hint">{hint}</p>}
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
### Form Instructions
|
|
580
|
+
```tsx
|
|
581
|
+
// Announce form requirements at the top
|
|
582
|
+
<p className="text-sm text-text-secondary mb-4">
|
|
583
|
+
Fields marked with <span className="text-red-400">*</span> are required.
|
|
584
|
+
</p>
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
## Audit Checklist for Forms
|
|
588
|
+
|
|
589
|
+
### Critical (Must Fix)
|
|
590
|
+
- [ ] All fields have visible labels - accessibility violation
|
|
591
|
+
- [ ] Error messages are clear and specific - users can't fix issues
|
|
592
|
+
- [ ] Errors appear next to relevant field - users can't find problems
|
|
593
|
+
- [ ] Keyboard navigation works (Tab, Enter) - accessibility violation
|
|
594
|
+
- [ ] Form can be submitted with Enter key - accessibility violation
|
|
595
|
+
|
|
596
|
+
### Major (Should Fix)
|
|
597
|
+
- [ ] Required fields are marked - users submit incomplete forms
|
|
598
|
+
- [ ] Validation timing is appropriate - frustrating UX
|
|
599
|
+
- [ ] Form has loading state during submission - users double-submit
|
|
600
|
+
- [ ] Success/error feedback after submission - users don't know outcome
|
|
601
|
+
- [ ] Cancel button doesn't submit form - data loss risk
|
|
602
|
+
- [ ] Dirty state tracked for unsaved changes warning - data loss risk
|
|
603
|
+
|
|
604
|
+
### Minor (Nice to Have)
|
|
605
|
+
- [ ] Long forms are sectioned or multi-step - cognitive load
|
|
606
|
+
- [ ] Password fields have visibility toggle - convenience
|
|
607
|
+
- [ ] Autofill attributes are correct - convenience
|
|
608
|
+
- [ ] Form resets properly after submission - edge case handling
|