@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.
- package/dist/create-zeno-form.d.mts +19 -4
- package/dist/create-zeno-form.mjs +29 -6
- package/dist/form-element.d.mts +16 -3
- package/dist/form-element.mjs +38 -18
- package/dist/index.d.mts +2 -1
- package/dist/index.mjs +2 -1
- package/dist/lib/date-value.d.mts +24 -0
- package/dist/lib/date-value.mjs +37 -0
- package/dist/lib/default-props.d.mts +7 -0
- package/dist/lib/default-props.mjs +13 -0
- package/dist/lib/empty-value.mjs +25 -1
- package/dist/lib/form-disabled.d.mts +28 -0
- package/dist/lib/form-disabled.mjs +54 -0
- package/dist/lib/item-options.d.mts +45 -0
- package/dist/lib/item-options.mjs +70 -0
- package/dist/lib/schema-required.mjs +17 -0
- package/dist/lib/use-field-control.d.mts +7 -0
- package/dist/lib/use-field-control.mjs +4 -1
- package/package.json +1 -1
- package/src/create-form.tsx +34 -29
- package/src/create-zeno-form.tsx +124 -51
- package/src/date-picker-field.test.tsx +108 -0
- package/src/default-props.test-d.ts +66 -0
- package/src/default-props.test.tsx +165 -0
- package/src/fields/checkbox-field.tsx +5 -0
- package/src/fields/checkbox-group-field.tsx +6 -2
- package/src/fields/combobox-field.tsx +107 -40
- package/src/fields/date-picker-field.tsx +34 -11
- package/src/fields/field-shell.tsx +8 -1
- package/src/fields/input-field.tsx +2 -0
- package/src/fields/multi-select-field.tsx +100 -42
- package/src/fields/otp-field.tsx +8 -2
- package/src/fields/radio-group-field.tsx +9 -1
- package/src/fields/reset-button.tsx +4 -0
- package/src/fields/select-field.tsx +9 -1
- package/src/fields/slider-field.tsx +9 -1
- package/src/fields/submit-button.tsx +3 -1
- package/src/fields/switch-field.tsx +5 -0
- package/src/fields/textarea-field.tsx +2 -0
- package/src/form-dialog.test.tsx +102 -1
- package/src/form-dialog.tsx +143 -42
- package/src/form-disabled.test.tsx +329 -0
- package/src/form-element.tsx +64 -29
- package/src/grouped-options.test-d.ts +59 -0
- package/src/grouped-options.test.tsx +251 -0
- package/src/index.ts +1 -0
- package/src/lib/date-value.test.ts +14 -0
- package/src/lib/date-value.ts +70 -0
- package/src/lib/default-props.ts +19 -0
- package/src/lib/empty-value.test.ts +19 -0
- package/src/lib/empty-value.ts +45 -1
- package/src/lib/form-disabled.ts +95 -0
- package/src/lib/item-options.test.ts +11 -0
- package/src/lib/item-options.ts +165 -0
- package/src/lib/schema-required.test.ts +9 -0
- package/src/lib/schema-required.ts +33 -0
- package/src/lib/use-field-control.ts +11 -1
- package/src/with-form.test-d.ts +76 -0
- 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
package/src/create-form.tsx
CHANGED
|
@@ -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 } =
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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 }
|
package/src/create-zeno-form.tsx
CHANGED
|
@@ -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
|
|
96
|
-
TOnChange extends
|
|
97
|
-
TOnChangeAsync extends
|
|
98
|
-
TOnBlur extends
|
|
99
|
-
TOnBlurAsync extends
|
|
100
|
-
TOnSubmit extends
|
|
101
|
-
TOnSubmitAsync extends
|
|
102
|
-
TOnDynamic extends
|
|
103
|
-
TOnDynamicAsync extends
|
|
104
|
-
TOnServer extends
|
|
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
|
|
129
|
-
TOnChange extends
|
|
130
|
-
TOnChangeAsync extends
|
|
131
|
-
TOnBlur extends
|
|
132
|
-
TOnBlurAsync extends
|
|
133
|
-
TOnSubmit extends
|
|
134
|
-
TOnSubmitAsync extends
|
|
135
|
-
TOnDynamic extends
|
|
136
|
-
TOnDynamicAsync extends
|
|
137
|
-
TOnServer extends
|
|
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
|
|
162
|
-
TOnChange extends
|
|
163
|
-
TOnChangeAsync extends
|
|
164
|
-
TOnBlur extends
|
|
165
|
-
TOnBlurAsync extends
|
|
166
|
-
TOnSubmit extends
|
|
167
|
-
TOnSubmitAsync extends
|
|
168
|
-
TOnDynamic extends
|
|
169
|
-
TOnDynamicAsync extends
|
|
170
|
-
TOnServer extends
|
|
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
|
|
394
|
+
fieldComponents,
|
|
323
395
|
fieldContext,
|
|
324
|
-
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(
|
|
339
|
-
const Impl =
|
|
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
|
|
362
|
-
TOnChange extends
|
|
436
|
+
TOnMount extends SyncValidator<TFormData> = SyncValidator<TFormData>,
|
|
437
|
+
TOnChange extends SyncValidator<TFormData> = SyncValidator<TFormData>,
|
|
363
438
|
TOnChangeAsync extends
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
TOnDynamic extends undefined | FormValidateOrFn<TFormData> = undefined,
|
|
444
|
+
AsyncValidator<TFormData> = AsyncValidator<TFormData>,
|
|
445
|
+
TOnDynamic extends SyncValidator<TFormData> = SyncValidator<TFormData>,
|
|
375
446
|
TOnDynamicAsync extends
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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,
|
|
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
|
+
})
|