@zeno-lib/forms 0.5.0 → 0.7.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 (67) hide show
  1. package/dist/create-zeno-form.d.mts +27 -6
  2. package/dist/create-zeno-form.mjs +29 -6
  3. package/dist/form-element.d.mts +16 -3
  4. package/dist/form-element.mjs +38 -18
  5. package/dist/index.d.mts +3 -1
  6. package/dist/index.mjs +3 -1
  7. package/dist/lib/date-value.d.mts +24 -0
  8. package/dist/lib/date-value.mjs +37 -0
  9. package/dist/lib/default-props.d.mts +7 -0
  10. package/dist/lib/default-props.mjs +13 -0
  11. package/dist/lib/empty-value.mjs +25 -1
  12. package/dist/lib/form-disabled.d.mts +28 -0
  13. package/dist/lib/form-disabled.mjs +54 -0
  14. package/dist/lib/item-options.d.mts +45 -0
  15. package/dist/lib/item-options.mjs +70 -0
  16. package/dist/lib/schema-required.mjs +17 -0
  17. package/dist/lib/submit-action.d.mts +8 -2
  18. package/dist/lib/submit-action.mjs +4 -2
  19. package/dist/lib/use-field-control.d.mts +83 -0
  20. package/dist/lib/use-field-control.mjs +70 -0
  21. package/package.json +1 -1
  22. package/src/control-field.test.tsx +338 -0
  23. package/src/create-form.tsx +36 -28
  24. package/src/create-zeno-form.tsx +144 -52
  25. package/src/date-picker-field.test.tsx +108 -0
  26. package/src/default-props.test-d.ts +66 -0
  27. package/src/default-props.test.tsx +165 -0
  28. package/src/fields/checkbox-field.tsx +5 -0
  29. package/src/fields/checkbox-group-field.tsx +6 -2
  30. package/src/fields/combobox-field.tsx +107 -40
  31. package/src/fields/control-field.tsx +57 -0
  32. package/src/fields/date-picker-field.tsx +34 -11
  33. package/src/fields/field-shell.tsx +80 -0
  34. package/src/fields/index.ts +2 -0
  35. package/src/fields/input-field.tsx +16 -48
  36. package/src/fields/multi-select-field.tsx +100 -42
  37. package/src/fields/otp-field.tsx +8 -2
  38. package/src/fields/radio-group-field.tsx +9 -1
  39. package/src/fields/reset-button.tsx +4 -0
  40. package/src/fields/select-field.tsx +9 -1
  41. package/src/fields/slider-field.tsx +9 -1
  42. package/src/fields/submit-button.tsx +3 -1
  43. package/src/fields/switch-field.tsx +5 -0
  44. package/src/fields/textarea-field.tsx +16 -48
  45. package/src/form-dialog.test.tsx +102 -1
  46. package/src/form-dialog.tsx +143 -42
  47. package/src/form-disabled.test.tsx +329 -0
  48. package/src/form-element.tsx +64 -29
  49. package/src/grouped-options.test-d.ts +59 -0
  50. package/src/grouped-options.test.tsx +251 -0
  51. package/src/index.ts +7 -0
  52. package/src/lib/date-value.test.ts +14 -0
  53. package/src/lib/date-value.ts +70 -0
  54. package/src/lib/default-props.ts +19 -0
  55. package/src/lib/empty-value.test.ts +19 -0
  56. package/src/lib/empty-value.ts +45 -1
  57. package/src/lib/form-disabled.ts +95 -0
  58. package/src/lib/item-options.test.ts +11 -0
  59. package/src/lib/item-options.ts +165 -0
  60. package/src/lib/schema-required.test.ts +9 -0
  61. package/src/lib/schema-required.ts +33 -0
  62. package/src/lib/submit-action.ts +18 -4
  63. package/src/lib/use-field-control.ts +191 -0
  64. package/src/submit-action.test.tsx +103 -0
  65. package/src/use-app-fields.test-d.ts +51 -1
  66. package/src/with-form.test-d.ts +76 -0
  67. package/src/with-form.test.tsx +60 -0
@@ -4,6 +4,7 @@
4
4
  import type {
5
5
  AnyFormApi,
6
6
  DeepKeys,
7
+ DeepValue,
7
8
  FormAsyncValidateOrFn,
8
9
  FormOptions,
9
10
  FormValidateOrFn,
@@ -12,13 +13,16 @@ import { createFormHook } from "@tanstack/react-form"
12
13
  import { type ComponentProps, type ReactNode, useMemo } from "react"
13
14
  import { applyValidationError } from "./lib/apply-validation-error"
14
15
  import { fieldContext, formContext } from "./lib/contexts"
16
+ import { withDefaultProps } from "./lib/default-props"
15
17
  import { withEmptyStartValues } from "./lib/empty-value"
18
+ import { useDisabledOption } from "./lib/form-disabled"
16
19
  import { deepMergeDefaults, extractZodDefaults } from "./lib/schema-defaults"
17
20
  import {
18
21
  getRequiredPaths,
19
22
  type StandardSchemaLike,
20
23
  } from "./lib/schema-required"
21
24
  import { setSubmitError } from "./lib/submit-error"
25
+ import type { FieldControlProps } from "./lib/use-field-control"
22
26
  import { useRebasedDefaultValues } from "./lib/use-rebased-default-values"
23
27
  import { useUnsavedChangesWarning } from "./lib/use-unsaved-changes-warning"
24
28
  import { ValidationError } from "./lib/validation-error"
@@ -52,6 +56,11 @@ type PartialFormData<T> = T extends readonly unknown[]
52
56
  : T
53
57
 
54
58
  type ZenoFormExtras<TFormData> = {
59
+ /**
60
+ * Disable every field of the form, its submit button and `FormDialog`'s
61
+ * Save. A field's own `disabled` still applies.
62
+ */
63
+ disabled?: boolean
55
64
  /**
56
65
  * If `true`, shipped fields skip rendering their inline `<FieldError>`
57
66
  * message. Fields still flip `data-invalid` / `aria-invalid`.
@@ -88,18 +97,21 @@ type ManualPathExtras = {
88
97
  schema?: never
89
98
  }
90
99
 
100
+ type SyncValidator<TFormData> = undefined | FormValidateOrFn<TFormData>
101
+ type AsyncValidator<TFormData> = undefined | FormAsyncValidateOrFn<TFormData>
102
+
91
103
  type SchemaFormOptions<
92
104
  TFormData,
93
- TOnMount extends undefined | FormValidateOrFn<TFormData>,
94
- TOnChange extends undefined | FormValidateOrFn<TFormData>,
95
- TOnChangeAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
96
- TOnBlur extends undefined | FormValidateOrFn<TFormData>,
97
- TOnBlurAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
98
- TOnSubmit extends undefined | FormValidateOrFn<TFormData>,
99
- TOnSubmitAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
100
- TOnDynamic extends undefined | FormValidateOrFn<TFormData>,
101
- TOnDynamicAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
102
- TOnServer extends undefined | FormAsyncValidateOrFn<TFormData>,
105
+ TOnMount extends SyncValidator<TFormData>,
106
+ TOnChange extends SyncValidator<TFormData>,
107
+ TOnChangeAsync extends AsyncValidator<TFormData>,
108
+ TOnBlur extends SyncValidator<TFormData>,
109
+ TOnBlurAsync extends AsyncValidator<TFormData>,
110
+ TOnSubmit extends SyncValidator<TFormData>,
111
+ TOnSubmitAsync extends AsyncValidator<TFormData>,
112
+ TOnDynamic extends SyncValidator<TFormData>,
113
+ TOnDynamicAsync extends AsyncValidator<TFormData>,
114
+ TOnServer extends AsyncValidator<TFormData>,
103
115
  TSubmitMeta,
104
116
  > = Omit<
105
117
  FormOptions<
@@ -123,16 +135,16 @@ type SchemaFormOptions<
123
135
 
124
136
  type ManualFormOptions<
125
137
  TFormData,
126
- TOnMount extends undefined | FormValidateOrFn<TFormData>,
127
- TOnChange extends undefined | FormValidateOrFn<TFormData>,
128
- TOnChangeAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
129
- TOnBlur extends undefined | FormValidateOrFn<TFormData>,
130
- TOnBlurAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
131
- TOnSubmit extends undefined | FormValidateOrFn<TFormData>,
132
- TOnSubmitAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
133
- TOnDynamic extends undefined | FormValidateOrFn<TFormData>,
134
- TOnDynamicAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
135
- TOnServer extends undefined | FormAsyncValidateOrFn<TFormData>,
138
+ TOnMount extends SyncValidator<TFormData>,
139
+ TOnChange extends SyncValidator<TFormData>,
140
+ TOnChangeAsync extends AsyncValidator<TFormData>,
141
+ TOnBlur extends SyncValidator<TFormData>,
142
+ TOnBlurAsync extends AsyncValidator<TFormData>,
143
+ TOnSubmit extends SyncValidator<TFormData>,
144
+ TOnSubmitAsync extends AsyncValidator<TFormData>,
145
+ TOnDynamic extends SyncValidator<TFormData>,
146
+ TOnDynamicAsync extends AsyncValidator<TFormData>,
147
+ TOnServer extends AsyncValidator<TFormData>,
136
148
  TSubmitMeta,
137
149
  > = Omit<
138
150
  FormOptions<
@@ -156,16 +168,16 @@ type ManualFormOptions<
156
168
 
157
169
  type UseFormOptions<
158
170
  TFormData,
159
- TOnMount extends undefined | FormValidateOrFn<TFormData>,
160
- TOnChange extends undefined | FormValidateOrFn<TFormData>,
161
- TOnChangeAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
162
- TOnBlur extends undefined | FormValidateOrFn<TFormData>,
163
- TOnBlurAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
164
- TOnSubmit extends undefined | FormValidateOrFn<TFormData>,
165
- TOnSubmitAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
166
- TOnDynamic extends undefined | FormValidateOrFn<TFormData>,
167
- TOnDynamicAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
168
- TOnServer extends undefined | FormAsyncValidateOrFn<TFormData>,
171
+ TOnMount extends SyncValidator<TFormData>,
172
+ TOnChange extends SyncValidator<TFormData>,
173
+ TOnChangeAsync extends AsyncValidator<TFormData>,
174
+ TOnBlur extends SyncValidator<TFormData>,
175
+ TOnBlurAsync extends AsyncValidator<TFormData>,
176
+ TOnSubmit extends SyncValidator<TFormData>,
177
+ TOnSubmitAsync extends AsyncValidator<TFormData>,
178
+ TOnDynamic extends SyncValidator<TFormData>,
179
+ TOnDynamicAsync extends AsyncValidator<TFormData>,
180
+ TOnServer extends AsyncValidator<TFormData>,
169
181
  TSubmitMeta,
170
182
  > =
171
183
  | SchemaFormOptions<
@@ -273,18 +285,89 @@ type WithValidators = {
273
285
  // biome-ignore lint/suspicious/noExplicitAny: field components accept arbitrary props.
274
286
  type AnyComponent = React.ComponentType<any>
275
287
 
288
+ // A field that hands `render` a `FieldControlProps` (the kit's `ControlField`)
289
+ // gets the control typed with the value at `name`, which its own generic
290
+ // can't see. Matching on the parameter, not on `render` as a whole, leaves a
291
+ // field whose `render` takes something else (or nothing) as declared.
292
+ type WithControlValue<P, V> = P extends {
293
+ render: (control: infer C) => ReactNode
294
+ }
295
+ ? // biome-ignore lint/suspicious/noExplicitAny: matches any value type.
296
+ [C] extends [FieldControlProps<any>]
297
+ ? Omit<P, "render"> & {
298
+ render: (control: FieldControlProps<V>) => ReactNode
299
+ }
300
+ : P
301
+ : P
302
+
276
303
  type FieldWrappers<FC extends Record<string, AnyComponent>, T> = {
277
304
  [K in keyof FC]: <N extends DeepKeys<T>>(
278
- props: ComponentProps<FC[K]> & { name: N } & WithValidators
305
+ props: WithControlValue<ComponentProps<FC[K]>, DeepValue<T, N>> & {
306
+ name: N
307
+ } & WithValidators
279
308
  ) => ReactNode
280
309
  }
281
310
 
311
+ // A component's optional props, per member of a union of props.
312
+ type OptionalProps<P> = P extends unknown
313
+ ? {
314
+ [K in keyof P as Pick<P, K> extends Required<Pick<P, K>>
315
+ ? never
316
+ : K]?: P[K]
317
+ }
318
+ : never
319
+
320
+ // Each component's defaults take only its own optional props: a required
321
+ // one stays required at every call site, so its default would never apply.
322
+ type DefaultProps<C extends Record<string, AnyComponent>> = {
323
+ [K in keyof C]?: OptionalProps<ComponentProps<C[K]>>
324
+ }
325
+
282
326
  type CreateZenoFormConfig<
283
327
  FC extends Record<string, AnyComponent>,
284
328
  FMC extends Record<string, AnyComponent>,
285
329
  > = {
286
330
  fieldComponents: FC
287
331
  formComponents: FMC
332
+ /**
333
+ * Default props for field and form components, keyed by component name,
334
+ * e.g. `{ MoneyField: { currency: "CHF", locale: "de-CH" } }`. A prop
335
+ * passed at the call site wins; one passed as `undefined` keeps the default.
336
+ */
337
+ defaultProps?: DefaultProps<NoInfer<FC> & NoInfer<FMC>>
338
+ }
339
+
340
+ // What `memo` and `forwardRef` store on their component object, which a
341
+ // function component must not take over.
342
+ const REACT_TYPE_KEYS = new Set(["$$typeof", "compare", "render", "type"])
343
+
344
+ // Wrap each component that has defaults, once, so they apply however it
345
+ // renders: destructured from `useForm`, as `field.X` in `AppField`, or in
346
+ // `withForm`.
347
+ function withComponentDefaults<C extends Record<string, AnyComponent>>(
348
+ components: C,
349
+ defaultProps: Partial<Record<string, object>> | undefined
350
+ ): C {
351
+ const wrapped: Record<string, AnyComponent> = { ...components }
352
+ for (const [name, defaults] of Object.entries(defaultProps ?? {})) {
353
+ const Component = components[name]
354
+ if (Component === undefined || defaults === undefined) {
355
+ continue
356
+ }
357
+ const Defaulted = (props: object) => (
358
+ <Component {...withDefaultProps(defaults, props)} />
359
+ )
360
+ // Keep the component's statics (`AddressField.Row`): the wrapper is
361
+ // typed as the component.
362
+ for (const [key, value] of Object.entries(Component)) {
363
+ if (!REACT_TYPE_KEYS.has(key)) {
364
+ Object.assign(Defaulted, { [key]: value })
365
+ }
366
+ }
367
+ Defaulted.displayName = name
368
+ wrapped[name] = Defaulted
369
+ }
370
+ return wrapped as C
288
371
  }
289
372
 
290
373
  /**
@@ -299,10 +382,18 @@ export function createZenoForm<
299
382
  FC extends Record<string, AnyComponent>,
300
383
  FMC extends Record<string, AnyComponent>,
301
384
  >(config: CreateZenoFormConfig<FC, FMC>) {
385
+ const fieldComponents = withComponentDefaults(
386
+ config.fieldComponents,
387
+ config.defaultProps
388
+ )
389
+ const formComponents = withComponentDefaults(
390
+ config.formComponents,
391
+ config.defaultProps
392
+ )
302
393
  const { useAppForm, withFieldGroup, withForm } = createFormHook({
303
- fieldComponents: config.fieldComponents,
394
+ fieldComponents,
304
395
  fieldContext,
305
- formComponents: config.formComponents,
396
+ formComponents,
306
397
  formContext,
307
398
  })
308
399
 
@@ -316,8 +407,8 @@ export function createZenoForm<
316
407
  children: () => ReactNode
317
408
  }>
318
409
  const wrappers: Record<string, AnyComponent> = {}
319
- for (const key of Object.keys(config.fieldComponents)) {
320
- const Impl = config.fieldComponents[key] as AnyComponent
410
+ for (const key of Object.keys(fieldComponents)) {
411
+ const Impl = fieldComponents[key] as AnyComponent
321
412
  wrappers[key] = ({
322
413
  name,
323
414
  validators,
@@ -337,27 +428,25 @@ export function createZenoForm<
337
428
  }, [form])
338
429
  }
339
430
 
431
+ // An unset generic defaults to its constraint, which is what TanStack's own
432
+ // `useForm` infers, never to `undefined` or `never`: `withForm` rejects the
433
+ // form otherwise (`with-form.test-d.ts`).
340
434
  function useForm<
341
435
  TFormData,
342
- TOnMount extends undefined | FormValidateOrFn<TFormData> = undefined,
343
- TOnChange extends undefined | FormValidateOrFn<TFormData> = undefined,
436
+ TOnMount extends SyncValidator<TFormData> = SyncValidator<TFormData>,
437
+ TOnChange extends SyncValidator<TFormData> = SyncValidator<TFormData>,
344
438
  TOnChangeAsync extends
345
- | undefined
346
- | FormAsyncValidateOrFn<TFormData> = undefined,
347
- TOnBlur extends undefined | FormValidateOrFn<TFormData> = undefined,
348
- TOnBlurAsync extends
349
- | undefined
350
- | FormAsyncValidateOrFn<TFormData> = undefined,
351
- TOnSubmit extends undefined | FormValidateOrFn<TFormData> = undefined,
439
+ AsyncValidator<TFormData> = AsyncValidator<TFormData>,
440
+ TOnBlur extends SyncValidator<TFormData> = SyncValidator<TFormData>,
441
+ TOnBlurAsync extends AsyncValidator<TFormData> = AsyncValidator<TFormData>,
442
+ TOnSubmit extends SyncValidator<TFormData> = SyncValidator<TFormData>,
352
443
  TOnSubmitAsync extends
353
- | undefined
354
- | FormAsyncValidateOrFn<TFormData> = undefined,
355
- TOnDynamic extends undefined | FormValidateOrFn<TFormData> = undefined,
444
+ AsyncValidator<TFormData> = AsyncValidator<TFormData>,
445
+ TOnDynamic extends SyncValidator<TFormData> = SyncValidator<TFormData>,
356
446
  TOnDynamicAsync extends
357
- | undefined
358
- | FormAsyncValidateOrFn<TFormData> = undefined,
359
- TOnServer extends undefined | FormAsyncValidateOrFn<TFormData> = undefined,
360
- TSubmitMeta = never,
447
+ AsyncValidator<TFormData> = AsyncValidator<TFormData>,
448
+ TOnServer extends AsyncValidator<TFormData> = AsyncValidator<TFormData>,
449
+ TSubmitMeta = unknown,
361
450
  >(
362
451
  options: UseFormOptions<
363
452
  TFormData,
@@ -395,6 +484,7 @@ export function createZenoForm<
395
484
  schema,
396
485
  validators: validatorsInput,
397
486
  validationLogic: userValidationLogic,
487
+ disabled = false,
398
488
  hideFieldErrors = false,
399
489
  requiredIndicator = true,
400
490
  unsavedChangesWarning = false,
@@ -406,6 +496,7 @@ export function createZenoForm<
406
496
  validators?: ValidationMode | NativeValidators
407
497
  validationLogic?: NativeValidationLogic
408
498
  defaultValues?: PartialFormData<TFormData>
499
+ disabled?: boolean
409
500
  hideFieldErrors?: boolean
410
501
  requiredIndicator?: boolean
411
502
  unsavedChangesWarning?: boolean | "if-changed" | "if-touched"
@@ -538,11 +629,12 @@ export function createZenoForm<
538
629
  ...(schemaMode === undefined ? {} : { validation: schemaMode }),
539
630
  })
540
631
 
632
+ useDisabledOption(form, disabled)
541
633
  useUnsavedChangesWarning(form, unsavedChangesWarning)
542
634
 
543
635
  const fields = useAppFields(form)
544
636
  return useMemo(
545
- () => Object.assign(form, fields, config.formComponents),
637
+ () => Object.assign(form, fields, formComponents),
546
638
  [form, fields]
547
639
  )
548
640
  }
@@ -0,0 +1,108 @@
1
+ import {
2
+ cleanup,
3
+ fireEvent,
4
+ render,
5
+ screen,
6
+ } from "@zeno-lib/test/testing-library"
7
+ import userEvent from "@zeno-lib/test/user-event"
8
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"
9
+ import { Form, FormProvider, useForm } from "./create-form"
10
+ import type { DateValueType } from "./lib/date-value"
11
+
12
+ const DAY_LABEL = /Day/
13
+ const OCTOBER_9_SELECTED = /October 9th, 2026, selected/
14
+ const OCTOBER_10 = /October 10th, 2026/
15
+
16
+ afterEach(() => {
17
+ cleanup()
18
+ vi.unstubAllEnvs()
19
+ })
20
+
21
+ function Harness({
22
+ captured,
23
+ defaultValue,
24
+ valueType,
25
+ }: {
26
+ captured: { day?: unknown }
27
+ defaultValue: string
28
+ valueType: DateValueType
29
+ }) {
30
+ const form = useForm({
31
+ defaultValues: { day: defaultValue as string | undefined },
32
+ onSubmit: vi.fn(),
33
+ })
34
+ const { DatePickerField, Subscribe } = form
35
+ return (
36
+ <FormProvider form={form}>
37
+ <Form>
38
+ <DatePickerField
39
+ // `toDateString` doesn't depend on the runtime's locale.
40
+ formatValue={(date) => date.toDateString()}
41
+ label="Day"
42
+ name="day"
43
+ valueType={valueType}
44
+ />
45
+ <Subscribe selector={(state) => state.values.day}>
46
+ {(day) => {
47
+ captured.day = day
48
+ return null
49
+ }}
50
+ </Subscribe>
51
+ </Form>
52
+ </FormProvider>
53
+ )
54
+ }
55
+
56
+ // The day before UTC's date in the evening, and the day after in the morning:
57
+ // a date read or written through UTC lands on the wrong day in one of them.
58
+ describe.each(["Pacific/Kiritimati", "America/Los_Angeles"])(
59
+ "DatePickerField in %s",
60
+ (zone) => {
61
+ beforeEach(() => {
62
+ vi.stubEnv("TZ", zone)
63
+ })
64
+
65
+ test('valueType="iso-date" shows and stores the day itself', async () => {
66
+ const user = userEvent.setup()
67
+ const captured: { day?: unknown } = {}
68
+ render(
69
+ <Harness
70
+ captured={captured}
71
+ defaultValue="2026-10-09"
72
+ valueType="iso-date"
73
+ />
74
+ )
75
+ const trigger = screen.getByRole("button", { name: DAY_LABEL })
76
+ expect(trigger.textContent).toContain("Oct 09 2026")
77
+
78
+ fireEvent.click(trigger)
79
+ expect(await screen.findByRole("button", { name: OCTOBER_9_SELECTED }))
80
+ await user.click(screen.getByRole("button", { name: OCTOBER_10 }))
81
+ expect(captured.day).toBe("2026-10-10")
82
+ expect(trigger.textContent).toContain("Oct 10 2026")
83
+ })
84
+
85
+ test('valueType="iso-datetime" stores a timestamp that reads back as the picked day', async () => {
86
+ const user = userEvent.setup()
87
+ const captured: { day?: unknown } = {}
88
+ render(
89
+ <Harness
90
+ captured={captured}
91
+ // What the field stored for October 9 in this zone.
92
+ defaultValue={new Date(2026, 9, 9).toISOString()}
93
+ valueType="iso-datetime"
94
+ />
95
+ )
96
+ const trigger = screen.getByRole("button", { name: DAY_LABEL })
97
+ expect(trigger.textContent).toContain("Oct 09 2026")
98
+
99
+ fireEvent.click(trigger)
100
+ await user.click(await screen.findByRole("button", { name: OCTOBER_10 }))
101
+ expect(typeof captured.day).toBe("string")
102
+ expect(new Date(captured.day as string).toDateString()).toBe(
103
+ "Sat Oct 10 2026"
104
+ )
105
+ expect(trigger.textContent).toContain("Oct 10 2026")
106
+ })
107
+ }
108
+ )
@@ -0,0 +1,66 @@
1
+ import { test } from "vitest"
2
+ import { createZenoForm } from "./create-zeno-form"
3
+ import { FormError } from "./fields/form-error"
4
+ import { MoneyField } from "./fields/money-field"
5
+ import { PercentageField } from "./fields/percentage-field"
6
+ import { SelectField } from "./fields/select-field"
7
+ import { FormDialogProvider } from "./form-dialog"
8
+
9
+ const fieldComponents = { MoneyField, PercentageField, SelectField }
10
+ const formComponents = { FormError }
11
+
12
+ test("defaultProps takes each component's own props", () => {
13
+ createZenoForm({
14
+ defaultProps: {
15
+ FormError: { submitErrorMessage: "Try again later." },
16
+ MoneyField: { currency: "CHF", locale: "de-CH" },
17
+ PercentageField: { scale: "fraction" },
18
+ },
19
+ fieldComponents,
20
+ formComponents,
21
+ })
22
+ })
23
+
24
+ test("defaultProps rejects another component's prop", () => {
25
+ createZenoForm({
26
+ defaultProps: {
27
+ // @ts-expect-error — `scale` is PercentageField's
28
+ MoneyField: { scale: "fraction" },
29
+ },
30
+ fieldComponents,
31
+ formComponents,
32
+ })
33
+ })
34
+
35
+ test("defaultProps rejects a required prop, which every call site still passes", () => {
36
+ createZenoForm({
37
+ defaultProps: {
38
+ // @ts-expect-error — `children` is required
39
+ SelectField: { children: null },
40
+ },
41
+ fieldComponents,
42
+ formComponents,
43
+ })
44
+ })
45
+
46
+ test("defaultProps rejects a component the form doesn't have", () => {
47
+ createZenoForm({
48
+ defaultProps: {
49
+ // @ts-expect-error — no such component
50
+ YearField: { min: 1900 },
51
+ },
52
+ fieldComponents,
53
+ formComponents,
54
+ })
55
+ })
56
+
57
+ test("FormDialogProvider takes the options every dialog shares", () => {
58
+ FormDialogProvider({
59
+ children: null,
60
+ discardPrompt: { title: "Leave?" },
61
+ saveFromPrompt: true,
62
+ submitLabel: "Speichern",
63
+ })
64
+ // @ts-expect-error — a title belongs to one dialog
65
+ FormDialogProvider({ children: null, title: "Person" })
66
+ })
@@ -0,0 +1,165 @@
1
+ import { cleanup, render, screen } from "@zeno-lib/test/testing-library"
2
+ import userEvent from "@zeno-lib/test/user-event"
3
+ import { afterEach, describe, expect, test, vi } from "vitest"
4
+ import { createZenoForm } from "./create-zeno-form"
5
+ import { FormError } from "./fields/form-error"
6
+ import { InputField } from "./fields/input-field"
7
+ import { MoneyField } from "./fields/money-field"
8
+ import { SubmitButton } from "./fields/submit-button"
9
+ import { FormDialog } from "./form-dialog"
10
+ import { Form, FormProvider } from "./form-element"
11
+ import { useFormDialog } from "./lib/use-form-dialog"
12
+
13
+ afterEach(() => {
14
+ cleanup()
15
+ vi.unstubAllGlobals()
16
+ })
17
+
18
+ const { useForm } = createZenoForm({
19
+ defaultProps: {
20
+ FormError: { submitErrorMessage: "Not saved. Try again later." },
21
+ MoneyField: { currency: "CHF", currencyDisplay: "code", placeholder: "0" },
22
+ },
23
+ fieldComponents: { InputField, MoneyField },
24
+ formComponents: { FormError, SubmitButton },
25
+ })
26
+
27
+ const VALUES = {
28
+ amount: null as number | null,
29
+ fee: null as number | null,
30
+ name: "",
31
+ total: null as number | null,
32
+ }
33
+
34
+ function Harness({ onSubmit = vi.fn() }: { onSubmit?: () => unknown }) {
35
+ const form = useForm({ defaultValues: VALUES, onSubmit })
36
+ const unset: string | undefined = undefined
37
+ return (
38
+ <FormProvider form={form}>
39
+ <Form>
40
+ <form.MoneyField label="Amount" name="amount" />
41
+ <form.MoneyField currency="EUR" label="Fee" name="fee" />
42
+ <form.MoneyField currency={unset} label="Total" name="total" />
43
+ <form.InputField label="Name" name="name" />
44
+ <form.FormError />
45
+ <form.SubmitButton>Submit</form.SubmitButton>
46
+ </Form>
47
+ </FormProvider>
48
+ )
49
+ }
50
+
51
+ function addonOf(label: string) {
52
+ return screen
53
+ .getByLabelText(label)
54
+ .closest("[data-slot=input-group]")
55
+ ?.querySelector("[data-slot=input-group-addon]")?.textContent
56
+ }
57
+
58
+ describe("createZenoForm({ defaultProps })", () => {
59
+ test("a field renders with its defaults, and a prop passed at the call site wins", () => {
60
+ render(<Harness />)
61
+ expect(addonOf("Amount")).toBe("CHF")
62
+ expect(addonOf("Fee")).toBe("EUR")
63
+ })
64
+
65
+ test("a prop passed as undefined keeps the default", () => {
66
+ render(<Harness />)
67
+ expect(addonOf("Total")).toBe("CHF")
68
+ })
69
+
70
+ test("a component with no defaults renders as before", () => {
71
+ render(<Harness />)
72
+ expect(screen.getByLabelText("Amount").getAttribute("placeholder")).toBe(
73
+ "0"
74
+ )
75
+ // InputField shares MoneyField's props, but not its defaults.
76
+ expect(screen.getByLabelText("Name").hasAttribute("placeholder")).toBe(
77
+ false
78
+ )
79
+ })
80
+
81
+ test("a form component renders with its defaults", async () => {
82
+ vi.stubGlobal("reportError", vi.fn())
83
+ const user = userEvent.setup()
84
+ render(
85
+ <Harness
86
+ onSubmit={() => {
87
+ throw new Error("offline")
88
+ }}
89
+ />
90
+ )
91
+ await user.click(screen.getByRole("button", { name: "Submit" }))
92
+ expect((await screen.findByRole("alert")).textContent).toBe(
93
+ "Not saved. Try again later."
94
+ )
95
+ })
96
+
97
+ test("a FormDialog renders the form's FormError, with its defaults", async () => {
98
+ vi.stubGlobal("reportError", vi.fn())
99
+ const user = userEvent.setup()
100
+ function DialogHarness() {
101
+ const dialog = useFormDialog({ defaultValues: VALUES })
102
+ const form = useForm({
103
+ defaultValues: dialog.defaultValues,
104
+ onSubmit: () => {
105
+ throw new Error("offline")
106
+ },
107
+ })
108
+ return (
109
+ <>
110
+ <button onClick={() => dialog.open()} type="button">
111
+ New
112
+ </button>
113
+ <FormDialog dialog={dialog} form={form} title="Payment">
114
+ <form.InputField label="Name" name="name" />
115
+ </FormDialog>
116
+ </>
117
+ )
118
+ }
119
+ render(<DialogHarness />)
120
+ await user.click(screen.getByRole("button", { name: "New" }))
121
+ await user.click(await screen.findByRole("button", { name: "Save" }))
122
+ expect((await screen.findByRole("alert")).textContent).toBe(
123
+ "Not saved. Try again later."
124
+ )
125
+ })
126
+
127
+ test("a component with defaults keeps its statics", () => {
128
+ function Hint({ text = "None" }: { text?: string }) {
129
+ return <p>{text}</p>
130
+ }
131
+ Hint.Icon = () => <span>i</span>
132
+ const { useForm: useHintForm } = createZenoForm({
133
+ defaultProps: { Hint: { text: "Marked fields are required" } },
134
+ fieldComponents: { InputField },
135
+ formComponents: { Hint },
136
+ })
137
+ function HintHarness() {
138
+ const form = useHintForm({ defaultValues: {}, onSubmit: vi.fn() })
139
+ return (
140
+ <FormProvider form={form}>
141
+ <form.Hint />
142
+ <form.Hint.Icon />
143
+ </FormProvider>
144
+ )
145
+ }
146
+ render(<HintHarness />)
147
+ screen.getByText("Marked fields are required")
148
+ screen.getByText("i")
149
+ })
150
+
151
+ test("a field rendered through AppField gets its defaults too", () => {
152
+ function AppFieldHarness() {
153
+ const form = useForm({ defaultValues: VALUES, onSubmit: vi.fn() })
154
+ return (
155
+ <FormProvider form={form}>
156
+ <form.AppField name="amount">
157
+ {(field) => <field.MoneyField label="Amount" />}
158
+ </form.AppField>
159
+ </FormProvider>
160
+ )
161
+ }
162
+ render(<AppFieldHarness />)
163
+ expect(addonOf("Amount")).toBe("CHF")
164
+ })
165
+ })