@lglab/compose-ui-mcp 0.0.1

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.
Files changed (48) hide show
  1. package/README.md +11 -0
  2. package/dist/assets/llms/accordion.md +184 -0
  3. package/dist/assets/llms/alert-dialog.md +306 -0
  4. package/dist/assets/llms/autocomplete.md +756 -0
  5. package/dist/assets/llms/avatar.md +166 -0
  6. package/dist/assets/llms/badge.md +478 -0
  7. package/dist/assets/llms/button.md +238 -0
  8. package/dist/assets/llms/card.md +264 -0
  9. package/dist/assets/llms/checkbox-group.md +158 -0
  10. package/dist/assets/llms/checkbox.md +83 -0
  11. package/dist/assets/llms/collapsible.md +165 -0
  12. package/dist/assets/llms/combobox.md +1255 -0
  13. package/dist/assets/llms/context-menu.md +371 -0
  14. package/dist/assets/llms/dialog.md +592 -0
  15. package/dist/assets/llms/drawer.md +437 -0
  16. package/dist/assets/llms/field.md +74 -0
  17. package/dist/assets/llms/form.md +1931 -0
  18. package/dist/assets/llms/input.md +47 -0
  19. package/dist/assets/llms/menu.md +484 -0
  20. package/dist/assets/llms/menubar.md +804 -0
  21. package/dist/assets/llms/meter.md +181 -0
  22. package/dist/assets/llms/navigation-menu.md +187 -0
  23. package/dist/assets/llms/number-field.md +243 -0
  24. package/dist/assets/llms/pagination.md +514 -0
  25. package/dist/assets/llms/popover.md +206 -0
  26. package/dist/assets/llms/preview-card.md +146 -0
  27. package/dist/assets/llms/progress.md +60 -0
  28. package/dist/assets/llms/radio-group.md +105 -0
  29. package/dist/assets/llms/scroll-area.md +132 -0
  30. package/dist/assets/llms/select.md +276 -0
  31. package/dist/assets/llms/separator.md +49 -0
  32. package/dist/assets/llms/skeleton.md +96 -0
  33. package/dist/assets/llms/slider.md +161 -0
  34. package/dist/assets/llms/switch.md +101 -0
  35. package/dist/assets/llms/table.md +1325 -0
  36. package/dist/assets/llms/tabs.md +327 -0
  37. package/dist/assets/llms/textarea.md +38 -0
  38. package/dist/assets/llms/toast.md +349 -0
  39. package/dist/assets/llms/toggle-group.md +261 -0
  40. package/dist/assets/llms/toggle.md +161 -0
  41. package/dist/assets/llms/toolbar.md +148 -0
  42. package/dist/assets/llms/tooltip.md +486 -0
  43. package/dist/assets/llms-full.txt +14515 -0
  44. package/dist/assets/llms.txt +65 -0
  45. package/dist/index.d.mts +1 -0
  46. package/dist/index.mjs +161 -0
  47. package/dist/index.mjs.map +1 -0
  48. package/package.json +54 -0
@@ -0,0 +1,1931 @@
1
+ # Form
2
+
3
+ A native form element with consolidated error handling. Examples include useActionState, Zod schema validation, React Hook Form, and TanStack Form integrations.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @lglab/compose-ui
9
+ ```
10
+
11
+ ## Import
12
+
13
+ ```tsx
14
+ import { FormRoot } from '@lglab/compose-ui'
15
+ ```
16
+
17
+ ## Examples
18
+
19
+ ### Default
20
+
21
+ ```tsx
22
+ import { Button } from '@lglab/compose-ui/button'
23
+ import { CheckboxIndicator, CheckboxRoot } from '@lglab/compose-ui/checkbox'
24
+ import { CheckboxGroupRoot } from '@lglab/compose-ui/checkbox-group'
25
+ import {
26
+ FieldControl,
27
+ FieldDescription,
28
+ FieldError,
29
+ FieldItem,
30
+ FieldLabel,
31
+ FieldRoot,
32
+ FieldValidity,
33
+ } from '@lglab/compose-ui/field'
34
+ import { FieldsetLegend, FieldsetRoot } from '@lglab/compose-ui/fieldset'
35
+ import { FormRoot } from '@lglab/compose-ui/form'
36
+ import { RadioIndicator, RadioRoot } from '@lglab/compose-ui/radio'
37
+ import { RadioGroupRoot } from '@lglab/compose-ui/radio-group'
38
+ import {
39
+ SelectIcon,
40
+ SelectItem,
41
+ SelectItemIndicator,
42
+ SelectItemText,
43
+ SelectList,
44
+ SelectPopup,
45
+ SelectPortal,
46
+ SelectPositioner,
47
+ SelectRoot,
48
+ SelectScrollDownArrow,
49
+ SelectScrollUpArrow,
50
+ SelectTrigger,
51
+ SelectValue,
52
+ } from '@lglab/compose-ui/select'
53
+ import {
54
+ SliderControl,
55
+ SliderIndicator,
56
+ SliderRoot,
57
+ SliderThumb,
58
+ SliderTrack,
59
+ SliderValue,
60
+ } from '@lglab/compose-ui/slider'
61
+ import { SwitchRoot, SwitchThumb } from '@lglab/compose-ui/switch'
62
+ import { Textarea } from '@lglab/compose-ui/textarea'
63
+ import { Check, ChevronsUpDown } from 'lucide-react'
64
+ import * as React from 'react'
65
+
66
+ const countries = [
67
+ { label: 'United States', value: 'us' },
68
+ { label: 'United Kingdom', value: 'uk' },
69
+ { label: 'Canada', value: 'ca' },
70
+ { label: 'Australia', value: 'au' },
71
+ { label: 'Germany', value: 'de' },
72
+ { label: 'France', value: 'fr' },
73
+ ]
74
+
75
+ const accountTypes = [
76
+ { value: 'personal', label: 'Personal' },
77
+ { value: 'business', label: 'Business' },
78
+ { value: 'developer', label: 'Developer' },
79
+ ]
80
+
81
+ const interests = [
82
+ { value: 'technology', label: 'Technology' },
83
+ { value: 'design', label: 'Design' },
84
+ { value: 'marketing', label: 'Marketing' },
85
+ { value: 'finance', label: 'Finance' },
86
+ ]
87
+
88
+ export default function DefaultExample() {
89
+ const [errors, setErrors] = React.useState<Record<string, string>>({})
90
+ const [loading, setLoading] = React.useState(false)
91
+
92
+ return (
93
+ <FormRoot
94
+ className='w-full max-w-md space-y-2'
95
+ errors={errors}
96
+ onFormSubmit={async (formValues) => {
97
+ setLoading(true)
98
+ const serverErrors = await validateForm(formValues)
99
+ setErrors(serverErrors)
100
+ setLoading(false)
101
+ }}
102
+ >
103
+ <FieldRoot name='fullName'>
104
+ <FieldLabel>Full Name</FieldLabel>
105
+ <FieldControl required minLength={2} placeholder='John Doe' />
106
+ <FieldError />
107
+ </FieldRoot>
108
+
109
+ <FieldRoot name='email'>
110
+ <FieldLabel>Email Address</FieldLabel>
111
+ <FieldControl type='email' required placeholder='john@example.com' />
112
+ <FieldDescription>We will never share your email.</FieldDescription>
113
+ <FieldError />
114
+ </FieldRoot>
115
+
116
+ <FieldRoot name='username'>
117
+ <FieldLabel>Username</FieldLabel>
118
+ <FieldControl
119
+ required
120
+ pattern='[a-z0-9_]+'
121
+ minLength={3}
122
+ maxLength={20}
123
+ placeholder='john_doe'
124
+ />
125
+ <FieldDescription>
126
+ Lowercase letters, numbers, and underscores only.
127
+ </FieldDescription>
128
+ <FieldValidity>
129
+ {(state) => {
130
+ if (state.validity.valueMissing) {
131
+ return <FieldError>Please enter a username.</FieldError>
132
+ }
133
+ if (state.validity.tooShort) {
134
+ return <FieldError>Username must be at least 3 characters.</FieldError>
135
+ }
136
+ if (state.validity.tooLong) {
137
+ return <FieldError>Username must be at most 20 characters.</FieldError>
138
+ }
139
+ if (state.validity.patternMismatch) {
140
+ return (
141
+ <FieldError>
142
+ Only lowercase letters, numbers, and underscores are allowed.
143
+ </FieldError>
144
+ )
145
+ }
146
+ if (state.error) {
147
+ return <FieldError>{state.error}</FieldError>
148
+ }
149
+ return null
150
+ }}
151
+ </FieldValidity>
152
+ {!!errors.username && <FieldError />}
153
+ </FieldRoot>
154
+
155
+ <FieldRoot name='password'>
156
+ <FieldLabel>Password</FieldLabel>
157
+ <FieldControl
158
+ type='password'
159
+ required
160
+ minLength={8}
161
+ placeholder='Enter password'
162
+ />
163
+ <FieldDescription>Must be at least 8 characters.</FieldDescription>
164
+ <FieldError />
165
+ </FieldRoot>
166
+
167
+ <FieldRoot name='country'>
168
+ <FieldLabel nativeLabel={false} render={<div />}>
169
+ Country
170
+ </FieldLabel>
171
+ <SelectRoot items={countries} required>
172
+ <SelectTrigger>
173
+ <SelectValue placeholder='Select country' />
174
+ <SelectIcon>
175
+ <ChevronsUpDown className='size-4' />
176
+ </SelectIcon>
177
+ </SelectTrigger>
178
+ <SelectPortal>
179
+ <SelectPositioner>
180
+ <SelectPopup>
181
+ <SelectScrollUpArrow />
182
+ <SelectList>
183
+ {countries.map(({ label, value }) => (
184
+ <SelectItem key={value} value={value}>
185
+ <SelectItemText>{label}</SelectItemText>
186
+ <SelectItemIndicator>
187
+ <Check className='size-3.5' />
188
+ </SelectItemIndicator>
189
+ </SelectItem>
190
+ ))}
191
+ </SelectList>
192
+ <SelectScrollDownArrow />
193
+ </SelectPopup>
194
+ </SelectPositioner>
195
+ </SelectPortal>
196
+ </SelectRoot>
197
+ <FieldError />
198
+ </FieldRoot>
199
+
200
+ <FieldRoot name='bio'>
201
+ <FieldLabel>Bio</FieldLabel>
202
+ <FieldControl
203
+ render={<Textarea />}
204
+ placeholder='Tell us about yourself...'
205
+ maxLength={500}
206
+ />
207
+ <FieldDescription>Optional. Max 500 characters.</FieldDescription>
208
+ <FieldError />
209
+ </FieldRoot>
210
+
211
+ <FieldRoot name='accountType'>
212
+ <FieldsetRoot
213
+ render={<RadioGroupRoot name='accountType' defaultValue='personal' />}
214
+ >
215
+ <FieldsetLegend>Account Type</FieldsetLegend>
216
+ {accountTypes.map((type) => (
217
+ <FieldItem key={type.value}>
218
+ <FieldLabel>
219
+ <RadioRoot value={type.value}>
220
+ <RadioIndicator />
221
+ </RadioRoot>
222
+ {type.label}
223
+ </FieldLabel>
224
+ </FieldItem>
225
+ ))}
226
+ </FieldsetRoot>
227
+ <FieldError />
228
+ </FieldRoot>
229
+
230
+ <FieldRoot name='interests'>
231
+ <FieldsetRoot render={<CheckboxGroupRoot defaultValue={[]} />}>
232
+ <FieldsetLegend>Interests</FieldsetLegend>
233
+ {interests.map((interest) => (
234
+ <FieldItem key={interest.value}>
235
+ <FieldLabel>
236
+ <CheckboxRoot value={interest.value}>
237
+ <CheckboxIndicator>
238
+ <Check className='size-3.5' />
239
+ </CheckboxIndicator>
240
+ </CheckboxRoot>
241
+ {interest.label}
242
+ </FieldLabel>
243
+ </FieldItem>
244
+ ))}
245
+ </FieldsetRoot>
246
+ <FieldDescription>Select at least one interest.</FieldDescription>
247
+ <FieldError />
248
+ </FieldRoot>
249
+
250
+ <FieldRoot name='experience'>
251
+ <FieldsetRoot render={<SliderRoot defaultValue={50} thumbAlignment='edge' />}>
252
+ <div className='flex items-center justify-between text-sm'>
253
+ <FieldsetLegend className='flex-1'>Experience Level</FieldsetLegend>
254
+ <SliderValue className='tabular-nums' />
255
+ </div>
256
+ <SliderControl>
257
+ <SliderTrack>
258
+ <SliderIndicator />
259
+ <SliderThumb aria-label='Experience level' />
260
+ </SliderTrack>
261
+ </SliderControl>
262
+ </FieldsetRoot>
263
+ <FieldDescription>0 = Beginner, 100 = Expert</FieldDescription>
264
+ </FieldRoot>
265
+
266
+ <FieldRoot name='newsletter'>
267
+ <FieldItem>
268
+ <FieldLabel className='flex items-center gap-3'>
269
+ <SwitchRoot name='newsletter' defaultChecked>
270
+ <SwitchThumb />
271
+ </SwitchRoot>
272
+ Subscribe to newsletter
273
+ </FieldLabel>
274
+ </FieldItem>
275
+ <FieldDescription>Receive updates and promotions via email.</FieldDescription>
276
+ </FieldRoot>
277
+
278
+ <FieldRoot name='terms'>
279
+ <FieldItem>
280
+ <FieldLabel>
281
+ <CheckboxRoot name='terms' required>
282
+ <CheckboxIndicator>
283
+ <Check className='size-3.5' />
284
+ </CheckboxIndicator>
285
+ </CheckboxRoot>
286
+ I agree to the Terms of Service and Privacy Policy
287
+ </FieldLabel>
288
+ </FieldItem>
289
+ <FieldError />
290
+ </FieldRoot>
291
+
292
+ <Button disabled={loading} focusableWhenDisabled type='submit' className='w-full'>
293
+ {loading ? 'Creating Account...' : 'Create Account'}
294
+ </Button>
295
+ </FormRoot>
296
+ )
297
+ }
298
+
299
+ async function validateForm(
300
+ formValues: Record<string, unknown>,
301
+ ): Promise<Record<string, string>> {
302
+ // Simulate server delay
303
+ await new Promise((resolve) => setTimeout(resolve, 1000))
304
+
305
+ const errors: Record<string, string> = {}
306
+
307
+ const email = formValues.email as string
308
+ const username = formValues.username as string
309
+
310
+ // Email validation
311
+ if (email?.endsWith('@example.com')) {
312
+ errors.email = 'Example email addresses are not allowed'
313
+ }
314
+
315
+ // Username validation (simulate taken username)
316
+ if (username === 'admin' || username === 'root') {
317
+ errors.username = 'This username is already taken'
318
+ }
319
+
320
+ return errors
321
+ }
322
+ ```
323
+
324
+ ### useActionState
325
+
326
+ ```tsx
327
+ import { Button } from '@lglab/compose-ui/button'
328
+ import { CheckboxIndicator, CheckboxRoot } from '@lglab/compose-ui/checkbox'
329
+ import { CheckboxGroupRoot } from '@lglab/compose-ui/checkbox-group'
330
+ import {
331
+ FieldControl,
332
+ FieldDescription,
333
+ FieldError,
334
+ FieldItem,
335
+ FieldLabel,
336
+ FieldRoot,
337
+ FieldValidity,
338
+ } from '@lglab/compose-ui/field'
339
+ import { FieldsetLegend, FieldsetRoot } from '@lglab/compose-ui/fieldset'
340
+ import { FormRoot, type FormRootProps } from '@lglab/compose-ui/form'
341
+ import { RadioIndicator, RadioRoot } from '@lglab/compose-ui/radio'
342
+ import { RadioGroupRoot } from '@lglab/compose-ui/radio-group'
343
+ import {
344
+ SelectIcon,
345
+ SelectItem,
346
+ SelectItemIndicator,
347
+ SelectItemText,
348
+ SelectList,
349
+ SelectPopup,
350
+ SelectPortal,
351
+ SelectPositioner,
352
+ SelectRoot,
353
+ SelectScrollDownArrow,
354
+ SelectScrollUpArrow,
355
+ SelectTrigger,
356
+ SelectValue,
357
+ } from '@lglab/compose-ui/select'
358
+ import {
359
+ SliderControl,
360
+ SliderIndicator,
361
+ SliderRoot,
362
+ SliderThumb,
363
+ SliderTrack,
364
+ SliderValue,
365
+ } from '@lglab/compose-ui/slider'
366
+ import { SwitchRoot, SwitchThumb } from '@lglab/compose-ui/switch'
367
+ import { Textarea } from '@lglab/compose-ui/textarea'
368
+ import { Check, ChevronsUpDown } from 'lucide-react'
369
+ import * as React from 'react'
370
+
371
+ const countries = [
372
+ { label: 'United States', value: 'us' },
373
+ { label: 'United Kingdom', value: 'uk' },
374
+ { label: 'Canada', value: 'ca' },
375
+ { label: 'Australia', value: 'au' },
376
+ { label: 'Germany', value: 'de' },
377
+ { label: 'France', value: 'fr' },
378
+ ]
379
+
380
+ const accountTypes = [
381
+ { value: 'personal', label: 'Personal' },
382
+ { value: 'business', label: 'Business' },
383
+ { value: 'developer', label: 'Developer' },
384
+ ]
385
+
386
+ const interests = [
387
+ { value: 'technology', label: 'Technology' },
388
+ { value: 'design', label: 'Design' },
389
+ { value: 'marketing', label: 'Marketing' },
390
+ { value: 'finance', label: 'Finance' },
391
+ ]
392
+
393
+ interface FormState {
394
+ serverErrors?: FormRootProps['errors']
395
+ }
396
+
397
+ export default function ActionStateExample() {
398
+ const [state, formAction, loading] = React.useActionState<FormState, FormData>(
399
+ submitForm,
400
+ {},
401
+ )
402
+
403
+ return (
404
+ <FormRoot
405
+ action={formAction}
406
+ errors={state.serverErrors}
407
+ className='w-full max-w-md space-y-2'
408
+ >
409
+ <FieldRoot name='fullName'>
410
+ <FieldLabel>Full Name</FieldLabel>
411
+ <FieldControl required minLength={2} placeholder='John Doe' />
412
+ <FieldError />
413
+ </FieldRoot>
414
+
415
+ <FieldRoot name='email'>
416
+ <FieldLabel>Email Address</FieldLabel>
417
+ <FieldControl type='email' required placeholder='john@example.com' />
418
+ <FieldDescription>We will never share your email.</FieldDescription>
419
+ <FieldError />
420
+ </FieldRoot>
421
+
422
+ <FieldRoot name='username'>
423
+ <FieldLabel>Username</FieldLabel>
424
+ <FieldControl
425
+ required
426
+ pattern='[a-z0-9_]+'
427
+ minLength={3}
428
+ maxLength={20}
429
+ placeholder='john_doe'
430
+ />
431
+ <FieldDescription>
432
+ Lowercase letters, numbers, and underscores only.
433
+ </FieldDescription>
434
+ <FieldValidity>
435
+ {(state) => {
436
+ if (state.validity.valueMissing) {
437
+ return <FieldError>Please enter a username.</FieldError>
438
+ }
439
+ if (state.validity.tooShort) {
440
+ return <FieldError>Username must be at least 3 characters.</FieldError>
441
+ }
442
+ if (state.validity.tooLong) {
443
+ return <FieldError>Username must be at most 20 characters.</FieldError>
444
+ }
445
+ if (state.validity.patternMismatch) {
446
+ return (
447
+ <FieldError>
448
+ Only lowercase letters, numbers, and underscores are allowed.
449
+ </FieldError>
450
+ )
451
+ }
452
+ if (state.error) {
453
+ return <FieldError>{state.error}</FieldError>
454
+ }
455
+ return null
456
+ }}
457
+ </FieldValidity>
458
+ {!!state.serverErrors?.username && <FieldError />}
459
+ </FieldRoot>
460
+
461
+ <FieldRoot name='password'>
462
+ <FieldLabel>Password</FieldLabel>
463
+ <FieldControl
464
+ type='password'
465
+ required
466
+ minLength={8}
467
+ placeholder='Enter password'
468
+ />
469
+ <FieldDescription>Must be at least 8 characters.</FieldDescription>
470
+ <FieldError />
471
+ </FieldRoot>
472
+
473
+ <FieldRoot name='country'>
474
+ <FieldLabel nativeLabel={false} render={<div />}>
475
+ Country
476
+ </FieldLabel>
477
+ <SelectRoot items={countries} required>
478
+ <SelectTrigger>
479
+ <SelectValue placeholder='Select country' />
480
+ <SelectIcon>
481
+ <ChevronsUpDown className='size-4' />
482
+ </SelectIcon>
483
+ </SelectTrigger>
484
+ <SelectPortal>
485
+ <SelectPositioner>
486
+ <SelectPopup>
487
+ <SelectScrollUpArrow />
488
+ <SelectList>
489
+ {countries.map(({ label, value }) => (
490
+ <SelectItem key={value} value={value}>
491
+ <SelectItemText>{label}</SelectItemText>
492
+ <SelectItemIndicator>
493
+ <Check className='size-3.5' />
494
+ </SelectItemIndicator>
495
+ </SelectItem>
496
+ ))}
497
+ </SelectList>
498
+ <SelectScrollDownArrow />
499
+ </SelectPopup>
500
+ </SelectPositioner>
501
+ </SelectPortal>
502
+ </SelectRoot>
503
+ <FieldError />
504
+ </FieldRoot>
505
+
506
+ <FieldRoot name='bio'>
507
+ <FieldLabel>Bio</FieldLabel>
508
+ <FieldControl
509
+ render={<Textarea />}
510
+ placeholder='Tell us about yourself...'
511
+ maxLength={500}
512
+ />
513
+ <FieldDescription>Optional. Max 500 characters.</FieldDescription>
514
+ <FieldError />
515
+ </FieldRoot>
516
+
517
+ <FieldRoot name='accountType'>
518
+ <FieldsetRoot
519
+ render={<RadioGroupRoot name='accountType' defaultValue='personal' />}
520
+ >
521
+ <FieldsetLegend>Account Type</FieldsetLegend>
522
+ {accountTypes.map((type) => (
523
+ <FieldItem key={type.value}>
524
+ <FieldLabel>
525
+ <RadioRoot value={type.value}>
526
+ <RadioIndicator />
527
+ </RadioRoot>
528
+ {type.label}
529
+ </FieldLabel>
530
+ </FieldItem>
531
+ ))}
532
+ </FieldsetRoot>
533
+ <FieldError />
534
+ </FieldRoot>
535
+
536
+ <FieldRoot name='interests'>
537
+ <FieldsetRoot render={<CheckboxGroupRoot defaultValue={[]} />}>
538
+ <FieldsetLegend>Interests</FieldsetLegend>
539
+ {interests.map((interest) => (
540
+ <FieldItem key={interest.value}>
541
+ <FieldLabel>
542
+ <CheckboxRoot value={interest.value}>
543
+ <CheckboxIndicator>
544
+ <Check className='size-3.5' />
545
+ </CheckboxIndicator>
546
+ </CheckboxRoot>
547
+ {interest.label}
548
+ </FieldLabel>
549
+ </FieldItem>
550
+ ))}
551
+ </FieldsetRoot>
552
+ <FieldDescription>Select at least one interest.</FieldDescription>
553
+ <FieldError />
554
+ </FieldRoot>
555
+
556
+ <FieldRoot name='experience'>
557
+ <FieldsetRoot render={<SliderRoot defaultValue={50} thumbAlignment='edge' />}>
558
+ <div className='flex items-center justify-between text-sm'>
559
+ <FieldsetLegend className='flex-1'>Experience Level</FieldsetLegend>
560
+ <SliderValue className='tabular-nums' />
561
+ </div>
562
+ <SliderControl>
563
+ <SliderTrack>
564
+ <SliderIndicator />
565
+ <SliderThumb aria-label='Experience level' />
566
+ </SliderTrack>
567
+ </SliderControl>
568
+ </FieldsetRoot>
569
+ <FieldDescription>0 = Beginner, 100 = Expert</FieldDescription>
570
+ </FieldRoot>
571
+
572
+ <FieldRoot name='newsletter'>
573
+ <FieldItem>
574
+ <FieldLabel className='flex items-center gap-3'>
575
+ <SwitchRoot name='newsletter' defaultChecked>
576
+ <SwitchThumb />
577
+ </SwitchRoot>
578
+ Subscribe to newsletter
579
+ </FieldLabel>
580
+ </FieldItem>
581
+ <FieldDescription>Receive updates and promotions via email.</FieldDescription>
582
+ </FieldRoot>
583
+
584
+ <FieldRoot name='terms'>
585
+ <FieldItem>
586
+ <FieldLabel>
587
+ <CheckboxRoot name='terms' required>
588
+ <CheckboxIndicator>
589
+ <Check className='size-3.5' />
590
+ </CheckboxIndicator>
591
+ </CheckboxRoot>
592
+ I agree to the Terms of Service and Privacy Policy
593
+ </FieldLabel>
594
+ </FieldItem>
595
+ <FieldError />
596
+ </FieldRoot>
597
+
598
+ <Button disabled={loading} focusableWhenDisabled type='submit' className='w-full'>
599
+ {loading ? 'Creating Account...' : 'Create Account'}
600
+ </Button>
601
+ </FormRoot>
602
+ )
603
+ }
604
+
605
+ async function submitForm(_previousState: FormState, formData: FormData) {
606
+ await new Promise((resolve) => {
607
+ setTimeout(resolve, 1000)
608
+ })
609
+
610
+ const email = formData.get('email') as string | null
611
+ const username = formData.get('username') as string | null
612
+ const serverErrors: Record<string, string> = {}
613
+
614
+ if (email?.endsWith('@example.com')) {
615
+ serverErrors.email = 'Example email addresses are not allowed'
616
+ }
617
+
618
+ if (username === 'admin' || username === 'root') {
619
+ serverErrors.username = 'This username is already taken'
620
+ }
621
+
622
+ if (Object.keys(serverErrors).length > 0) {
623
+ return { serverErrors }
624
+ }
625
+
626
+ return {}
627
+ }
628
+ ```
629
+
630
+ ### Zod Validation
631
+
632
+ ```tsx
633
+ import { Button } from '@lglab/compose-ui/button'
634
+ import { CheckboxIndicator, CheckboxRoot } from '@lglab/compose-ui/checkbox'
635
+ import { CheckboxGroupRoot } from '@lglab/compose-ui/checkbox-group'
636
+ import {
637
+ FieldControl,
638
+ FieldDescription,
639
+ FieldError,
640
+ FieldItem,
641
+ FieldLabel,
642
+ FieldRoot,
643
+ FieldValidity,
644
+ } from '@lglab/compose-ui/field'
645
+ import { FieldsetLegend, FieldsetRoot } from '@lglab/compose-ui/fieldset'
646
+ import { FormRoot, type FormRootProps } from '@lglab/compose-ui/form'
647
+ import { RadioIndicator, RadioRoot } from '@lglab/compose-ui/radio'
648
+ import { RadioGroupRoot } from '@lglab/compose-ui/radio-group'
649
+ import {
650
+ SelectIcon,
651
+ SelectItem,
652
+ SelectItemIndicator,
653
+ SelectItemText,
654
+ SelectList,
655
+ SelectPopup,
656
+ SelectPortal,
657
+ SelectPositioner,
658
+ SelectRoot,
659
+ SelectScrollDownArrow,
660
+ SelectScrollUpArrow,
661
+ SelectTrigger,
662
+ SelectValue,
663
+ } from '@lglab/compose-ui/select'
664
+ import {
665
+ SliderControl,
666
+ SliderIndicator,
667
+ SliderRoot,
668
+ SliderThumb,
669
+ SliderTrack,
670
+ SliderValue,
671
+ } from '@lglab/compose-ui/slider'
672
+ import { SwitchRoot, SwitchThumb } from '@lglab/compose-ui/switch'
673
+ import { Textarea } from '@lglab/compose-ui/textarea'
674
+ import { Check, ChevronsUpDown } from 'lucide-react'
675
+ import * as React from 'react'
676
+ import { z } from 'zod'
677
+
678
+ const countries = [
679
+ { label: 'United States', value: 'us' },
680
+ { label: 'United Kingdom', value: 'uk' },
681
+ { label: 'Canada', value: 'ca' },
682
+ { label: 'Australia', value: 'au' },
683
+ { label: 'Germany', value: 'de' },
684
+ { label: 'France', value: 'fr' },
685
+ ]
686
+
687
+ const accountTypes = [
688
+ { value: 'personal', label: 'Personal' },
689
+ { value: 'business', label: 'Business' },
690
+ { value: 'developer', label: 'Developer' },
691
+ ]
692
+
693
+ const interests = [
694
+ { value: 'technology', label: 'Technology' },
695
+ { value: 'design', label: 'Design' },
696
+ { value: 'marketing', label: 'Marketing' },
697
+ { value: 'finance', label: 'Finance' },
698
+ ]
699
+
700
+ const schema = z.object({
701
+ fullName: z.string().min(2, 'Full name must be at least 2 characters'),
702
+ email: z
703
+ .string()
704
+ .email('Please enter a valid email address')
705
+ .refine((email) => !email.endsWith('@example.com'), {
706
+ message: 'Example email addresses are not allowed',
707
+ }),
708
+ username: z
709
+ .string()
710
+ .min(3, 'Username must be at least 3 characters')
711
+ .max(20, 'Username must be at most 20 characters')
712
+ .regex(/^[a-z0-9_]+$/, 'Only lowercase letters, numbers, and underscores are allowed')
713
+ .refine((username) => username !== 'admin' && username !== 'root', {
714
+ message: 'This username is already taken',
715
+ }),
716
+ password: z.string().min(8, 'Password must be at least 8 characters'),
717
+ country: z.string().min(1, 'Please select a country'),
718
+ bio: z.string().max(500, 'Bio must be at most 500 characters').optional(),
719
+ accountType: z.enum(['personal', 'business', 'developer']),
720
+ interests: z.array(z.string()).min(1, 'Please select at least one interest'),
721
+ experience: z.number().min(0).max(100),
722
+ newsletter: z.boolean(),
723
+ terms: z.literal(true, 'You must agree to the terms'),
724
+ })
725
+
726
+ async function submitForm(formValues: Record<string, unknown>) {
727
+ await new Promise((resolve) => setTimeout(resolve, 1000))
728
+
729
+ const result = schema.safeParse(formValues)
730
+
731
+ if (!result.success) {
732
+ return {
733
+ errors: z.flattenError(result.error).fieldErrors,
734
+ }
735
+ }
736
+
737
+ return {
738
+ errors: {},
739
+ }
740
+ }
741
+
742
+ export default function WithZodExample() {
743
+ const [errors, setErrors] = React.useState<FormRootProps['errors']>({})
744
+ const [loading, setLoading] = React.useState(false)
745
+
746
+ return (
747
+ <FormRoot
748
+ className='w-full max-w-md space-y-2'
749
+ errors={errors}
750
+ onFormSubmit={async (formValues) => {
751
+ setLoading(true)
752
+ const response = await submitForm(formValues)
753
+ setErrors(response.errors)
754
+ setLoading(false)
755
+ }}
756
+ >
757
+ <FieldRoot name='fullName'>
758
+ <FieldLabel>Full Name</FieldLabel>
759
+ <FieldControl required minLength={2} placeholder='John Doe' />
760
+ <FieldError />
761
+ </FieldRoot>
762
+
763
+ <FieldRoot name='email'>
764
+ <FieldLabel>Email Address</FieldLabel>
765
+ <FieldControl type='email' required placeholder='john@example.com' />
766
+ <FieldDescription>We will never share your email.</FieldDescription>
767
+ <FieldError />
768
+ </FieldRoot>
769
+
770
+ <FieldRoot name='username'>
771
+ <FieldLabel>Username</FieldLabel>
772
+ <FieldControl
773
+ required
774
+ pattern='[a-z0-9_]+'
775
+ minLength={3}
776
+ maxLength={20}
777
+ placeholder='john_doe'
778
+ />
779
+ <FieldDescription>
780
+ Lowercase letters, numbers, and underscores only.
781
+ </FieldDescription>
782
+ <FieldValidity>
783
+ {(state) => {
784
+ if (state.validity.valueMissing) {
785
+ return <FieldError>Please enter a username.</FieldError>
786
+ }
787
+ if (state.validity.tooShort) {
788
+ return <FieldError>Username must be at least 3 characters.</FieldError>
789
+ }
790
+ if (state.validity.tooLong) {
791
+ return <FieldError>Username must be at most 20 characters.</FieldError>
792
+ }
793
+ if (state.validity.patternMismatch) {
794
+ return (
795
+ <FieldError>
796
+ Only lowercase letters, numbers, and underscores are allowed.
797
+ </FieldError>
798
+ )
799
+ }
800
+ if (state.error) {
801
+ return <FieldError>{state.error}</FieldError>
802
+ }
803
+ return null
804
+ }}
805
+ </FieldValidity>
806
+ {!!errors?.username && <FieldError />}
807
+ </FieldRoot>
808
+
809
+ <FieldRoot name='password'>
810
+ <FieldLabel>Password</FieldLabel>
811
+ <FieldControl
812
+ type='password'
813
+ required
814
+ minLength={8}
815
+ placeholder='Enter password'
816
+ />
817
+ <FieldDescription>Must be at least 8 characters.</FieldDescription>
818
+ <FieldError />
819
+ </FieldRoot>
820
+
821
+ <FieldRoot name='country'>
822
+ <FieldLabel nativeLabel={false} render={<div />}>
823
+ Country
824
+ </FieldLabel>
825
+ <SelectRoot items={countries} required>
826
+ <SelectTrigger>
827
+ <SelectValue placeholder='Select country' />
828
+ <SelectIcon>
829
+ <ChevronsUpDown className='size-4' />
830
+ </SelectIcon>
831
+ </SelectTrigger>
832
+ <SelectPortal>
833
+ <SelectPositioner>
834
+ <SelectPopup>
835
+ <SelectScrollUpArrow />
836
+ <SelectList>
837
+ {countries.map(({ label, value }) => (
838
+ <SelectItem key={value} value={value}>
839
+ <SelectItemText>{label}</SelectItemText>
840
+ <SelectItemIndicator>
841
+ <Check className='size-3.5' />
842
+ </SelectItemIndicator>
843
+ </SelectItem>
844
+ ))}
845
+ </SelectList>
846
+ <SelectScrollDownArrow />
847
+ </SelectPopup>
848
+ </SelectPositioner>
849
+ </SelectPortal>
850
+ </SelectRoot>
851
+ <FieldError />
852
+ </FieldRoot>
853
+
854
+ <FieldRoot name='bio'>
855
+ <FieldLabel>Bio</FieldLabel>
856
+ <FieldControl
857
+ render={<Textarea />}
858
+ placeholder='Tell us about yourself...'
859
+ maxLength={500}
860
+ />
861
+ <FieldDescription>Optional. Max 500 characters.</FieldDescription>
862
+ <FieldError />
863
+ </FieldRoot>
864
+
865
+ <FieldRoot name='accountType'>
866
+ <FieldsetRoot
867
+ render={<RadioGroupRoot name='accountType' defaultValue='personal' />}
868
+ >
869
+ <FieldsetLegend>Account Type</FieldsetLegend>
870
+ {accountTypes.map((type) => (
871
+ <FieldItem key={type.value}>
872
+ <FieldLabel>
873
+ <RadioRoot value={type.value}>
874
+ <RadioIndicator />
875
+ </RadioRoot>
876
+ {type.label}
877
+ </FieldLabel>
878
+ </FieldItem>
879
+ ))}
880
+ </FieldsetRoot>
881
+ <FieldError />
882
+ </FieldRoot>
883
+
884
+ <FieldRoot name='interests'>
885
+ <FieldsetRoot render={<CheckboxGroupRoot defaultValue={[]} />}>
886
+ <FieldsetLegend>Interests</FieldsetLegend>
887
+ {interests.map((interest) => (
888
+ <FieldItem key={interest.value}>
889
+ <FieldLabel>
890
+ <CheckboxRoot value={interest.value}>
891
+ <CheckboxIndicator>
892
+ <Check className='size-3.5' />
893
+ </CheckboxIndicator>
894
+ </CheckboxRoot>
895
+ {interest.label}
896
+ </FieldLabel>
897
+ </FieldItem>
898
+ ))}
899
+ </FieldsetRoot>
900
+ <FieldDescription>Select at least one interest.</FieldDescription>
901
+ <FieldError />
902
+ </FieldRoot>
903
+
904
+ <FieldRoot name='experience'>
905
+ <FieldsetRoot render={<SliderRoot defaultValue={50} thumbAlignment='edge' />}>
906
+ <div className='flex items-center justify-between text-sm'>
907
+ <FieldsetLegend className='flex-1'>Experience Level</FieldsetLegend>
908
+ <SliderValue className='tabular-nums' />
909
+ </div>
910
+ <SliderControl>
911
+ <SliderTrack>
912
+ <SliderIndicator />
913
+ <SliderThumb aria-label='Experience level' />
914
+ </SliderTrack>
915
+ </SliderControl>
916
+ </FieldsetRoot>
917
+ <FieldDescription>0 = Beginner, 100 = Expert</FieldDescription>
918
+ </FieldRoot>
919
+
920
+ <FieldRoot name='newsletter'>
921
+ <FieldItem>
922
+ <FieldLabel className='flex items-center gap-3'>
923
+ <SwitchRoot name='newsletter' defaultChecked>
924
+ <SwitchThumb />
925
+ </SwitchRoot>
926
+ Subscribe to newsletter
927
+ </FieldLabel>
928
+ </FieldItem>
929
+ <FieldDescription>Receive updates and promotions via email.</FieldDescription>
930
+ </FieldRoot>
931
+
932
+ <FieldRoot name='terms'>
933
+ <FieldItem>
934
+ <FieldLabel>
935
+ <CheckboxRoot name='terms' required>
936
+ <CheckboxIndicator>
937
+ <Check className='size-3.5' />
938
+ </CheckboxIndicator>
939
+ </CheckboxRoot>
940
+ I agree to the Terms of Service and Privacy Policy
941
+ </FieldLabel>
942
+ </FieldItem>
943
+ <FieldError />
944
+ </FieldRoot>
945
+
946
+ <Button disabled={loading} focusableWhenDisabled type='submit' className='w-full'>
947
+ {loading ? 'Creating Account...' : 'Create Account'}
948
+ </Button>
949
+ </FormRoot>
950
+ )
951
+ }
952
+ ```
953
+
954
+ ### React Hook Form
955
+
956
+ ```tsx
957
+ import { zodResolver } from '@hookform/resolvers/zod'
958
+ import { Button } from '@lglab/compose-ui/button'
959
+ import { CheckboxIndicator, CheckboxRoot } from '@lglab/compose-ui/checkbox'
960
+ import { CheckboxGroupRoot } from '@lglab/compose-ui/checkbox-group'
961
+ import {
962
+ FieldControl,
963
+ FieldDescription,
964
+ FieldError,
965
+ FieldItem,
966
+ FieldLabel,
967
+ FieldRoot,
968
+ } from '@lglab/compose-ui/field'
969
+ import { FieldsetLegend, FieldsetRoot } from '@lglab/compose-ui/fieldset'
970
+ import { FormRoot } from '@lglab/compose-ui/form'
971
+ import { RadioIndicator, RadioRoot } from '@lglab/compose-ui/radio'
972
+ import { RadioGroupRoot } from '@lglab/compose-ui/radio-group'
973
+ import {
974
+ SelectIcon,
975
+ SelectItem,
976
+ SelectItemIndicator,
977
+ SelectItemText,
978
+ SelectList,
979
+ SelectPopup,
980
+ SelectPortal,
981
+ SelectPositioner,
982
+ SelectRoot,
983
+ SelectScrollDownArrow,
984
+ SelectScrollUpArrow,
985
+ SelectTrigger,
986
+ SelectValue,
987
+ } from '@lglab/compose-ui/select'
988
+ import {
989
+ SliderControl,
990
+ SliderIndicator,
991
+ SliderRoot,
992
+ SliderThumb,
993
+ SliderTrack,
994
+ SliderValue,
995
+ } from '@lglab/compose-ui/slider'
996
+ import { SwitchRoot, SwitchThumb } from '@lglab/compose-ui/switch'
997
+ import { Textarea } from '@lglab/compose-ui/textarea'
998
+ import { Check, ChevronsUpDown } from 'lucide-react'
999
+ import { Controller, useForm } from 'react-hook-form'
1000
+ import { z } from 'zod'
1001
+
1002
+ const countries = [
1003
+ { label: 'United States', value: 'us' },
1004
+ { label: 'United Kingdom', value: 'uk' },
1005
+ { label: 'Canada', value: 'ca' },
1006
+ { label: 'Australia', value: 'au' },
1007
+ { label: 'Germany', value: 'de' },
1008
+ { label: 'France', value: 'fr' },
1009
+ ]
1010
+
1011
+ const accountTypes = [
1012
+ { value: 'personal', label: 'Personal' },
1013
+ { value: 'business', label: 'Business' },
1014
+ { value: 'developer', label: 'Developer' },
1015
+ ]
1016
+
1017
+ const interests = [
1018
+ { value: 'technology', label: 'Technology' },
1019
+ { value: 'design', label: 'Design' },
1020
+ { value: 'marketing', label: 'Marketing' },
1021
+ { value: 'finance', label: 'Finance' },
1022
+ ]
1023
+
1024
+ const schema = z.object({
1025
+ fullName: z.string().min(2, 'Full name must be at least 2 characters'),
1026
+ email: z
1027
+ .email({ error: 'Please enter a valid email address' })
1028
+ .refine((email) => !email.endsWith('@example.com'), {
1029
+ message: 'Example email addresses are not allowed',
1030
+ }),
1031
+ username: z
1032
+ .string()
1033
+ .min(3, 'Username must be at least 3 characters')
1034
+ .max(20, 'Username must be at most 20 characters')
1035
+ .regex(/^[a-z0-9_]+$/, 'Only lowercase letters, numbers, and underscores are allowed')
1036
+ .refine((username) => username !== 'admin' && username !== 'root', {
1037
+ message: 'This username is already taken',
1038
+ }),
1039
+ password: z.string().min(8, 'Password must be at least 8 characters'),
1040
+ country: z.string().min(1, 'Please select a country'),
1041
+ bio: z.string().max(500, 'Bio must be at most 500 characters').optional(),
1042
+ accountType: z.enum(['personal', 'business', 'developer']),
1043
+ interests: z.array(z.string()).min(1, 'Please select at least one interest'),
1044
+ experience: z.number().min(0).max(100),
1045
+ newsletter: z.boolean(),
1046
+ terms: z.literal(true, { message: 'You must agree to the terms' }),
1047
+ })
1048
+
1049
+ type FormValues = z.infer<typeof schema>
1050
+
1051
+ export default function WithReactHookFormExample() {
1052
+ const { control, handleSubmit, formState, setError } = useForm<FormValues>({
1053
+ resolver: zodResolver(schema),
1054
+ defaultValues: {
1055
+ fullName: '',
1056
+ email: '',
1057
+ username: '',
1058
+ password: '',
1059
+ country: '',
1060
+ bio: '',
1061
+ accountType: 'personal',
1062
+ interests: [],
1063
+ experience: 50,
1064
+ newsletter: true,
1065
+ terms: undefined,
1066
+ },
1067
+ })
1068
+
1069
+ const onSubmit = async (data: FormValues) => {
1070
+ await new Promise((resolve) => setTimeout(resolve, 1000))
1071
+
1072
+ // Simulate server error for specific username
1073
+ if (data.username === 'taken_user') {
1074
+ return setError('username', {
1075
+ type: 'server',
1076
+ message: 'This username is already registered',
1077
+ })
1078
+ }
1079
+
1080
+ // Simulate general server error for specific email
1081
+ if (data.email === 'error@test.com') {
1082
+ return setError('root.serverError', {
1083
+ type: 'server',
1084
+ message: 'Unable to create account. Please try again later.',
1085
+ })
1086
+ }
1087
+ }
1088
+
1089
+ return (
1090
+ <FormRoot
1091
+ aria-label='Create account'
1092
+ onSubmit={handleSubmit(onSubmit)}
1093
+ className='w-full max-w-md space-y-2'
1094
+ >
1095
+ <Controller
1096
+ name='fullName'
1097
+ control={control}
1098
+ render={({
1099
+ field: { ref, name, value, onBlur, onChange },
1100
+ fieldState: { invalid, isTouched, isDirty, error },
1101
+ }) => (
1102
+ <FieldRoot name={name} invalid={invalid} touched={isTouched} dirty={isDirty}>
1103
+ <FieldLabel>Full Name</FieldLabel>
1104
+ <FieldControl
1105
+ ref={ref}
1106
+ value={value}
1107
+ onBlur={onBlur}
1108
+ onValueChange={onChange}
1109
+ placeholder='John Doe'
1110
+ />
1111
+ <FieldError match={!!error}>{error?.message}</FieldError>
1112
+ </FieldRoot>
1113
+ )}
1114
+ />
1115
+
1116
+ <Controller
1117
+ name='email'
1118
+ control={control}
1119
+ render={({
1120
+ field: { ref, name, value, onBlur, onChange },
1121
+ fieldState: { invalid, isTouched, isDirty, error },
1122
+ }) => (
1123
+ <FieldRoot name={name} invalid={invalid} touched={isTouched} dirty={isDirty}>
1124
+ <FieldLabel>Email Address</FieldLabel>
1125
+ <FieldControl
1126
+ ref={ref}
1127
+ type='email'
1128
+ value={value}
1129
+ onBlur={onBlur}
1130
+ onValueChange={onChange}
1131
+ placeholder='john@example.com'
1132
+ />
1133
+ <FieldDescription>We will never share your email.</FieldDescription>
1134
+ <FieldError match={!!error}>{error?.message}</FieldError>
1135
+ </FieldRoot>
1136
+ )}
1137
+ />
1138
+
1139
+ <Controller
1140
+ name='username'
1141
+ control={control}
1142
+ render={({
1143
+ field: { ref, name, value, onBlur, onChange },
1144
+ fieldState: { invalid, isTouched, isDirty, error },
1145
+ }) => (
1146
+ <FieldRoot name={name} invalid={invalid} touched={isTouched} dirty={isDirty}>
1147
+ <FieldLabel>Username</FieldLabel>
1148
+ <FieldControl
1149
+ ref={ref}
1150
+ value={value}
1151
+ onBlur={onBlur}
1152
+ onValueChange={onChange}
1153
+ placeholder='john_doe'
1154
+ />
1155
+ <FieldDescription>
1156
+ Lowercase letters, numbers, and underscores only.
1157
+ </FieldDescription>
1158
+ <FieldError match={!!error}>{error?.message}</FieldError>
1159
+ </FieldRoot>
1160
+ )}
1161
+ />
1162
+
1163
+ <Controller
1164
+ name='password'
1165
+ control={control}
1166
+ render={({
1167
+ field: { ref, name, value, onBlur, onChange },
1168
+ fieldState: { invalid, isTouched, isDirty, error },
1169
+ }) => (
1170
+ <FieldRoot name={name} invalid={invalid} touched={isTouched} dirty={isDirty}>
1171
+ <FieldLabel>Password</FieldLabel>
1172
+ <FieldControl
1173
+ ref={ref}
1174
+ type='password'
1175
+ value={value}
1176
+ onBlur={onBlur}
1177
+ onValueChange={onChange}
1178
+ placeholder='Enter password'
1179
+ />
1180
+ <FieldDescription>Must be at least 8 characters.</FieldDescription>
1181
+ <FieldError match={!!error}>{error?.message}</FieldError>
1182
+ </FieldRoot>
1183
+ )}
1184
+ />
1185
+
1186
+ <Controller
1187
+ name='country'
1188
+ control={control}
1189
+ render={({
1190
+ field: { ref, name, value, onBlur, onChange },
1191
+ fieldState: { invalid, isTouched, isDirty, error },
1192
+ }) => (
1193
+ <FieldRoot name={name} invalid={invalid} touched={isTouched} dirty={isDirty}>
1194
+ <FieldLabel nativeLabel={false} render={<div />}>
1195
+ Country
1196
+ </FieldLabel>
1197
+ <SelectRoot
1198
+ items={countries}
1199
+ value={value}
1200
+ onValueChange={onChange}
1201
+ inputRef={ref}
1202
+ >
1203
+ <SelectTrigger onBlur={onBlur}>
1204
+ <SelectValue placeholder='Select country' />
1205
+ <SelectIcon>
1206
+ <ChevronsUpDown className='size-4' />
1207
+ </SelectIcon>
1208
+ </SelectTrigger>
1209
+ <SelectPortal>
1210
+ <SelectPositioner>
1211
+ <SelectPopup>
1212
+ <SelectScrollUpArrow />
1213
+ <SelectList>
1214
+ {countries.map(({ label, value: countryValue }) => (
1215
+ <SelectItem key={countryValue} value={countryValue}>
1216
+ <SelectItemText>{label}</SelectItemText>
1217
+ <SelectItemIndicator>
1218
+ <Check className='size-3.5' />
1219
+ </SelectItemIndicator>
1220
+ </SelectItem>
1221
+ ))}
1222
+ </SelectList>
1223
+ <SelectScrollDownArrow />
1224
+ </SelectPopup>
1225
+ </SelectPositioner>
1226
+ </SelectPortal>
1227
+ </SelectRoot>
1228
+ <FieldError match={!!error}>{error?.message}</FieldError>
1229
+ </FieldRoot>
1230
+ )}
1231
+ />
1232
+
1233
+ <Controller
1234
+ name='bio'
1235
+ control={control}
1236
+ render={({
1237
+ field: { ref, name, value, onBlur, onChange },
1238
+ fieldState: { invalid, isTouched, isDirty, error },
1239
+ }) => (
1240
+ <FieldRoot name={name} invalid={invalid} touched={isTouched} dirty={isDirty}>
1241
+ <FieldLabel>Bio</FieldLabel>
1242
+ <FieldControl
1243
+ ref={ref}
1244
+ render={<Textarea />}
1245
+ value={value}
1246
+ onBlur={onBlur}
1247
+ onValueChange={onChange}
1248
+ placeholder='Tell us about yourself...'
1249
+ />
1250
+ <FieldDescription>Optional. Max 500 characters.</FieldDescription>
1251
+ <FieldError match={!!error}>{error?.message}</FieldError>
1252
+ </FieldRoot>
1253
+ )}
1254
+ />
1255
+
1256
+ <Controller
1257
+ name='accountType'
1258
+ control={control}
1259
+ render={({
1260
+ field: { ref, name, value, onBlur, onChange },
1261
+ fieldState: { invalid, isTouched, isDirty, error },
1262
+ }) => (
1263
+ <FieldRoot name={name} invalid={invalid} touched={isTouched} dirty={isDirty}>
1264
+ <FieldsetRoot
1265
+ render={
1266
+ <RadioGroupRoot
1267
+ name='accountType'
1268
+ value={value}
1269
+ onValueChange={onChange}
1270
+ inputRef={ref}
1271
+ />
1272
+ }
1273
+ >
1274
+ <FieldsetLegend>Account Type</FieldsetLegend>
1275
+ {accountTypes.map((type) => (
1276
+ <FieldItem key={type.value}>
1277
+ <FieldLabel>
1278
+ <RadioRoot value={type.value} onBlur={onBlur}>
1279
+ <RadioIndicator />
1280
+ </RadioRoot>
1281
+ {type.label}
1282
+ </FieldLabel>
1283
+ </FieldItem>
1284
+ ))}
1285
+ </FieldsetRoot>
1286
+ <FieldError match={!!error}>{error?.message}</FieldError>
1287
+ </FieldRoot>
1288
+ )}
1289
+ />
1290
+
1291
+ <Controller
1292
+ name='interests'
1293
+ control={control}
1294
+ render={({
1295
+ field: { ref, name, value, onBlur, onChange },
1296
+ fieldState: { invalid, isTouched, isDirty, error },
1297
+ }) => (
1298
+ <FieldRoot name={name} invalid={invalid} touched={isTouched} dirty={isDirty}>
1299
+ <FieldsetRoot
1300
+ render={<CheckboxGroupRoot value={value} onValueChange={onChange} />}
1301
+ >
1302
+ <FieldsetLegend>Interests</FieldsetLegend>
1303
+ {interests.map((interest, index) => (
1304
+ <FieldItem key={interest.value}>
1305
+ <FieldLabel>
1306
+ <CheckboxRoot
1307
+ value={interest.value}
1308
+ inputRef={index === 0 ? ref : undefined}
1309
+ onBlur={onBlur}
1310
+ >
1311
+ <CheckboxIndicator>
1312
+ <Check className='size-3.5' />
1313
+ </CheckboxIndicator>
1314
+ </CheckboxRoot>
1315
+ {interest.label}
1316
+ </FieldLabel>
1317
+ </FieldItem>
1318
+ ))}
1319
+ </FieldsetRoot>
1320
+ <FieldDescription>Select at least one interest.</FieldDescription>
1321
+ <FieldError match={!!error}>{error?.message}</FieldError>
1322
+ </FieldRoot>
1323
+ )}
1324
+ />
1325
+
1326
+ <Controller
1327
+ name='experience'
1328
+ control={control}
1329
+ render={({
1330
+ field: { ref, name, value, onBlur, onChange },
1331
+ fieldState: { invalid, isTouched, isDirty },
1332
+ }) => (
1333
+ <FieldRoot name={name} invalid={invalid} touched={isTouched} dirty={isDirty}>
1334
+ <FieldsetRoot
1335
+ render={
1336
+ <SliderRoot
1337
+ value={value}
1338
+ onValueChange={onChange}
1339
+ onValueCommitted={onChange}
1340
+ thumbAlignment='edge'
1341
+ />
1342
+ }
1343
+ >
1344
+ <div className='flex items-center justify-between text-sm'>
1345
+ <FieldsetLegend className='flex-1'>Experience Level</FieldsetLegend>
1346
+ <SliderValue className='tabular-nums' />
1347
+ </div>
1348
+ <SliderControl>
1349
+ <SliderTrack>
1350
+ <SliderIndicator />
1351
+ <SliderThumb
1352
+ aria-label='Experience level'
1353
+ onBlur={onBlur}
1354
+ inputRef={ref}
1355
+ />
1356
+ </SliderTrack>
1357
+ </SliderControl>
1358
+ </FieldsetRoot>
1359
+ <FieldDescription>0 = Beginner, 100 = Expert</FieldDescription>
1360
+ </FieldRoot>
1361
+ )}
1362
+ />
1363
+
1364
+ <Controller
1365
+ name='newsletter'
1366
+ control={control}
1367
+ render={({
1368
+ field: { ref, name, value, onBlur, onChange },
1369
+ fieldState: { invalid, isTouched, isDirty },
1370
+ }) => (
1371
+ <FieldRoot name={name} invalid={invalid} touched={isTouched} dirty={isDirty}>
1372
+ <FieldItem>
1373
+ <FieldLabel className='flex items-center gap-3'>
1374
+ <SwitchRoot
1375
+ checked={value}
1376
+ inputRef={ref}
1377
+ onCheckedChange={onChange}
1378
+ onBlur={onBlur}
1379
+ >
1380
+ <SwitchThumb />
1381
+ </SwitchRoot>
1382
+ Subscribe to newsletter
1383
+ </FieldLabel>
1384
+ </FieldItem>
1385
+ <FieldDescription>Receive updates and promotions via email.</FieldDescription>
1386
+ </FieldRoot>
1387
+ )}
1388
+ />
1389
+
1390
+ <Controller
1391
+ name='terms'
1392
+ control={control}
1393
+ render={({
1394
+ field: { ref, name, value, onBlur, onChange },
1395
+ fieldState: { invalid, isTouched, isDirty, error },
1396
+ }) => (
1397
+ <FieldRoot name={name} invalid={invalid} touched={isTouched} dirty={isDirty}>
1398
+ <FieldItem>
1399
+ <FieldLabel>
1400
+ <CheckboxRoot
1401
+ checked={value ?? false}
1402
+ inputRef={ref}
1403
+ onCheckedChange={onChange}
1404
+ onBlur={onBlur}
1405
+ >
1406
+ <CheckboxIndicator>
1407
+ <Check className='size-3.5' />
1408
+ </CheckboxIndicator>
1409
+ </CheckboxRoot>
1410
+ I agree to the Terms of Service and Privacy Policy
1411
+ </FieldLabel>
1412
+ </FieldItem>
1413
+ <FieldError match={!!error}>{error?.message}</FieldError>
1414
+ </FieldRoot>
1415
+ )}
1416
+ />
1417
+
1418
+ {formState.errors.root?.serverError && (
1419
+ <p className='text-sm text-destructive' role='alert'>
1420
+ {formState.errors.root.serverError.message}
1421
+ </p>
1422
+ )}
1423
+
1424
+ <Button
1425
+ disabled={formState.isSubmitting}
1426
+ focusableWhenDisabled
1427
+ type='submit'
1428
+ className='w-full'
1429
+ >
1430
+ {formState.isSubmitting ? 'Creating Account...' : 'Create Account'}
1431
+ </Button>
1432
+ </FormRoot>
1433
+ )
1434
+ }
1435
+ ```
1436
+
1437
+ ### TanStack Form
1438
+
1439
+ ```tsx
1440
+ import { Button } from '@lglab/compose-ui/button'
1441
+ import { CheckboxIndicator, CheckboxRoot } from '@lglab/compose-ui/checkbox'
1442
+ import { CheckboxGroupRoot } from '@lglab/compose-ui/checkbox-group'
1443
+ import {
1444
+ FieldControl,
1445
+ FieldDescription,
1446
+ FieldError,
1447
+ FieldItem,
1448
+ FieldLabel,
1449
+ FieldRoot,
1450
+ } from '@lglab/compose-ui/field'
1451
+ import { FieldsetLegend, FieldsetRoot } from '@lglab/compose-ui/fieldset'
1452
+ import { FormRoot } from '@lglab/compose-ui/form'
1453
+ import { RadioIndicator, RadioRoot } from '@lglab/compose-ui/radio'
1454
+ import { RadioGroupRoot } from '@lglab/compose-ui/radio-group'
1455
+ import {
1456
+ SelectIcon,
1457
+ SelectItem,
1458
+ SelectItemIndicator,
1459
+ SelectItemText,
1460
+ SelectList,
1461
+ SelectPopup,
1462
+ SelectPortal,
1463
+ SelectPositioner,
1464
+ SelectRoot,
1465
+ SelectScrollDownArrow,
1466
+ SelectScrollUpArrow,
1467
+ SelectTrigger,
1468
+ SelectValue,
1469
+ } from '@lglab/compose-ui/select'
1470
+ import {
1471
+ SliderControl,
1472
+ SliderIndicator,
1473
+ SliderRoot,
1474
+ SliderThumb,
1475
+ SliderTrack,
1476
+ SliderValue,
1477
+ } from '@lglab/compose-ui/slider'
1478
+ import { SwitchRoot, SwitchThumb } from '@lglab/compose-ui/switch'
1479
+ import { Textarea } from '@lglab/compose-ui/textarea'
1480
+ import { DeepKeys, ValidationError, useForm } from '@tanstack/react-form'
1481
+ import { Check, ChevronsUpDown } from 'lucide-react'
1482
+
1483
+ const countries = [
1484
+ { label: 'United States', value: 'us' },
1485
+ { label: 'United Kingdom', value: 'uk' },
1486
+ { label: 'Canada', value: 'ca' },
1487
+ { label: 'Australia', value: 'au' },
1488
+ { label: 'Germany', value: 'de' },
1489
+ { label: 'France', value: 'fr' },
1490
+ ]
1491
+
1492
+ const accountTypes = [
1493
+ { value: 'personal', label: 'Personal' },
1494
+ { value: 'business', label: 'Business' },
1495
+ { value: 'developer', label: 'Developer' },
1496
+ ]
1497
+
1498
+ const interests = [
1499
+ { value: 'technology', label: 'Technology' },
1500
+ { value: 'design', label: 'Design' },
1501
+ { value: 'marketing', label: 'Marketing' },
1502
+ { value: 'finance', label: 'Finance' },
1503
+ ]
1504
+
1505
+ interface FormValues {
1506
+ fullName: string
1507
+ email: string
1508
+ username: string
1509
+ password: string
1510
+ country: string
1511
+ bio: string
1512
+ accountType: 'personal' | 'business' | 'developer'
1513
+ interests: string[]
1514
+ experience: number
1515
+ newsletter: boolean
1516
+ terms: boolean
1517
+ }
1518
+
1519
+ const defaultValues: FormValues = {
1520
+ fullName: '',
1521
+ email: '',
1522
+ username: '',
1523
+ password: '',
1524
+ country: '',
1525
+ bio: '',
1526
+ accountType: 'personal',
1527
+ interests: [],
1528
+ experience: 50,
1529
+ newsletter: true,
1530
+ terms: false,
1531
+ }
1532
+
1533
+ export default function WithTanstackFormExample() {
1534
+ const form = useForm({
1535
+ defaultValues,
1536
+ onSubmit: async ({ value }) => {
1537
+ await new Promise((resolve) => setTimeout(resolve, 1000))
1538
+
1539
+ if (value.username === 'taken_user') {
1540
+ form.setFieldMeta('username', (prev) => ({
1541
+ ...prev,
1542
+ errorMap: { onChange: 'This username is already registered' },
1543
+ }))
1544
+ return
1545
+ }
1546
+ console.log('Form submitted:', value)
1547
+ },
1548
+ validators: {
1549
+ onChange: ({ value: formValues }) => {
1550
+ const errors: Partial<Record<DeepKeys<FormValues>, ValidationError>> = {}
1551
+
1552
+ if (!formValues.fullName || formValues.fullName.length < 2) {
1553
+ errors.fullName = 'Full name must be at least 2 characters'
1554
+ }
1555
+
1556
+ if (!formValues.email) {
1557
+ errors.email = 'Please enter a valid email address'
1558
+ } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formValues.email)) {
1559
+ errors.email = 'Please enter a valid email address'
1560
+ } else if (formValues.email.endsWith('@example.com')) {
1561
+ errors.email = 'Example email addresses are not allowed'
1562
+ }
1563
+
1564
+ if (!formValues.username) {
1565
+ errors.username = 'Please enter a username'
1566
+ } else if (formValues.username.length < 3) {
1567
+ errors.username = 'Username must be at least 3 characters'
1568
+ } else if (formValues.username.length > 20) {
1569
+ errors.username = 'Username must be at most 20 characters'
1570
+ } else if (!/^[a-z0-9_]+$/.test(formValues.username)) {
1571
+ errors.username = 'Only lowercase letters, numbers, and underscores are allowed'
1572
+ } else if (formValues.username === 'admin' || formValues.username === 'root') {
1573
+ errors.username = 'This username is already taken'
1574
+ }
1575
+
1576
+ if (!formValues.password || formValues.password.length < 8) {
1577
+ errors.password = 'Password must be at least 8 characters'
1578
+ }
1579
+
1580
+ if (!formValues.country) {
1581
+ errors.country = 'Please select a country'
1582
+ }
1583
+
1584
+ if (formValues.bio && formValues.bio.length > 500) {
1585
+ errors.bio = 'Bio must be at most 500 characters'
1586
+ }
1587
+
1588
+ if (formValues.interests.length === 0) {
1589
+ errors.interests = 'Please select at least one interest'
1590
+ }
1591
+
1592
+ if (!formValues.terms) {
1593
+ errors.terms = 'You must agree to the terms'
1594
+ }
1595
+
1596
+ return Object.keys(errors).length > 0
1597
+ ? { form: errors, fields: errors }
1598
+ : undefined
1599
+ },
1600
+ },
1601
+ })
1602
+
1603
+ return (
1604
+ <FormRoot
1605
+ aria-label='Create account'
1606
+ onSubmit={(event) => {
1607
+ event.preventDefault()
1608
+ form.handleSubmit()
1609
+ }}
1610
+ className='w-full max-w-md space-y-2'
1611
+ >
1612
+ <form.Field name='fullName'>
1613
+ {({ name, state, handleBlur, handleChange }) => {
1614
+ const { value, meta } = state
1615
+ const { isValid, isTouched, isDirty, errors } = meta
1616
+ return (
1617
+ <FieldRoot name={name} invalid={!isValid} touched={isTouched} dirty={isDirty}>
1618
+ <FieldLabel>Full Name</FieldLabel>
1619
+ <FieldControl
1620
+ value={value}
1621
+ onBlur={handleBlur}
1622
+ onValueChange={handleChange}
1623
+ placeholder='John Doe'
1624
+ />
1625
+ <FieldError match={!isValid}>{errors.join(', ')}</FieldError>
1626
+ </FieldRoot>
1627
+ )
1628
+ }}
1629
+ </form.Field>
1630
+
1631
+ <form.Field name='email'>
1632
+ {({ name, state, handleBlur, handleChange }) => {
1633
+ const { value, meta } = state
1634
+ const { isValid, isTouched, isDirty, errors } = meta
1635
+ return (
1636
+ <FieldRoot name={name} invalid={!isValid} touched={isTouched} dirty={isDirty}>
1637
+ <FieldLabel>Email Address</FieldLabel>
1638
+ <FieldControl
1639
+ type='email'
1640
+ value={value}
1641
+ onBlur={handleBlur}
1642
+ onValueChange={handleChange}
1643
+ placeholder='john@example.com'
1644
+ />
1645
+ <FieldDescription>We will never share your email.</FieldDescription>
1646
+ <FieldError match={!isValid}>{errors.join(', ')}</FieldError>
1647
+ </FieldRoot>
1648
+ )
1649
+ }}
1650
+ </form.Field>
1651
+
1652
+ <form.Field name='username'>
1653
+ {({ name, state, handleBlur, handleChange }) => {
1654
+ const { value, meta } = state
1655
+ const { isValid, isTouched, isDirty, errors } = meta
1656
+ return (
1657
+ <FieldRoot name={name} invalid={!isValid} touched={isTouched} dirty={isDirty}>
1658
+ <FieldLabel>Username</FieldLabel>
1659
+ <FieldControl
1660
+ value={value}
1661
+ onBlur={handleBlur}
1662
+ onValueChange={handleChange}
1663
+ placeholder='john_doe'
1664
+ />
1665
+ <FieldDescription>
1666
+ Lowercase letters, numbers, and underscores only.
1667
+ </FieldDescription>
1668
+ <FieldError match={!isValid}>{errors.join(', ')}</FieldError>
1669
+ </FieldRoot>
1670
+ )
1671
+ }}
1672
+ </form.Field>
1673
+
1674
+ <form.Field name='password'>
1675
+ {({ name, state, handleBlur, handleChange }) => {
1676
+ const { value, meta } = state
1677
+ const { isValid, isTouched, isDirty, errors } = meta
1678
+ return (
1679
+ <FieldRoot name={name} invalid={!isValid} touched={isTouched} dirty={isDirty}>
1680
+ <FieldLabel>Password</FieldLabel>
1681
+ <FieldControl
1682
+ type='password'
1683
+ value={value}
1684
+ onBlur={handleBlur}
1685
+ onValueChange={handleChange}
1686
+ placeholder='Enter password'
1687
+ />
1688
+ <FieldDescription>Must be at least 8 characters.</FieldDescription>
1689
+ <FieldError match={!isValid}>{errors.join(', ')}</FieldError>
1690
+ </FieldRoot>
1691
+ )
1692
+ }}
1693
+ </form.Field>
1694
+
1695
+ <form.Field name='country'>
1696
+ {({ name, state, handleBlur, handleChange }) => {
1697
+ const { value, meta } = state
1698
+ const { isValid, isTouched, isDirty, errors } = meta
1699
+ return (
1700
+ <FieldRoot name={name} invalid={!isValid} touched={isTouched} dirty={isDirty}>
1701
+ <FieldLabel nativeLabel={false} render={<div />}>
1702
+ Country
1703
+ </FieldLabel>
1704
+ <SelectRoot
1705
+ items={countries}
1706
+ value={value}
1707
+ onValueChange={(v) => handleChange(v ?? '')}
1708
+ >
1709
+ <SelectTrigger onBlur={handleBlur}>
1710
+ <SelectValue placeholder='Select country' />
1711
+ <SelectIcon>
1712
+ <ChevronsUpDown className='size-4' />
1713
+ </SelectIcon>
1714
+ </SelectTrigger>
1715
+ <SelectPortal>
1716
+ <SelectPositioner>
1717
+ <SelectPopup>
1718
+ <SelectScrollUpArrow />
1719
+ <SelectList>
1720
+ {countries.map(({ label, value: countryValue }) => (
1721
+ <SelectItem key={countryValue} value={countryValue}>
1722
+ <SelectItemText>{label}</SelectItemText>
1723
+ <SelectItemIndicator>
1724
+ <Check className='size-3.5' />
1725
+ </SelectItemIndicator>
1726
+ </SelectItem>
1727
+ ))}
1728
+ </SelectList>
1729
+ <SelectScrollDownArrow />
1730
+ </SelectPopup>
1731
+ </SelectPositioner>
1732
+ </SelectPortal>
1733
+ </SelectRoot>
1734
+ <FieldError match={!isValid}>{errors.join(', ')}</FieldError>
1735
+ </FieldRoot>
1736
+ )
1737
+ }}
1738
+ </form.Field>
1739
+
1740
+ <form.Field name='bio'>
1741
+ {({ name, state, handleBlur, handleChange }) => {
1742
+ const { value, meta } = state
1743
+ const { isValid, isTouched, isDirty, errors } = meta
1744
+ return (
1745
+ <FieldRoot name={name} invalid={!isValid} touched={isTouched} dirty={isDirty}>
1746
+ <FieldLabel>Bio</FieldLabel>
1747
+ <FieldControl
1748
+ render={<Textarea />}
1749
+ value={value}
1750
+ onBlur={handleBlur}
1751
+ onValueChange={handleChange}
1752
+ placeholder='Tell us about yourself...'
1753
+ />
1754
+ <FieldDescription>Optional. Max 500 characters.</FieldDescription>
1755
+ <FieldError match={!isValid}>{errors.join(', ')}</FieldError>
1756
+ </FieldRoot>
1757
+ )
1758
+ }}
1759
+ </form.Field>
1760
+
1761
+ <form.Field name='accountType'>
1762
+ {({ name, state, handleBlur, handleChange }) => {
1763
+ const { value, meta } = state
1764
+ const { isValid, isTouched, isDirty, errors } = meta
1765
+ return (
1766
+ <FieldRoot name={name} invalid={!isValid} touched={isTouched} dirty={isDirty}>
1767
+ <FieldsetRoot
1768
+ render={
1769
+ <RadioGroupRoot
1770
+ name='accountType'
1771
+ value={value}
1772
+ onValueChange={(v) => handleChange(v as FormValues['accountType'])}
1773
+ />
1774
+ }
1775
+ >
1776
+ <FieldsetLegend>Account Type</FieldsetLegend>
1777
+ {accountTypes.map((type) => (
1778
+ <FieldItem key={type.value}>
1779
+ <FieldLabel>
1780
+ <RadioRoot value={type.value} onBlur={handleBlur}>
1781
+ <RadioIndicator />
1782
+ </RadioRoot>
1783
+ {type.label}
1784
+ </FieldLabel>
1785
+ </FieldItem>
1786
+ ))}
1787
+ </FieldsetRoot>
1788
+ <FieldError match={!isValid}>{errors.join(', ')}</FieldError>
1789
+ </FieldRoot>
1790
+ )
1791
+ }}
1792
+ </form.Field>
1793
+
1794
+ <form.Field name='interests'>
1795
+ {({ name, state, handleBlur, handleChange }) => {
1796
+ const { value, meta } = state
1797
+ const { isValid, isTouched, isDirty, errors } = meta
1798
+ return (
1799
+ <FieldRoot name={name} invalid={!isValid} touched={isTouched} dirty={isDirty}>
1800
+ <FieldsetRoot
1801
+ render={<CheckboxGroupRoot value={value} onValueChange={handleChange} />}
1802
+ >
1803
+ <FieldsetLegend>Interests</FieldsetLegend>
1804
+ {interests.map((interest) => (
1805
+ <FieldItem key={interest.value}>
1806
+ <FieldLabel>
1807
+ <CheckboxRoot value={interest.value} onBlur={handleBlur}>
1808
+ <CheckboxIndicator>
1809
+ <Check className='size-3.5' />
1810
+ </CheckboxIndicator>
1811
+ </CheckboxRoot>
1812
+ {interest.label}
1813
+ </FieldLabel>
1814
+ </FieldItem>
1815
+ ))}
1816
+ </FieldsetRoot>
1817
+ <FieldDescription>Select at least one interest.</FieldDescription>
1818
+ <FieldError match={!isValid}>{errors.join(', ')}</FieldError>
1819
+ </FieldRoot>
1820
+ )
1821
+ }}
1822
+ </form.Field>
1823
+
1824
+ <form.Field name='experience'>
1825
+ {({ name, state, handleBlur, handleChange }) => {
1826
+ const { value, meta } = state
1827
+ const { isValid, isTouched, isDirty } = meta
1828
+ return (
1829
+ <FieldRoot name={name} invalid={!isValid} touched={isTouched} dirty={isDirty}>
1830
+ <FieldsetRoot
1831
+ render={
1832
+ <SliderRoot
1833
+ value={value}
1834
+ onValueChange={(v) => handleChange(typeof v === 'number' ? v : v[0])}
1835
+ onValueCommitted={(v) =>
1836
+ handleChange(typeof v === 'number' ? v : v[0])
1837
+ }
1838
+ thumbAlignment='edge'
1839
+ />
1840
+ }
1841
+ >
1842
+ <div className='flex items-center justify-between text-sm'>
1843
+ <FieldsetLegend className='flex-1'>Experience Level</FieldsetLegend>
1844
+ <SliderValue className='tabular-nums' />
1845
+ </div>
1846
+ <SliderControl>
1847
+ <SliderTrack>
1848
+ <SliderIndicator />
1849
+ <SliderThumb aria-label='Experience level' onBlur={handleBlur} />
1850
+ </SliderTrack>
1851
+ </SliderControl>
1852
+ </FieldsetRoot>
1853
+ <FieldDescription>0 = Beginner, 100 = Expert</FieldDescription>
1854
+ </FieldRoot>
1855
+ )
1856
+ }}
1857
+ </form.Field>
1858
+
1859
+ <form.Field name='newsletter'>
1860
+ {({ name, state, handleBlur, handleChange }) => {
1861
+ const { value, meta } = state
1862
+ const { isValid, isTouched, isDirty } = meta
1863
+ return (
1864
+ <FieldRoot name={name} invalid={!isValid} touched={isTouched} dirty={isDirty}>
1865
+ <FieldItem>
1866
+ <FieldLabel className='flex items-center gap-3'>
1867
+ <SwitchRoot
1868
+ checked={value}
1869
+ onCheckedChange={handleChange}
1870
+ onBlur={handleBlur}
1871
+ >
1872
+ <SwitchThumb />
1873
+ </SwitchRoot>
1874
+ Subscribe to newsletter
1875
+ </FieldLabel>
1876
+ </FieldItem>
1877
+ <FieldDescription>
1878
+ Receive updates and promotions via email.
1879
+ </FieldDescription>
1880
+ </FieldRoot>
1881
+ )
1882
+ }}
1883
+ </form.Field>
1884
+
1885
+ <form.Field name='terms'>
1886
+ {({ name, state, handleBlur, handleChange }) => {
1887
+ const { value, meta } = state
1888
+ const { isValid, isTouched, isDirty, errors } = meta
1889
+ return (
1890
+ <FieldRoot name={name} invalid={!isValid} touched={isTouched} dirty={isDirty}>
1891
+ <FieldItem>
1892
+ <FieldLabel>
1893
+ <CheckboxRoot
1894
+ checked={value}
1895
+ onCheckedChange={handleChange}
1896
+ onBlur={handleBlur}
1897
+ >
1898
+ <CheckboxIndicator>
1899
+ <Check className='size-3.5' />
1900
+ </CheckboxIndicator>
1901
+ </CheckboxRoot>
1902
+ I agree to the Terms of Service and Privacy Policy
1903
+ </FieldLabel>
1904
+ </FieldItem>
1905
+ <FieldError match={!isValid}>{errors.join(', ')}</FieldError>
1906
+ </FieldRoot>
1907
+ )
1908
+ }}
1909
+ </form.Field>
1910
+
1911
+ <form.Subscribe selector={(state) => state.isSubmitting}>
1912
+ {(isSubmitting) => (
1913
+ <Button
1914
+ disabled={isSubmitting}
1915
+ focusableWhenDisabled
1916
+ type='submit'
1917
+ className='w-full'
1918
+ >
1919
+ {isSubmitting ? 'Creating Account...' : 'Create Account'}
1920
+ </Button>
1921
+ )}
1922
+ </form.Subscribe>
1923
+ </FormRoot>
1924
+ )
1925
+ }
1926
+ ```
1927
+
1928
+ ## Resources
1929
+
1930
+ - [Base UI Form Documentation](https://base-ui.com/react/components/form)
1931
+ - [API Reference](https://base-ui.com/react/components/form#api-reference)