lazyconvex 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +926 -0
  2. package/dist/components/index.mjs +937 -0
  3. package/dist/error-D4GuI0ot.mjs +71 -0
  4. package/dist/file-field-BqVgy8xY.mjs +205 -0
  5. package/dist/form-BXJK_j10.d.mts +99 -0
  6. package/dist/index.d.mts +433 -0
  7. package/dist/index.mjs +1 -0
  8. package/dist/index2.d.mts +5 -0
  9. package/dist/index3.d.mts +35 -0
  10. package/dist/index4.d.mts +101 -0
  11. package/dist/index5.d.mts +842 -0
  12. package/dist/next/index.mjs +151 -0
  13. package/dist/org-CmJBb8z-.d.mts +56 -0
  14. package/dist/react/index.mjs +158 -0
  15. package/dist/retry.d.mts +12 -0
  16. package/dist/retry.mjs +35 -0
  17. package/dist/schema.d.mts +23 -0
  18. package/dist/schema.mjs +15 -0
  19. package/dist/server/index.mjs +2572 -0
  20. package/dist/types-DWBVRtit.d.mts +322 -0
  21. package/dist/use-online-status-CMr73Jlk.mjs +155 -0
  22. package/dist/use-upload-DtELytQi.mjs +95 -0
  23. package/dist/zod.d.mts +18 -0
  24. package/dist/zod.mjs +87 -0
  25. package/package.json +40 -0
  26. package/src/components/editors-section.tsx +86 -0
  27. package/src/components/fields.tsx +884 -0
  28. package/src/components/file-field.tsx +234 -0
  29. package/src/components/form.tsx +191 -0
  30. package/src/components/index.ts +11 -0
  31. package/src/components/offline-indicator.tsx +15 -0
  32. package/src/components/org-avatar.tsx +13 -0
  33. package/src/components/permission-guard.tsx +36 -0
  34. package/src/components/role-badge.tsx +14 -0
  35. package/src/components/suspense-wrap.tsx +8 -0
  36. package/src/index.ts +40 -0
  37. package/src/next/active-org.ts +33 -0
  38. package/src/next/auth.ts +9 -0
  39. package/src/next/image.ts +134 -0
  40. package/src/next/index.ts +3 -0
  41. package/src/react/form-meta.ts +53 -0
  42. package/src/react/form.ts +201 -0
  43. package/src/react/index.ts +8 -0
  44. package/src/react/org.tsx +96 -0
  45. package/src/react/use-active-org.ts +48 -0
  46. package/src/react/use-bulk-selection.ts +47 -0
  47. package/src/react/use-online-status.ts +21 -0
  48. package/src/react/use-optimistic.ts +54 -0
  49. package/src/react/use-upload.ts +101 -0
  50. package/src/retry.ts +47 -0
  51. package/src/schema.ts +30 -0
  52. package/src/server/cache-crud.ts +175 -0
  53. package/src/server/check-schema.ts +29 -0
  54. package/src/server/child.ts +98 -0
  55. package/src/server/crud.ts +384 -0
  56. package/src/server/db.ts +7 -0
  57. package/src/server/error.ts +39 -0
  58. package/src/server/file.ts +372 -0
  59. package/src/server/helpers.ts +214 -0
  60. package/src/server/index.ts +12 -0
  61. package/src/server/org-crud.ts +307 -0
  62. package/src/server/org-helpers.ts +54 -0
  63. package/src/server/org.ts +572 -0
  64. package/src/server/schema-helpers.ts +107 -0
  65. package/src/server/setup.ts +138 -0
  66. package/src/server/test-crud.ts +211 -0
  67. package/src/server/test.ts +554 -0
  68. package/src/server/types.ts +392 -0
  69. package/src/server/unique.ts +28 -0
  70. package/src/zod.ts +141 -0
@@ -0,0 +1,884 @@
1
+ // oxlint-disable promise/prefer-await-to-then
2
+ /* eslint-disable max-statements, @typescript-eslint/no-unsafe-assignment */
3
+ // biome-ignore-all lint/performance/noImgElement: x
4
+ // biome-ignore-all lint/performance/noAwaitInLoops: x
5
+ // biome-ignore-all lint/nursery/noContinue: x
6
+ // biome-ignore-all lint/suspicious/noExplicitAny: x
7
+ 'use client'
8
+ import type { AnyFieldApi } from '@tanstack/react-form'
9
+ import type { LucideIcon } from 'lucide-react'
10
+ import type { ComponentProps, ReactNode } from 'react'
11
+ import type { ZodObject, ZodRawShape } from 'zod/v4'
12
+
13
+ import { cn } from '@a/ui'
14
+ import { Button } from '@a/ui/button'
15
+ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@a/ui/command'
16
+ import { Field, FieldError, FieldLabel } from '@a/ui/field'
17
+ import { Input } from '@a/ui/input'
18
+ import { Popover, PopoverContent, PopoverTrigger } from '@a/ui/popover'
19
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@a/ui/select'
20
+ import { Slider as UISlider } from '@a/ui/slider'
21
+ import { Spinner } from '@a/ui/spinner'
22
+ import { Switch } from '@a/ui/switch'
23
+ import { Textarea } from '@a/ui/textarea'
24
+ import { format } from 'date-fns'
25
+ import { CalendarIcon, Check, ChevronsUpDown, Star, X } from 'lucide-react'
26
+ import dynamic from 'next/dynamic'
27
+ import { createContext, use, useState } from 'react'
28
+ import { toast } from 'sonner'
29
+
30
+ import type { Api } from '../react/form'
31
+ import type { FieldKind, FieldMetaMap } from '../react/form-meta'
32
+
33
+ import { unwrapZod } from '../zod'
34
+
35
+ const Calendar = dynamic(async () => import('@a/ui/calendar').then(m => ({ default: m.Calendar })), {
36
+ loading: () => <div className='h-64 w-full animate-pulse rounded-md bg-muted' />,
37
+ ssr: false
38
+ }),
39
+ // eslint-disable-next-line require-unicode-regexp
40
+ HEX_COLOR_REGEX = /^#[\dA-Fa-f]{6}$/,
41
+ DynamicFileField = dynamic(async () => import('./file-field'), {
42
+ loading: () => <div className='h-32 w-full animate-pulse rounded-lg bg-muted' />,
43
+ ssr: false
44
+ }),
45
+ FormContext = createContext<null | {
46
+ form: Api<Record<string, unknown>>
47
+ meta: FieldMetaMap
48
+ schema: ZodObject<ZodRawShape>
49
+ }>(null),
50
+ useFCtx = () => {
51
+ const c = use(FormContext)
52
+ if (!c) throw new Error('Field must be inside <Form>')
53
+ return c
54
+ },
55
+ useField = (name: string, kind: FieldKind) => {
56
+ const ctx = useFCtx(),
57
+ info = ctx.meta[name]
58
+ if (!info) throw new Error(`Unknown field: ${name}`)
59
+ if (info.kind !== kind) throw new Error(`Field ${name} is not ${kind}`)
60
+ return { form: ctx.form, info, schema: ctx.schema }
61
+ },
62
+ defaultEnumOptions = (schema: ZodObject<ZodRawShape>, name: string): { label: string; value: string }[] => {
63
+ const { schema: inner } = unwrapZod(schema.shape[name])
64
+ if (inner && 'options' in inner) {
65
+ const opts = (inner as { options: readonly string[] }).options
66
+ return opts.map(v => ({ label: v.charAt(0).toUpperCase() + v.slice(1), value: v }))
67
+ }
68
+ throw new Error(`Choose: field "${name}" has no enum options. Pass options prop.`)
69
+ },
70
+ fields = {
71
+ Arr: ({
72
+ className,
73
+ 'data-testid': testId,
74
+ disabled,
75
+ inputClassName,
76
+ label,
77
+ name,
78
+ placeholder,
79
+ tagClassName,
80
+ transform
81
+ }: {
82
+ className?: string
83
+ 'data-testid'?: string
84
+ disabled?: boolean
85
+ inputClassName?: string
86
+ label?: string
87
+ name: string
88
+ placeholder?: string
89
+ tagClassName?: string
90
+ transform?: (v: string) => string
91
+ }) => {
92
+ const { form, info } = useField(name, 'stringArray')
93
+ return (
94
+ <form.Field mode='array' name={name}>
95
+ {(f: AnyFieldApi) => {
96
+ const tags = (f.state.value ?? []) as string[],
97
+ inv = f.state.meta.isTouched && !f.state.meta.isValid,
98
+ mx = info.max,
99
+ tid = testId ?? f.name,
100
+ errorId = `${f.name}-error`
101
+ return (
102
+ <Field data-invalid={inv} data-testid={tid}>
103
+ {label ? <FieldLabel htmlFor={f.name}>{label}</FieldLabel> : null}
104
+ <div
105
+ className={cn(
106
+ 'relative flex min-h-10 w-full flex-wrap items-center gap-0.75 rounded-md border border-input bg-transparent p-1 text-sm transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 has-[input:focus-visible]:border-ring has-[input:focus-visible]:ring-[3px] has-[input:focus-visible]:ring-ring/50 dark:bg-background',
107
+ className
108
+ )}>
109
+ {tags.map((t, i) => (
110
+ <p
111
+ className={cn(
112
+ 'flex h-7 items-center gap-0.5 rounded-full bg-muted pr-1.5 pl-3 transition-all duration-300 hover:bg-input',
113
+ tagClassName,
114
+ disabled && 'cursor-not-allowed opacity-50 *:cursor-not-allowed'
115
+ )}
116
+ key={t}>
117
+ <span className='mb-px'>{t}</span>
118
+ <X
119
+ className='size-4 cursor-pointer rounded-full stroke-1 p-0.5 text-muted-foreground transition-all duration-300 hover:scale-110 hover:bg-background hover:stroke-2 hover:text-destructive active:scale-75'
120
+ onClick={() => {
121
+ if (!disabled) f.removeValue(i)
122
+ }}
123
+ />
124
+ </p>
125
+ ))}
126
+ <input
127
+ aria-describedby={inv ? errorId : undefined}
128
+ aria-invalid={inv}
129
+ className={cn(
130
+ 'peer ml-1 w-0 flex-1 outline-none placeholder:text-muted-foreground placeholder:capitalize',
131
+ tags.length ? 'placeholder:opacity-0' : 'pl-1',
132
+ inputClassName
133
+ )}
134
+ disabled={disabled}
135
+ id={f.name}
136
+ name={f.name}
137
+ onBlur={f.handleBlur}
138
+ onKeyDown={e => {
139
+ const { value } = e.currentTarget
140
+ if (e.key === 'Enter') {
141
+ e.preventDefault()
142
+ if (!value.trim()) return
143
+ const v = transform ? transform(value) : value
144
+ if (tags.includes(v)) {
145
+ toast.error('Item duplicated')
146
+ return
147
+ }
148
+ if (mx && tags.length + 1 > mx) {
149
+ toast.error(`Max ${mx}`)
150
+ return
151
+ }
152
+
153
+ f.handleChange([...new Set([...tags, v])])
154
+ e.currentTarget.value = ''
155
+ } else if (e.key === 'Backspace' && tags.length && !value.trim()) {
156
+ e.preventDefault()
157
+ f.removeValue(tags.length - 1)
158
+ }
159
+ }}
160
+ placeholder={tags.length ? undefined : placeholder}
161
+ />
162
+ </div>
163
+ {inv ? <FieldError errors={f.state.meta.errors} id={errorId} /> : null}
164
+ </Field>
165
+ )
166
+ }}
167
+ </form.Field>
168
+ )
169
+ },
170
+ Choose: ({
171
+ 'data-testid': testId,
172
+ label,
173
+ name,
174
+ options: explicitOptions,
175
+ placeholder
176
+ }: {
177
+ 'data-testid'?: string
178
+ label?: string
179
+ name: string
180
+ options?: readonly { label: string; value: string }[]
181
+ placeholder?: string
182
+ }) => {
183
+ const { form, schema } = useField(name, 'string'),
184
+ options = explicitOptions ?? defaultEnumOptions(schema, name)
185
+ return (
186
+ <form.Field name={name}>
187
+ {(f: AnyFieldApi) => {
188
+ const inv = f.state.meta.isTouched && !f.state.meta.isValid,
189
+ tid = testId ?? f.name,
190
+ errorId = `${f.name}-error`
191
+ return (
192
+ <Field data-invalid={inv} data-testid={tid}>
193
+ {label ? <FieldLabel htmlFor={f.name}>{label}</FieldLabel> : null}
194
+ <Select name={f.name} onValueChange={v => f.handleChange(v)} value={f.state.value ?? ''}>
195
+ <SelectTrigger
196
+ aria-describedby={inv ? errorId : undefined}
197
+ aria-invalid={inv}
198
+ id={f.name}
199
+ onBlur={f.handleBlur}>
200
+ <SelectValue placeholder={placeholder} />
201
+ </SelectTrigger>
202
+ <SelectContent>
203
+ {options.map(o => (
204
+ <SelectItem key={o.value} value={o.value}>
205
+ {o.label}
206
+ </SelectItem>
207
+ ))}
208
+ </SelectContent>
209
+ </Select>
210
+ {inv ? <FieldError errors={f.state.meta.errors} id={errorId} /> : null}
211
+ </Field>
212
+ )
213
+ }}
214
+ </form.Field>
215
+ )
216
+ },
217
+ Colorpick: ({ 'data-testid': testId, label, name }: { 'data-testid'?: string; label?: string; name: string }) => {
218
+ const { form } = useField(name, 'string')
219
+ return (
220
+ <form.Field name={name}>
221
+ {(f: AnyFieldApi) => {
222
+ const inv = f.state.meta.isTouched && !f.state.meta.isValid,
223
+ tid = testId ?? f.name,
224
+ errorId = `${f.name}-error`,
225
+ val = f.state.value ?? '#000000'
226
+ return (
227
+ <Field data-invalid={inv} data-testid={tid}>
228
+ {label ? <FieldLabel htmlFor={f.name}>{label}</FieldLabel> : null}
229
+ <div className='flex gap-2'>
230
+ <input
231
+ aria-describedby={inv ? errorId : undefined}
232
+ aria-invalid={inv}
233
+ className='size-10 cursor-pointer rounded-md border border-input'
234
+ id={f.name}
235
+ name={f.name}
236
+ onBlur={f.handleBlur}
237
+ onChange={e => f.handleChange(e.target.value)}
238
+ type='color'
239
+ value={val}
240
+ />
241
+ <Input
242
+ className='flex-1 font-mono'
243
+ onBlur={f.handleBlur}
244
+ onChange={e => {
245
+ const v = e.target.value
246
+ if (HEX_COLOR_REGEX.test(v)) f.handleChange(v)
247
+ }}
248
+ placeholder='#000000'
249
+ value={val}
250
+ />
251
+ </div>
252
+ {inv ? <FieldError errors={f.state.meta.errors} id={errorId} /> : null}
253
+ </Field>
254
+ )
255
+ }}
256
+ </form.Field>
257
+ )
258
+ },
259
+ Combobox: ({
260
+ 'data-testid': testId,
261
+ emptyText = 'No results found.',
262
+ label,
263
+ name,
264
+ options,
265
+ placeholder = 'Select...',
266
+ searchPlaceholder = 'Search...'
267
+ }: {
268
+ 'data-testid'?: string
269
+ emptyText?: string
270
+ label?: string
271
+ name: string
272
+ options: readonly { label: string; value: string }[]
273
+ placeholder?: string
274
+ searchPlaceholder?: string
275
+ }) => {
276
+ const { form } = useField(name, 'string'),
277
+ [open, setOpen] = useState(false)
278
+ return (
279
+ <form.Field name={name}>
280
+ {(f: AnyFieldApi) => {
281
+ const inv = f.state.meta.isTouched && !f.state.meta.isValid,
282
+ tid = testId ?? f.name,
283
+ errorId = `${f.name}-error`,
284
+ selected = options.find(o => o.value === f.state.value),
285
+ listId = `${f.name}-listbox`
286
+ return (
287
+ <Field data-invalid={inv} data-testid={tid}>
288
+ {label ? <FieldLabel htmlFor={f.name}>{label}</FieldLabel> : null}
289
+ <Popover onOpenChange={setOpen} open={open}>
290
+ <PopoverTrigger asChild>
291
+ <Button
292
+ aria-controls={listId}
293
+ aria-describedby={inv ? errorId : undefined}
294
+ aria-expanded={open}
295
+ aria-invalid={inv}
296
+ className='w-full justify-between font-normal'
297
+ id={f.name}
298
+ onBlur={f.handleBlur}
299
+ role='combobox'
300
+ variant='outline'>
301
+ {selected ? selected.label : <span className='text-muted-foreground'>{placeholder}</span>}
302
+ <ChevronsUpDown className='ml-2 size-4 shrink-0 opacity-50' />
303
+ </Button>
304
+ </PopoverTrigger>
305
+ <PopoverContent className='w-(--radix-popover-trigger-width) p-0'>
306
+ <Command>
307
+ <CommandInput placeholder={searchPlaceholder} />
308
+ <CommandList id={listId}>
309
+ <CommandEmpty>{emptyText}</CommandEmpty>
310
+ <CommandGroup>
311
+ {options.map(o => (
312
+ <CommandItem
313
+ key={o.value}
314
+ onSelect={() => {
315
+ f.handleChange(o.value === f.state.value ? '' : o.value)
316
+ setOpen(false)
317
+ }}
318
+ value={o.label}>
319
+ <Check
320
+ className={cn('mr-2 size-4', f.state.value === o.value ? 'opacity-100' : 'opacity-0')}
321
+ />
322
+ {o.label}
323
+ </CommandItem>
324
+ ))}
325
+ </CommandGroup>
326
+ </CommandList>
327
+ </Command>
328
+ </PopoverContent>
329
+ </Popover>
330
+ {inv ? <FieldError errors={f.state.meta.errors} id={errorId} /> : null}
331
+ </Field>
332
+ )
333
+ }}
334
+ </form.Field>
335
+ )
336
+ },
337
+ Datepick: ({
338
+ clearable = true,
339
+ 'data-testid': testId,
340
+ disabled,
341
+ label,
342
+ name,
343
+ placeholder = 'Pick a date'
344
+ }: {
345
+ clearable?: boolean
346
+ 'data-testid'?: string
347
+ disabled?: boolean
348
+ label?: string
349
+ name: string
350
+ placeholder?: string
351
+ }) => {
352
+ const { form } = useField(name, 'number')
353
+ return (
354
+ <form.Field name={name}>
355
+ {(f: AnyFieldApi) => {
356
+ const inv = f.state.meta.isTouched && !f.state.meta.isValid,
357
+ ts = f.state.value as null | number | undefined,
358
+ dateVal = ts ? new Date(ts) : undefined,
359
+ tid = testId ?? f.name,
360
+ errorId = `${f.name}-error`
361
+ return (
362
+ <Field data-invalid={inv} data-testid={tid}>
363
+ {label ? <FieldLabel htmlFor={f.name}>{label}</FieldLabel> : null}
364
+ <div className='flex gap-1'>
365
+ <Popover>
366
+ <PopoverTrigger asChild>
367
+ <Button
368
+ aria-describedby={inv ? errorId : undefined}
369
+ aria-invalid={inv}
370
+ className={cn('flex-1 justify-start text-left font-normal', !dateVal && 'text-muted-foreground')}
371
+ data-testid={`${tid}-trigger`}
372
+ disabled={disabled}
373
+ id={f.name}
374
+ variant='outline'>
375
+ <CalendarIcon className='mr-2 size-4' />
376
+ {dateVal ? format(dateVal, 'PPP') : placeholder}
377
+ </Button>
378
+ </PopoverTrigger>
379
+ <PopoverContent align='start' className='w-auto p-0' data-testid={`${tid}-calendar`}>
380
+ <Calendar
381
+ mode='single'
382
+ onSelect={d => {
383
+ f.handleChange(d ? d.getTime() : null)
384
+ f.handleBlur()
385
+ }}
386
+ selected={dateVal}
387
+ />
388
+ </PopoverContent>
389
+ </Popover>
390
+ {clearable && dateVal ? (
391
+ <Button
392
+ data-testid={`${tid}-clear`}
393
+ disabled={disabled}
394
+ onClick={() => {
395
+ f.handleChange(null)
396
+ f.handleBlur()
397
+ }}
398
+ size='icon'
399
+ type='button'
400
+ variant='outline'>
401
+ <X className='size-4' />
402
+ </Button>
403
+ ) : null}
404
+ </div>
405
+ {inv ? <FieldError errors={f.state.meta.errors} id={errorId} /> : null}
406
+ </Field>
407
+ )
408
+ }}
409
+ </form.Field>
410
+ )
411
+ },
412
+ Err: ({ error }: { error: Error | null }) =>
413
+ error ? (
414
+ <p className='rounded-lg bg-destructive/10 p-3 text-sm text-destructive' role='alert'>
415
+ {error.message}
416
+ </p>
417
+ ) : null,
418
+ File: ({
419
+ accept,
420
+ className,
421
+ compressImg,
422
+ 'data-testid': testId,
423
+ disabled,
424
+ label,
425
+ maxSize,
426
+ name
427
+ }: {
428
+ accept?: string
429
+ className?: string
430
+ compressImg?: boolean
431
+ 'data-testid'?: string
432
+ disabled?: boolean
433
+ label?: string
434
+ maxSize?: number
435
+ name: string
436
+ }) => {
437
+ const { form } = useField(name, 'file')
438
+ return (
439
+ <form.Field name={name}>
440
+ {(f: AnyFieldApi) => (
441
+ <DynamicFileField
442
+ accept={accept}
443
+ className={className}
444
+ compressImg={compressImg}
445
+ data-testid={testId}
446
+ disabled={disabled}
447
+ field={f}
448
+ label={label}
449
+ maxSize={maxSize}
450
+ />
451
+ )}
452
+ </form.Field>
453
+ )
454
+ },
455
+ Files: ({
456
+ accept,
457
+ className,
458
+ compressImg,
459
+ 'data-testid': testId,
460
+ disabled,
461
+ label,
462
+ max,
463
+ maxSize,
464
+ name
465
+ }: {
466
+ accept?: string
467
+ className?: string
468
+ compressImg?: boolean
469
+ 'data-testid'?: string
470
+ disabled?: boolean
471
+ label?: string
472
+ max?: number
473
+ maxSize?: number
474
+ name: string
475
+ }) => {
476
+ const { form, info } = useField(name, 'files')
477
+ return (
478
+ <form.Field mode='array' name={name}>
479
+ {(f: AnyFieldApi) => (
480
+ <DynamicFileField
481
+ accept={accept}
482
+ className={className}
483
+ compressImg={compressImg}
484
+ data-testid={testId}
485
+ disabled={disabled}
486
+ field={f}
487
+ label={label}
488
+ max={max ?? info.max}
489
+ maxSize={maxSize}
490
+ multiple
491
+ />
492
+ )}
493
+ </form.Field>
494
+ )
495
+ },
496
+ MultiSelect: ({
497
+ 'data-testid': testId,
498
+ label,
499
+ name,
500
+ options,
501
+ placeholder
502
+ }: {
503
+ 'data-testid'?: string
504
+ label?: string
505
+ name: string
506
+ options: readonly { label: string; value: string }[]
507
+ placeholder?: string
508
+ }) => {
509
+ const { form, info } = useField(name, 'stringArray')
510
+ return (
511
+ <form.Field mode='array' name={name}>
512
+ {(f: AnyFieldApi) => {
513
+ const selected = (f.state.value ?? []) as string[],
514
+ inv = f.state.meta.isTouched && !f.state.meta.isValid,
515
+ mx = info.max,
516
+ tid = testId ?? f.name,
517
+ errorId = `${f.name}-error`
518
+ return (
519
+ <Field data-invalid={inv} data-testid={tid}>
520
+ {label ? <FieldLabel htmlFor={f.name}>{label}</FieldLabel> : null}
521
+ <Select
522
+ name={f.name}
523
+ onValueChange={v => {
524
+ if (selected.includes(v)) f.handleChange(selected.filter(x => x !== v))
525
+ else {
526
+ if (mx && selected.length >= mx) {
527
+ toast.error(`Max ${mx}`)
528
+ return
529
+ }
530
+ f.handleChange([...selected, v])
531
+ }
532
+ }}
533
+ value=''>
534
+ <SelectTrigger
535
+ aria-describedby={inv ? errorId : undefined}
536
+ aria-invalid={inv}
537
+ id={f.name}
538
+ onBlur={f.handleBlur}>
539
+ <SelectValue placeholder={selected.length ? `${selected.length} selected` : placeholder} />
540
+ </SelectTrigger>
541
+ <SelectContent>
542
+ {options.map(o => (
543
+ <SelectItem className={selected.includes(o.value) ? 'bg-accent' : ''} key={o.value} value={o.value}>
544
+ {o.label}
545
+ </SelectItem>
546
+ ))}
547
+ </SelectContent>
548
+ </Select>
549
+ {selected.length ? (
550
+ <div className='flex flex-wrap gap-1'>
551
+ {selected.map(v => {
552
+ const opt = options.find(o => o.value === v)
553
+ return (
554
+ <p
555
+ className='flex h-7 items-center gap-0.5 rounded-full bg-muted pr-1.5 pl-3 text-sm transition-all duration-300 hover:bg-input'
556
+ key={v}>
557
+ <span className='mb-px'>{opt?.label ?? v}</span>
558
+ <X
559
+ className='size-4 cursor-pointer rounded-full stroke-1 p-0.5 text-muted-foreground transition-all duration-300 hover:scale-110 hover:bg-background hover:stroke-2 hover:text-destructive active:scale-75'
560
+ onClick={() => f.handleChange(selected.filter(x => x !== v))}
561
+ />
562
+ </p>
563
+ )
564
+ })}
565
+ </div>
566
+ ) : null}
567
+ {inv ? <FieldError errors={f.state.meta.errors} id={errorId} /> : null}
568
+ </Field>
569
+ )
570
+ }}
571
+ </form.Field>
572
+ )
573
+ },
574
+ Num: ({
575
+ 'data-testid': testId,
576
+ label,
577
+ name,
578
+ ...props
579
+ }: Omit<ComponentProps<'input'>, 'form' | 'id' | 'key' | 'name' | 'onBlur' | 'onChange' | 'type' | 'value'> & {
580
+ 'data-testid'?: string
581
+ label?: string
582
+ name: string
583
+ }) => {
584
+ const { form } = useField(name, 'number')
585
+ return (
586
+ <form.Field name={name}>
587
+ {(f: AnyFieldApi) => {
588
+ const inv = f.state.meta.isTouched && !f.state.meta.isValid,
589
+ tid = testId ?? f.name,
590
+ errorId = `${f.name}-error`
591
+ return (
592
+ <Field data-invalid={inv} data-testid={tid}>
593
+ {label ? <FieldLabel htmlFor={f.name}>{label}</FieldLabel> : null}
594
+ <Input
595
+ aria-describedby={inv ? errorId : undefined}
596
+ aria-invalid={inv}
597
+ id={f.name}
598
+ name={f.name}
599
+ onBlur={f.handleBlur}
600
+ onChange={e => {
601
+ const { value, valueAsNumber } = e.currentTarget
602
+ f.handleChange(value === '' || Number.isNaN(valueAsNumber) ? undefined : valueAsNumber)
603
+ }}
604
+ type='number'
605
+ value={f.state.value ?? ''}
606
+ {...props}
607
+ />
608
+ {inv ? <FieldError errors={f.state.meta.errors} id={errorId} /> : null}
609
+ </Field>
610
+ )
611
+ }}
612
+ </form.Field>
613
+ )
614
+ },
615
+ Rating: ({
616
+ 'data-testid': testId,
617
+ label,
618
+ max = 5,
619
+ name
620
+ }: {
621
+ 'data-testid'?: string
622
+ label?: string
623
+ max?: number
624
+ name: string
625
+ }) => {
626
+ const { form } = useField(name, 'number')
627
+ return (
628
+ <form.Field name={name}>
629
+ {(f: AnyFieldApi) => {
630
+ const inv = f.state.meta.isTouched && !f.state.meta.isValid,
631
+ tid = testId ?? f.name,
632
+ errorId = `${f.name}-error`,
633
+ val = f.state.value ?? 0
634
+ return (
635
+ <Field data-invalid={inv} data-testid={tid}>
636
+ {label ? <FieldLabel htmlFor={f.name}>{label}</FieldLabel> : null}
637
+ <div className='flex gap-1'>
638
+ {Array.from({ length: max }, (_, i) => i + 1).map(i => (
639
+ <Star
640
+ className={cn(
641
+ 'size-6 cursor-pointer transition-all',
642
+ i <= val ? 'fill-yellow-400 text-yellow-400' : 'text-muted-foreground hover:text-yellow-400'
643
+ )}
644
+ key={i}
645
+ onBlur={f.handleBlur}
646
+ onClick={() => f.handleChange(i)}
647
+ />
648
+ ))}
649
+ </div>
650
+ {inv ? <FieldError errors={f.state.meta.errors} id={errorId} /> : null}
651
+ </Field>
652
+ )
653
+ }}
654
+ </form.Field>
655
+ )
656
+ },
657
+ Slider: ({
658
+ 'data-testid': testId,
659
+ label,
660
+ max = 100,
661
+ min = 0,
662
+ name,
663
+ step = 1
664
+ }: {
665
+ 'data-testid'?: string
666
+ label?: string
667
+ max?: number
668
+ min?: number
669
+ name: string
670
+ step?: number
671
+ }) => {
672
+ const { form } = useField(name, 'number')
673
+ return (
674
+ <form.Field name={name}>
675
+ {(f: AnyFieldApi) => {
676
+ const inv = f.state.meta.isTouched && !f.state.meta.isValid,
677
+ tid = testId ?? f.name,
678
+ errorId = `${f.name}-error`,
679
+ val = f.state.value ?? min
680
+ return (
681
+ <Field data-invalid={inv} data-testid={tid}>
682
+ <div className='flex items-center justify-between'>
683
+ {label ? <FieldLabel htmlFor={f.name}>{label}</FieldLabel> : null}
684
+ <span className='text-sm text-muted-foreground'>{val}</span>
685
+ </div>
686
+ <UISlider
687
+ aria-describedby={inv ? errorId : undefined}
688
+ aria-invalid={inv}
689
+ id={f.name}
690
+ max={max}
691
+ min={min}
692
+ name={f.name}
693
+ onBlur={f.handleBlur}
694
+ onValueChange={([v]) => f.handleChange(v)}
695
+ step={step}
696
+ value={[val]}
697
+ />
698
+ {inv ? <FieldError errors={f.state.meta.errors} id={errorId} /> : null}
699
+ </Field>
700
+ )
701
+ }}
702
+ </form.Field>
703
+ )
704
+ },
705
+ Submit: ({
706
+ children,
707
+ disabled,
708
+ Icon,
709
+ ...props
710
+ }: Omit<ComponentProps<typeof Button>, 'key' | 'type'> & { children: ReactNode; Icon?: LucideIcon }) => {
711
+ const { form } = useFCtx()
712
+ return (
713
+ <form.Subscribe selector={s => s.isSubmitting}>
714
+ {pending => (
715
+ <Button disabled={disabled ?? pending} type='submit' {...props}>
716
+ {pending ? <Spinner /> : Icon ? <Icon /> : null}
717
+ {children}
718
+ </Button>
719
+ )}
720
+ </form.Subscribe>
721
+ )
722
+ },
723
+ Text: ({
724
+ asyncDebounceMs = 300,
725
+ asyncValidate,
726
+ 'data-testid': testId,
727
+ label,
728
+ maxLength,
729
+ multiline,
730
+ name,
731
+ ...props
732
+ }: Omit<
733
+ ComponentProps<'input'> & ComponentProps<'textarea'>,
734
+ 'form' | 'id' | 'key' | 'maxLength' | 'name' | 'onBlur' | 'onChange' | 'value'
735
+ > & {
736
+ asyncDebounceMs?: number
737
+ asyncValidate?: (value: string) => Promise<string | undefined>
738
+ 'data-testid'?: string
739
+ label?: string
740
+ maxLength?: number
741
+ multiline?: boolean
742
+ name: string
743
+ }) => {
744
+ const { form } = useField(name, 'string')
745
+ return (
746
+ <form.Field
747
+ asyncDebounceMs={asyncDebounceMs}
748
+ name={name}
749
+ validators={
750
+ asyncValidate
751
+ ? {
752
+ onChangeAsync: async ({ value }: { value: string }) => {
753
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
754
+ const error = await asyncValidate(value ?? '')
755
+ return error
756
+ }
757
+ }
758
+ : undefined
759
+ }>
760
+ {(f: AnyFieldApi) => {
761
+ const inv = f.state.meta.isTouched && !f.state.meta.isValid,
762
+ validating = f.state.meta.isValidating,
763
+ C = multiline ? Textarea : Input,
764
+ val = f.state.value ?? '',
765
+ tid = testId ?? f.name,
766
+ errorId = `${f.name}-error`
767
+ return (
768
+ <Field data-invalid={inv} data-testid={tid}>
769
+ <div className='flex items-center justify-between'>
770
+ {label ? <FieldLabel htmlFor={f.name}>{label}</FieldLabel> : null}
771
+ <div className='flex items-center gap-2'>
772
+ {validating ? (
773
+ <div className='flex items-center gap-1 text-xs text-muted-foreground'>
774
+ <Spinner className='size-3' />
775
+ <span>Validating...</span>
776
+ </div>
777
+ ) : null}
778
+ {maxLength ? (
779
+ <span className='text-xs text-muted-foreground'>
780
+ {String(val).length}/{maxLength}
781
+ </span>
782
+ ) : null}
783
+ </div>
784
+ </div>
785
+ <C
786
+ aria-describedby={inv ? errorId : undefined}
787
+ aria-invalid={inv}
788
+ id={f.name}
789
+ maxLength={maxLength}
790
+ name={f.name}
791
+ onBlur={f.handleBlur}
792
+ onChange={e => f.handleChange(e.target.value)}
793
+ value={val}
794
+ {...props}
795
+ />
796
+ {inv ? <FieldError errors={f.state.meta.errors} id={errorId} /> : null}
797
+ </Field>
798
+ )
799
+ }}
800
+ </form.Field>
801
+ )
802
+ },
803
+ Timepick: ({
804
+ 'data-testid': testId,
805
+ label,
806
+ name,
807
+ placeholder = 'HH:MM'
808
+ }: {
809
+ 'data-testid'?: string
810
+ label?: string
811
+ name: string
812
+ placeholder?: string
813
+ }) => {
814
+ const { form } = useField(name, 'string')
815
+ return (
816
+ <form.Field name={name}>
817
+ {(f: AnyFieldApi) => {
818
+ const inv = f.state.meta.isTouched && !f.state.meta.isValid,
819
+ tid = testId ?? f.name,
820
+ errorId = `${f.name}-error`
821
+ return (
822
+ <Field data-invalid={inv} data-testid={tid}>
823
+ {label ? <FieldLabel htmlFor={f.name}>{label}</FieldLabel> : null}
824
+ <Input
825
+ aria-describedby={inv ? errorId : undefined}
826
+ aria-invalid={inv}
827
+ id={f.name}
828
+ name={f.name}
829
+ onBlur={f.handleBlur}
830
+ onChange={e => f.handleChange(e.target.value)}
831
+ placeholder={placeholder}
832
+ type='time'
833
+ value={f.state.value ?? ''}
834
+ />
835
+ {inv ? <FieldError errors={f.state.meta.errors} id={errorId} /> : null}
836
+ </Field>
837
+ )
838
+ }}
839
+ </form.Field>
840
+ )
841
+ },
842
+ Toggle: ({
843
+ 'data-testid': testId,
844
+ falseLabel,
845
+ name,
846
+ trueLabel
847
+ }: {
848
+ 'data-testid'?: string
849
+ falseLabel?: string
850
+ name: string
851
+ trueLabel: string
852
+ }) => {
853
+ const { form } = useField(name, 'boolean')
854
+ return (
855
+ <form.Field name={name}>
856
+ {(f: AnyFieldApi) => {
857
+ const inv = f.state.meta.isTouched && !f.state.meta.isValid,
858
+ tid = testId ?? f.name,
859
+ errorId = `${f.name}-error`
860
+ return (
861
+ <Field data-invalid={inv} data-testid={tid}>
862
+ <div className='flex items-center gap-2'>
863
+ <Switch
864
+ aria-describedby={inv ? errorId : undefined}
865
+ aria-invalid={inv}
866
+ checked={f.state.value ?? false}
867
+ id={f.name}
868
+ name={f.name}
869
+ onBlur={f.handleBlur}
870
+ onCheckedChange={v => f.handleChange(v)}
871
+ />
872
+ <FieldLabel htmlFor={f.name}>{f.state.value ? trueLabel : (falseLabel ?? trueLabel)}</FieldLabel>
873
+ </div>
874
+ {inv ? <FieldError errors={f.state.meta.errors} id={errorId} /> : null}
875
+ </Field>
876
+ )
877
+ }}
878
+ </form.Field>
879
+ )
880
+ }
881
+ }
882
+
883
+ export type { Api }
884
+ export { fields, FormContext }