@zeno-lib/forms 0.6.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 (59) hide show
  1. package/dist/create-zeno-form.d.mts +19 -4
  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 +2 -1
  6. package/dist/index.mjs +2 -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/use-field-control.d.mts +7 -0
  18. package/dist/lib/use-field-control.mjs +4 -1
  19. package/package.json +1 -1
  20. package/src/create-form.tsx +34 -29
  21. package/src/create-zeno-form.tsx +124 -51
  22. package/src/date-picker-field.test.tsx +108 -0
  23. package/src/default-props.test-d.ts +66 -0
  24. package/src/default-props.test.tsx +165 -0
  25. package/src/fields/checkbox-field.tsx +5 -0
  26. package/src/fields/checkbox-group-field.tsx +6 -2
  27. package/src/fields/combobox-field.tsx +107 -40
  28. package/src/fields/date-picker-field.tsx +34 -11
  29. package/src/fields/field-shell.tsx +8 -1
  30. package/src/fields/input-field.tsx +2 -0
  31. package/src/fields/multi-select-field.tsx +100 -42
  32. package/src/fields/otp-field.tsx +8 -2
  33. package/src/fields/radio-group-field.tsx +9 -1
  34. package/src/fields/reset-button.tsx +4 -0
  35. package/src/fields/select-field.tsx +9 -1
  36. package/src/fields/slider-field.tsx +9 -1
  37. package/src/fields/submit-button.tsx +3 -1
  38. package/src/fields/switch-field.tsx +5 -0
  39. package/src/fields/textarea-field.tsx +2 -0
  40. package/src/form-dialog.test.tsx +102 -1
  41. package/src/form-dialog.tsx +143 -42
  42. package/src/form-disabled.test.tsx +329 -0
  43. package/src/form-element.tsx +64 -29
  44. package/src/grouped-options.test-d.ts +59 -0
  45. package/src/grouped-options.test.tsx +251 -0
  46. package/src/index.ts +1 -0
  47. package/src/lib/date-value.test.ts +14 -0
  48. package/src/lib/date-value.ts +70 -0
  49. package/src/lib/default-props.ts +19 -0
  50. package/src/lib/empty-value.test.ts +19 -0
  51. package/src/lib/empty-value.ts +45 -1
  52. package/src/lib/form-disabled.ts +95 -0
  53. package/src/lib/item-options.test.ts +11 -0
  54. package/src/lib/item-options.ts +165 -0
  55. package/src/lib/schema-required.test.ts +9 -0
  56. package/src/lib/schema-required.ts +33 -0
  57. package/src/lib/use-field-control.ts +11 -1
  58. package/src/with-form.test-d.ts +76 -0
  59. package/src/with-form.test.tsx +60 -0
@@ -7,6 +7,10 @@ type UseFieldControlOptions = {
7
7
  * it, and by the error while one shows.
8
8
  */
9
9
  description?: ReactNode;
10
+ /**
11
+ * Disable the control. A disabled form (`useForm({ disabled })`,
12
+ * `<Form disabled>`, `FormDialog`'s `disabled`) disables it either way.
13
+ */
10
14
  disabled?: boolean;
11
15
  /**
12
16
  * The label the field renders, if any. The control gets `aria-labelledby`,
@@ -26,6 +30,7 @@ type FieldControlProps<TValue = unknown> = {
26
30
  "aria-invalid": true | undefined;
27
31
  "aria-labelledby": string | undefined;
28
32
  "aria-required": true | undefined;
33
+ /** The field's own `disabled`, or the form's. */
29
34
  disabled: boolean | undefined;
30
35
  id: string;
31
36
  name: string;
@@ -56,6 +61,8 @@ type FieldControl<TValue = unknown> = {
56
61
  errors: AnyFieldMeta["errors"];
57
62
  /** The control's id, for the label's `htmlFor`. */
58
63
  id: string;
64
+ /** The field is disabled, by its own `disabled` or the form's. */
65
+ isDisabled: boolean;
59
66
  isInvalid: boolean;
60
67
  /** The field requires a value, by the schema or `required`. */
61
68
  isRequired: boolean;
@@ -2,6 +2,7 @@
2
2
  import { useFieldContext } from "./contexts.mjs";
3
3
  import { getFormSchema } from "./validation-modes.mjs";
4
4
  import { emptyValueFor } from "./empty-value.mjs";
5
+ import { useIsFormDisabled } from "./form-disabled.mjs";
5
6
  import { describedBy } from "./aria.mjs";
6
7
  import { useHideFieldErrors, useIsFieldRequired, useIsFieldRequiredBySchema, useIsInvalid } from "./use-is-invalid.mjs";
7
8
  import { useCallback, useId } from "react";
@@ -30,6 +31,7 @@ function useFieldControl({ description, disabled, label, required } = {}) {
30
31
  const schemaRequired = useIsFieldRequired(field);
31
32
  const schemaRequiresValue = useIsFieldRequiredBySchema(field);
32
33
  const isRequired = required ?? schemaRequiresValue;
34
+ const isDisabled = useIsFormDisabled(field.form) || Boolean(disabled);
33
35
  const { form, handleBlur, handleChange, name } = field;
34
36
  const onChange = useCallback((value) => {
35
37
  if (isSyntheticEvent(value)) throw new TypeError(`Field "${name}" got a change event, not a value. Map it in the control: onChange(event.target.value).`);
@@ -45,7 +47,7 @@ function useFieldControl({ description, disabled, label, required } = {}) {
45
47
  "aria-invalid": isInvalid || void 0,
46
48
  "aria-labelledby": labelId,
47
49
  "aria-required": isRequired || void 0,
48
- disabled,
50
+ disabled: isDisabled,
49
51
  id,
50
52
  name,
51
53
  onBlur: handleBlur,
@@ -56,6 +58,7 @@ function useFieldControl({ description, disabled, label, required } = {}) {
56
58
  errorId,
57
59
  errors: showError ? field.state.meta.errors : [],
58
60
  id,
61
+ isDisabled,
59
62
  isInvalid,
60
63
  isRequired,
61
64
  labelId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeno-lib/forms",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "repository": {
5
5
  "url": "https://github.com/zeno-lib/zeno.git",
6
6
  "directory": "packages/forms"
@@ -28,37 +28,42 @@ import { YearField } from "./fields/year-field"
28
28
 
29
29
  // Wire the dropped-in field components into the headless factory. Edit the
30
30
  // field files under `./fields` freely — they use your own shadcn primitives.
31
- const { useAppForm, useForm, withFieldGroup, withForm } = createZenoForm({
32
- fieldComponents: {
33
- CheckboxField,
34
- CheckboxGroupField,
35
- ComboboxField,
36
- ControlField,
37
- DatePickerField,
38
- EmailField,
39
- InputField,
40
- MoneyField,
41
- MultiSelectField,
42
- NumberField,
43
- OtpField,
44
- PasswordField,
45
- PercentageField,
46
- RadioGroupField,
47
- SelectField,
48
- SliderField,
49
- SwitchField,
50
- TextAreaField,
51
- YearField,
52
- },
53
- formComponents: {
54
- FormError,
55
- ResetButton,
56
- SubmitButton,
57
- },
58
- })
31
+ const { useAppFields, useAppForm, useForm, withFieldGroup, withForm } =
32
+ createZenoForm({
33
+ // Your app's default props per component, e.g.
34
+ // `MoneyField: { currency: "CHF", locale: "de-CH" }`. A prop passed at the
35
+ // call site wins.
36
+ defaultProps: {},
37
+ fieldComponents: {
38
+ CheckboxField,
39
+ CheckboxGroupField,
40
+ ComboboxField,
41
+ ControlField,
42
+ DatePickerField,
43
+ EmailField,
44
+ InputField,
45
+ MoneyField,
46
+ MultiSelectField,
47
+ NumberField,
48
+ OtpField,
49
+ PasswordField,
50
+ PercentageField,
51
+ RadioGroupField,
52
+ SelectField,
53
+ SliderField,
54
+ SwitchField,
55
+ TextAreaField,
56
+ YearField,
57
+ },
58
+ formComponents: {
59
+ FormError,
60
+ ResetButton,
61
+ SubmitButton,
62
+ },
63
+ })
59
64
 
60
65
  export * from "@zeno-lib/forms"
61
66
  export { ValidationSpinner } from "./addons/validation-spinner"
62
67
  export { FieldShell } from "./fields/field-shell"
63
68
  export { RadioGroupFieldItem } from "./fields/radio-group-field"
64
- export { useAppForm, useForm, withFieldGroup, withForm }
69
+ export { useAppFields, useAppForm, useForm, withFieldGroup, withForm }
@@ -13,7 +13,9 @@ import { createFormHook } from "@tanstack/react-form"
13
13
  import { type ComponentProps, type ReactNode, useMemo } from "react"
14
14
  import { applyValidationError } from "./lib/apply-validation-error"
15
15
  import { fieldContext, formContext } from "./lib/contexts"
16
+ import { withDefaultProps } from "./lib/default-props"
16
17
  import { withEmptyStartValues } from "./lib/empty-value"
18
+ import { useDisabledOption } from "./lib/form-disabled"
17
19
  import { deepMergeDefaults, extractZodDefaults } from "./lib/schema-defaults"
18
20
  import {
19
21
  getRequiredPaths,
@@ -54,6 +56,11 @@ type PartialFormData<T> = T extends readonly unknown[]
54
56
  : T
55
57
 
56
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
57
64
  /**
58
65
  * If `true`, shipped fields skip rendering their inline `<FieldError>`
59
66
  * message. Fields still flip `data-invalid` / `aria-invalid`.
@@ -90,18 +97,21 @@ type ManualPathExtras = {
90
97
  schema?: never
91
98
  }
92
99
 
100
+ type SyncValidator<TFormData> = undefined | FormValidateOrFn<TFormData>
101
+ type AsyncValidator<TFormData> = undefined | FormAsyncValidateOrFn<TFormData>
102
+
93
103
  type SchemaFormOptions<
94
104
  TFormData,
95
- TOnMount extends undefined | FormValidateOrFn<TFormData>,
96
- TOnChange extends undefined | FormValidateOrFn<TFormData>,
97
- TOnChangeAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
98
- TOnBlur extends undefined | FormValidateOrFn<TFormData>,
99
- TOnBlurAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
100
- TOnSubmit extends undefined | FormValidateOrFn<TFormData>,
101
- TOnSubmitAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
102
- TOnDynamic extends undefined | FormValidateOrFn<TFormData>,
103
- TOnDynamicAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
104
- 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>,
105
115
  TSubmitMeta,
106
116
  > = Omit<
107
117
  FormOptions<
@@ -125,16 +135,16 @@ type SchemaFormOptions<
125
135
 
126
136
  type ManualFormOptions<
127
137
  TFormData,
128
- TOnMount extends undefined | FormValidateOrFn<TFormData>,
129
- TOnChange extends undefined | FormValidateOrFn<TFormData>,
130
- TOnChangeAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
131
- TOnBlur extends undefined | FormValidateOrFn<TFormData>,
132
- TOnBlurAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
133
- TOnSubmit extends undefined | FormValidateOrFn<TFormData>,
134
- TOnSubmitAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
135
- TOnDynamic extends undefined | FormValidateOrFn<TFormData>,
136
- TOnDynamicAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
137
- 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>,
138
148
  TSubmitMeta,
139
149
  > = Omit<
140
150
  FormOptions<
@@ -158,16 +168,16 @@ type ManualFormOptions<
158
168
 
159
169
  type UseFormOptions<
160
170
  TFormData,
161
- TOnMount extends undefined | FormValidateOrFn<TFormData>,
162
- TOnChange extends undefined | FormValidateOrFn<TFormData>,
163
- TOnChangeAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
164
- TOnBlur extends undefined | FormValidateOrFn<TFormData>,
165
- TOnBlurAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
166
- TOnSubmit extends undefined | FormValidateOrFn<TFormData>,
167
- TOnSubmitAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
168
- TOnDynamic extends undefined | FormValidateOrFn<TFormData>,
169
- TOnDynamicAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
170
- 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>,
171
181
  TSubmitMeta,
172
182
  > =
173
183
  | SchemaFormOptions<
@@ -298,12 +308,66 @@ type FieldWrappers<FC extends Record<string, AnyComponent>, T> = {
298
308
  ) => ReactNode
299
309
  }
300
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
+
301
326
  type CreateZenoFormConfig<
302
327
  FC extends Record<string, AnyComponent>,
303
328
  FMC extends Record<string, AnyComponent>,
304
329
  > = {
305
330
  fieldComponents: FC
306
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
307
371
  }
308
372
 
309
373
  /**
@@ -318,10 +382,18 @@ export function createZenoForm<
318
382
  FC extends Record<string, AnyComponent>,
319
383
  FMC extends Record<string, AnyComponent>,
320
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
+ )
321
393
  const { useAppForm, withFieldGroup, withForm } = createFormHook({
322
- fieldComponents: config.fieldComponents,
394
+ fieldComponents,
323
395
  fieldContext,
324
- formComponents: config.formComponents,
396
+ formComponents,
325
397
  formContext,
326
398
  })
327
399
 
@@ -335,8 +407,8 @@ export function createZenoForm<
335
407
  children: () => ReactNode
336
408
  }>
337
409
  const wrappers: Record<string, AnyComponent> = {}
338
- for (const key of Object.keys(config.fieldComponents)) {
339
- const Impl = config.fieldComponents[key] as AnyComponent
410
+ for (const key of Object.keys(fieldComponents)) {
411
+ const Impl = fieldComponents[key] as AnyComponent
340
412
  wrappers[key] = ({
341
413
  name,
342
414
  validators,
@@ -356,27 +428,25 @@ export function createZenoForm<
356
428
  }, [form])
357
429
  }
358
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`).
359
434
  function useForm<
360
435
  TFormData,
361
- TOnMount extends undefined | FormValidateOrFn<TFormData> = undefined,
362
- TOnChange extends undefined | FormValidateOrFn<TFormData> = undefined,
436
+ TOnMount extends SyncValidator<TFormData> = SyncValidator<TFormData>,
437
+ TOnChange extends SyncValidator<TFormData> = SyncValidator<TFormData>,
363
438
  TOnChangeAsync extends
364
- | undefined
365
- | FormAsyncValidateOrFn<TFormData> = undefined,
366
- TOnBlur extends undefined | FormValidateOrFn<TFormData> = undefined,
367
- TOnBlurAsync extends
368
- | undefined
369
- | FormAsyncValidateOrFn<TFormData> = undefined,
370
- 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>,
371
443
  TOnSubmitAsync extends
372
- | undefined
373
- | FormAsyncValidateOrFn<TFormData> = undefined,
374
- TOnDynamic extends undefined | FormValidateOrFn<TFormData> = undefined,
444
+ AsyncValidator<TFormData> = AsyncValidator<TFormData>,
445
+ TOnDynamic extends SyncValidator<TFormData> = SyncValidator<TFormData>,
375
446
  TOnDynamicAsync extends
376
- | undefined
377
- | FormAsyncValidateOrFn<TFormData> = undefined,
378
- TOnServer extends undefined | FormAsyncValidateOrFn<TFormData> = undefined,
379
- TSubmitMeta = never,
447
+ AsyncValidator<TFormData> = AsyncValidator<TFormData>,
448
+ TOnServer extends AsyncValidator<TFormData> = AsyncValidator<TFormData>,
449
+ TSubmitMeta = unknown,
380
450
  >(
381
451
  options: UseFormOptions<
382
452
  TFormData,
@@ -414,6 +484,7 @@ export function createZenoForm<
414
484
  schema,
415
485
  validators: validatorsInput,
416
486
  validationLogic: userValidationLogic,
487
+ disabled = false,
417
488
  hideFieldErrors = false,
418
489
  requiredIndicator = true,
419
490
  unsavedChangesWarning = false,
@@ -425,6 +496,7 @@ export function createZenoForm<
425
496
  validators?: ValidationMode | NativeValidators
426
497
  validationLogic?: NativeValidationLogic
427
498
  defaultValues?: PartialFormData<TFormData>
499
+ disabled?: boolean
428
500
  hideFieldErrors?: boolean
429
501
  requiredIndicator?: boolean
430
502
  unsavedChangesWarning?: boolean | "if-changed" | "if-touched"
@@ -557,11 +629,12 @@ export function createZenoForm<
557
629
  ...(schemaMode === undefined ? {} : { validation: schemaMode }),
558
630
  })
559
631
 
632
+ useDisabledOption(form, disabled)
560
633
  useUnsavedChangesWarning(form, unsavedChangesWarning)
561
634
 
562
635
  const fields = useAppFields(form)
563
636
  return useMemo(
564
- () => Object.assign(form, fields, config.formComponents),
637
+ () => Object.assign(form, fields, formComponents),
565
638
  [form, fields]
566
639
  )
567
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
+ })