@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.
- package/README.md +11 -0
- package/dist/assets/llms/accordion.md +184 -0
- package/dist/assets/llms/alert-dialog.md +306 -0
- package/dist/assets/llms/autocomplete.md +756 -0
- package/dist/assets/llms/avatar.md +166 -0
- package/dist/assets/llms/badge.md +478 -0
- package/dist/assets/llms/button.md +238 -0
- package/dist/assets/llms/card.md +264 -0
- package/dist/assets/llms/checkbox-group.md +158 -0
- package/dist/assets/llms/checkbox.md +83 -0
- package/dist/assets/llms/collapsible.md +165 -0
- package/dist/assets/llms/combobox.md +1255 -0
- package/dist/assets/llms/context-menu.md +371 -0
- package/dist/assets/llms/dialog.md +592 -0
- package/dist/assets/llms/drawer.md +437 -0
- package/dist/assets/llms/field.md +74 -0
- package/dist/assets/llms/form.md +1931 -0
- package/dist/assets/llms/input.md +47 -0
- package/dist/assets/llms/menu.md +484 -0
- package/dist/assets/llms/menubar.md +804 -0
- package/dist/assets/llms/meter.md +181 -0
- package/dist/assets/llms/navigation-menu.md +187 -0
- package/dist/assets/llms/number-field.md +243 -0
- package/dist/assets/llms/pagination.md +514 -0
- package/dist/assets/llms/popover.md +206 -0
- package/dist/assets/llms/preview-card.md +146 -0
- package/dist/assets/llms/progress.md +60 -0
- package/dist/assets/llms/radio-group.md +105 -0
- package/dist/assets/llms/scroll-area.md +132 -0
- package/dist/assets/llms/select.md +276 -0
- package/dist/assets/llms/separator.md +49 -0
- package/dist/assets/llms/skeleton.md +96 -0
- package/dist/assets/llms/slider.md +161 -0
- package/dist/assets/llms/switch.md +101 -0
- package/dist/assets/llms/table.md +1325 -0
- package/dist/assets/llms/tabs.md +327 -0
- package/dist/assets/llms/textarea.md +38 -0
- package/dist/assets/llms/toast.md +349 -0
- package/dist/assets/llms/toggle-group.md +261 -0
- package/dist/assets/llms/toggle.md +161 -0
- package/dist/assets/llms/toolbar.md +148 -0
- package/dist/assets/llms/tooltip.md +486 -0
- package/dist/assets/llms-full.txt +14515 -0
- package/dist/assets/llms.txt +65 -0
- package/dist/index.d.mts +1 -0
- package/dist/index.mjs +161 -0
- package/dist/index.mjs.map +1 -0
- 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)
|