@zeno-lib/forms 0.5.0 → 0.6.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 +8 -2
- package/dist/index.d.mts +2 -1
- package/dist/index.mjs +2 -1
- package/dist/lib/submit-action.d.mts +8 -2
- package/dist/lib/submit-action.mjs +4 -2
- package/dist/lib/use-field-control.d.mts +76 -0
- package/dist/lib/use-field-control.mjs +67 -0
- package/package.json +1 -1
- package/src/control-field.test.tsx +338 -0
- package/src/create-form.tsx +3 -0
- package/src/create-zeno-form.tsx +20 -1
- package/src/fields/control-field.tsx +57 -0
- package/src/fields/field-shell.tsx +73 -0
- package/src/fields/index.ts +2 -0
- package/src/fields/input-field.tsx +14 -48
- package/src/fields/textarea-field.tsx +14 -48
- package/src/index.ts +6 -0
- package/src/lib/submit-action.ts +18 -4
- package/src/lib/use-field-control.ts +181 -0
- package/src/submit-action.test.tsx +103 -0
- package/src/use-app-fields.test-d.ts +51 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { FieldControlProps } from "./lib/use-field-control.mjs";
|
|
1
2
|
import { ValidationMode } from "./lib/validation-modes.mjs";
|
|
2
|
-
import { DeepKeys, FormAsyncValidateOrFn, FormOptions, FormValidateOrFn } from "@tanstack/react-form";
|
|
3
|
+
import { DeepKeys, DeepValue, FormAsyncValidateOrFn, FormOptions, FormValidateOrFn } from "@tanstack/react-form";
|
|
3
4
|
import { ComponentProps, ReactNode } from "react";
|
|
4
5
|
//#region src/create-zeno-form.d.ts
|
|
5
6
|
type StandardSchema<T> = {
|
|
@@ -84,7 +85,12 @@ type WithValidators = {
|
|
|
84
85
|
listeners?: AnyFieldListeners;
|
|
85
86
|
};
|
|
86
87
|
type AnyComponent = React.ComponentType<any>;
|
|
87
|
-
type
|
|
88
|
+
type WithControlValue<P, V> = P extends {
|
|
89
|
+
render: (control: infer C) => ReactNode;
|
|
90
|
+
} ? [C] extends [FieldControlProps<any>] ? Omit<P, "render"> & {
|
|
91
|
+
render: (control: FieldControlProps<V>) => ReactNode;
|
|
92
|
+
} : P : P;
|
|
93
|
+
type FieldWrappers<FC extends Record<string, AnyComponent>, T> = { [K in keyof FC]: <N extends DeepKeys<T>>(props: WithControlValue<ComponentProps<FC[K]>, DeepValue<T, N>> & {
|
|
88
94
|
name: N;
|
|
89
95
|
} & WithValidators) => ReactNode; };
|
|
90
96
|
type CreateZenoFormConfig<FC extends Record<string, AnyComponent>, FMC extends Record<string, AnyComponent>> = {
|
package/dist/index.d.mts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { FieldControl, FieldControlProps, UseFieldControlOptions, useFieldControl } from "./lib/use-field-control.mjs";
|
|
1
2
|
import { ValidationMode } from "./lib/validation-modes.mjs";
|
|
2
3
|
import { createZenoForm } from "./create-zeno-form.mjs";
|
|
3
4
|
import { Form, FormProvider } from "./form-element.mjs";
|
|
@@ -9,4 +10,4 @@ import { SubmitActionOptions, SubmitActionSchema, applyActionError, submitAction
|
|
|
9
10
|
import { useHideFieldErrors, useIsFieldRequired, useIsFieldRequiredBySchema, useIsInvalid } from "./lib/use-is-invalid.mjs";
|
|
10
11
|
import { blurThenChangeLogic } from "./lib/validation-logic.mjs";
|
|
11
12
|
import { useSelector } from "@tanstack/react-form";
|
|
12
|
-
export { type ActionError, type ActionIssue, type ActionResult, type FieldMessage, Form, FormProvider, type SubmitActionOptions, type SubmitActionSchema, ValidationError, type ValidationMode, applyActionError, applyValidationError, blurThenChangeLogic, createZenoForm, submitAction, toActionError, toFieldName, useFieldContext, useFormContext, useHideFieldErrors, useIsFieldRequired, useIsFieldRequiredBySchema, useIsInvalid, useSelector };
|
|
13
|
+
export { type ActionError, type ActionIssue, type ActionResult, type FieldControl, type FieldControlProps, type FieldMessage, Form, FormProvider, type SubmitActionOptions, type SubmitActionSchema, type UseFieldControlOptions, ValidationError, type ValidationMode, applyActionError, applyValidationError, blurThenChangeLogic, createZenoForm, submitAction, toActionError, toFieldName, useFieldContext, useFieldControl, useFormContext, useHideFieldErrors, useIsFieldRequired, useIsFieldRequiredBySchema, useIsInvalid, useSelector };
|
package/dist/index.mjs
CHANGED
|
@@ -7,5 +7,6 @@ import { createZenoForm } from "./create-zeno-form.mjs";
|
|
|
7
7
|
import { Form, FormProvider } from "./form-element.mjs";
|
|
8
8
|
import { applyActionError, submitAction } from "./lib/submit-action.mjs";
|
|
9
9
|
import { useHideFieldErrors, useIsFieldRequired, useIsFieldRequiredBySchema, useIsInvalid } from "./lib/use-is-invalid.mjs";
|
|
10
|
+
import { useFieldControl } from "./lib/use-field-control.mjs";
|
|
10
11
|
import { useSelector } from "@tanstack/react-form";
|
|
11
|
-
export { Form, FormProvider, ValidationError, applyActionError, applyValidationError, blurThenChangeLogic, createZenoForm, submitAction, toActionError, toFieldName, useFieldContext, useFormContext, useHideFieldErrors, useIsFieldRequired, useIsFieldRequiredBySchema, useIsInvalid, useSelector };
|
|
12
|
+
export { Form, FormProvider, ValidationError, applyActionError, applyValidationError, blurThenChangeLogic, createZenoForm, submitAction, toActionError, toFieldName, useFieldContext, useFieldControl, useFormContext, useHideFieldErrors, useIsFieldRequired, useIsFieldRequiredBySchema, useIsInvalid, useSelector };
|
|
@@ -40,7 +40,9 @@ type SubmitActionSchemaOptions<TValues, TOutput, TData> = SubmitActionOptions<TV
|
|
|
40
40
|
/**
|
|
41
41
|
* Parsed on the client before the call: `onSubmit` receives the form's
|
|
42
42
|
* input values, and the action gets the schema's output. A failure is
|
|
43
|
-
* applied to the form and the action is never called.
|
|
43
|
+
* applied to the form and the action is never called. Replaces the form's
|
|
44
|
+
* own `schema` for this call; pass one only when the action takes a
|
|
45
|
+
* different schema than the form.
|
|
44
46
|
*/
|
|
45
47
|
schema: SubmitActionSchema<TOutput>;
|
|
46
48
|
};
|
|
@@ -49,9 +51,13 @@ type SubmitActionSchemaOptions<TValues, TOutput, TData> = SubmitActionOptions<TV
|
|
|
49
51
|
* form. Pass `onSubmit`'s own argument straight through:
|
|
50
52
|
*
|
|
51
53
|
* ```ts
|
|
52
|
-
* onSubmit: (submit) => submitAction(submit, saveProfile, { reset: true
|
|
54
|
+
* onSubmit: (submit) => submitAction(submit, saveProfile, { reset: true })
|
|
53
55
|
* ```
|
|
54
56
|
*
|
|
57
|
+
* The values are parsed with `options.schema`, else with the form's own
|
|
58
|
+
* `useForm({ schema })`, and the action gets the schema's output. A form with
|
|
59
|
+
* no schema (`useForm` without one, `useAppForm`) sends its values as they are.
|
|
60
|
+
*
|
|
55
61
|
* On `{ ok: false }` the field errors land on their fields (array paths like
|
|
56
62
|
* `owners[0].percentage` included) and clear as each field is edited. On
|
|
57
63
|
* `{ ok: true }` the form is optionally reset. Either way the result is
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { applyValidationError } from "./apply-validation-error.mjs";
|
|
2
2
|
import { toActionError } from "./action-result.mjs";
|
|
3
|
+
import { getFormSchema } from "./validation-modes.mjs";
|
|
3
4
|
import { ValidationError } from "./validation-error.mjs";
|
|
4
5
|
//#region src/lib/submit-action.ts
|
|
5
6
|
function applyActionError(formApi, error) {
|
|
@@ -15,9 +16,10 @@ function applyActionError(formApi, error) {
|
|
|
15
16
|
}
|
|
16
17
|
async function submitAction(props, action, options = {}) {
|
|
17
18
|
const { formApi, value } = props;
|
|
19
|
+
const schema = options.schema ?? getFormSchema(formApi);
|
|
18
20
|
let input = value;
|
|
19
|
-
if (
|
|
20
|
-
const parsed = await
|
|
21
|
+
if (schema) {
|
|
22
|
+
const parsed = await schema["~standard"].validate(value);
|
|
21
23
|
if (parsed.issues) {
|
|
22
24
|
const error = toActionError(parsed.issues);
|
|
23
25
|
applyActionError(formApi, error);
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { AnyFieldMeta } from "@tanstack/react-form";
|
|
2
|
+
import { ReactNode } from "react";
|
|
3
|
+
//#region src/lib/use-field-control.d.ts
|
|
4
|
+
type UseFieldControlOptions = {
|
|
5
|
+
/**
|
|
6
|
+
* The description the field renders, if any. The control is described by
|
|
7
|
+
* it, and by the error while one shows.
|
|
8
|
+
*/
|
|
9
|
+
description?: ReactNode;
|
|
10
|
+
disabled?: boolean;
|
|
11
|
+
/**
|
|
12
|
+
* The label the field renders, if any. The control gets `aria-labelledby`,
|
|
13
|
+
* which names a control a `<label htmlFor>` can't, such as a
|
|
14
|
+
* `role="radiogroup"`.
|
|
15
|
+
*/
|
|
16
|
+
label?: ReactNode;
|
|
17
|
+
/**
|
|
18
|
+
* Mark the field required, or not, over the schema. Drives the `*` and
|
|
19
|
+
* `aria-required`.
|
|
20
|
+
*/
|
|
21
|
+
required?: boolean;
|
|
22
|
+
};
|
|
23
|
+
/** The props that bind a control to its field. Spread them on the control. */
|
|
24
|
+
type FieldControlProps<TValue = unknown> = {
|
|
25
|
+
"aria-describedby": string | undefined;
|
|
26
|
+
"aria-invalid": true | undefined;
|
|
27
|
+
"aria-labelledby": string | undefined;
|
|
28
|
+
"aria-required": true | undefined;
|
|
29
|
+
disabled: boolean | undefined;
|
|
30
|
+
id: string;
|
|
31
|
+
name: string;
|
|
32
|
+
/** Marks the field touched. */
|
|
33
|
+
onBlur: () => void;
|
|
34
|
+
/**
|
|
35
|
+
* Stores `value`, not a change event: a control that reports one maps it
|
|
36
|
+
* (`onChange(event.target.value)`). An empty value (`null`, `undefined`,
|
|
37
|
+
* `""`, `NaN` or `[]`) stores the field's empty value instead, which its
|
|
38
|
+
* schema picks. Stable across renders.
|
|
39
|
+
*/
|
|
40
|
+
onChange: (value: TValue) => void;
|
|
41
|
+
/**
|
|
42
|
+
* The value as stored, the field's empty value (`undefined` or `null`)
|
|
43
|
+
* included. A control that needs a defined value maps it
|
|
44
|
+
* (`value={value ?? ""}`), or it turns uncontrolled once the field empties.
|
|
45
|
+
*/
|
|
46
|
+
value: TValue;
|
|
47
|
+
};
|
|
48
|
+
type FieldControl<TValue = unknown> = {
|
|
49
|
+
controlProps: FieldControlProps<TValue>;
|
|
50
|
+
descriptionId: string;
|
|
51
|
+
errorId: string;
|
|
52
|
+
/**
|
|
53
|
+
* The errors to render: none while the field's errors are hidden, by the
|
|
54
|
+
* form's validation mode or `hideFieldErrors`.
|
|
55
|
+
*/
|
|
56
|
+
errors: AnyFieldMeta["errors"];
|
|
57
|
+
/** The control's id, for the label's `htmlFor`. */
|
|
58
|
+
id: string;
|
|
59
|
+
isInvalid: boolean;
|
|
60
|
+
/** The field requires a value, by the schema or `required`. */
|
|
61
|
+
isRequired: boolean;
|
|
62
|
+
/** The label's id, when `label` is passed. */
|
|
63
|
+
labelId: string | undefined;
|
|
64
|
+
name: string;
|
|
65
|
+
/** Show the `*`: as `isRequired`, but off under `requiredIndicator: false`. */
|
|
66
|
+
showRequiredIndicator: boolean;
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* Everything a control needs to act as a field of the form, inside a field
|
|
70
|
+
* component the form renders (`form.AppField`, or one registered with
|
|
71
|
+
* `createZenoForm`): the props to spread on the control, and the ids and
|
|
72
|
+
* state its label, description and error need.
|
|
73
|
+
*/
|
|
74
|
+
declare function useFieldControl<TValue = unknown>({ description, disabled, label, required }?: UseFieldControlOptions): FieldControl<TValue>;
|
|
75
|
+
//#endregion
|
|
76
|
+
export { type FieldControl, type FieldControlProps, type UseFieldControlOptions, useFieldControl };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useFieldContext } from "./contexts.mjs";
|
|
3
|
+
import { getFormSchema } from "./validation-modes.mjs";
|
|
4
|
+
import { emptyValueFor } from "./empty-value.mjs";
|
|
5
|
+
import { describedBy } from "./aria.mjs";
|
|
6
|
+
import { useHideFieldErrors, useIsFieldRequired, useIsFieldRequiredBySchema, useIsInvalid } from "./use-is-invalid.mjs";
|
|
7
|
+
import { useCallback, useId } from "react";
|
|
8
|
+
//#region src/lib/use-field-control.ts
|
|
9
|
+
function isEmpty(value) {
|
|
10
|
+
return value === null || value === void 0 || value === "" || Number.isNaN(value) || Array.isArray(value) && value.length === 0;
|
|
11
|
+
}
|
|
12
|
+
function isSyntheticEvent(value) {
|
|
13
|
+
return typeof value === "object" && value !== null && "nativeEvent" in value && "target" in value;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Everything a control needs to act as a field of the form, inside a field
|
|
17
|
+
* component the form renders (`form.AppField`, or one registered with
|
|
18
|
+
* `createZenoForm`): the props to spread on the control, and the ids and
|
|
19
|
+
* state its label, description and error need.
|
|
20
|
+
*/
|
|
21
|
+
function useFieldControl({ description, disabled, label, required } = {}) {
|
|
22
|
+
const field = useFieldContext();
|
|
23
|
+
const id = useId();
|
|
24
|
+
const labelId = label ? `${id}-label` : void 0;
|
|
25
|
+
const descriptionId = `${id}-description`;
|
|
26
|
+
const errorId = `${id}-error`;
|
|
27
|
+
const isInvalid = useIsInvalid(field);
|
|
28
|
+
const hideErrors = useHideFieldErrors(field);
|
|
29
|
+
const showError = isInvalid && !hideErrors;
|
|
30
|
+
const schemaRequired = useIsFieldRequired(field);
|
|
31
|
+
const schemaRequiresValue = useIsFieldRequiredBySchema(field);
|
|
32
|
+
const isRequired = required ?? schemaRequiresValue;
|
|
33
|
+
const { form, handleBlur, handleChange, name } = field;
|
|
34
|
+
const onChange = useCallback((value) => {
|
|
35
|
+
if (isSyntheticEvent(value)) throw new TypeError(`Field "${name}" got a change event, not a value. Map it in the control: onChange(event.target.value).`);
|
|
36
|
+
handleChange(isEmpty(value) ? emptyValueFor(getFormSchema(form), form.state.values, name, value) : value);
|
|
37
|
+
}, [
|
|
38
|
+
form,
|
|
39
|
+
handleChange,
|
|
40
|
+
name
|
|
41
|
+
]);
|
|
42
|
+
return {
|
|
43
|
+
controlProps: {
|
|
44
|
+
"aria-describedby": describedBy([description, descriptionId], [showError, errorId]),
|
|
45
|
+
"aria-invalid": isInvalid || void 0,
|
|
46
|
+
"aria-labelledby": labelId,
|
|
47
|
+
"aria-required": isRequired || void 0,
|
|
48
|
+
disabled,
|
|
49
|
+
id,
|
|
50
|
+
name,
|
|
51
|
+
onBlur: handleBlur,
|
|
52
|
+
onChange,
|
|
53
|
+
value: field.state.value
|
|
54
|
+
},
|
|
55
|
+
descriptionId,
|
|
56
|
+
errorId,
|
|
57
|
+
errors: showError ? field.state.meta.errors : [],
|
|
58
|
+
id,
|
|
59
|
+
isInvalid,
|
|
60
|
+
isRequired,
|
|
61
|
+
labelId,
|
|
62
|
+
name,
|
|
63
|
+
showRequiredIndicator: required ?? schemaRequired
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
//#endregion
|
|
67
|
+
export { useFieldControl };
|
package/package.json
CHANGED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { cleanup, render, screen } from "@zeno-lib/test/testing-library"
|
|
2
|
+
import userEvent from "@zeno-lib/test/user-event"
|
|
3
|
+
import type { ComponentProps } from "react"
|
|
4
|
+
import { afterEach, describe, expect, test, vi } from "vitest"
|
|
5
|
+
import { z } from "zod"
|
|
6
|
+
import { Form, FormProvider, useForm } from "./create-form"
|
|
7
|
+
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
cleanup()
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
const PHONE = /Phone/
|
|
13
|
+
const SUBMIT = { name: "Submit" }
|
|
14
|
+
|
|
15
|
+
// A control a `<label htmlFor>` can't name: only `aria-labelledby` does.
|
|
16
|
+
function StarRating({
|
|
17
|
+
disabled,
|
|
18
|
+
name: _name,
|
|
19
|
+
onChange,
|
|
20
|
+
value,
|
|
21
|
+
...props
|
|
22
|
+
}: Omit<ComponentProps<"div">, "onChange"> & {
|
|
23
|
+
disabled?: boolean
|
|
24
|
+
name?: string
|
|
25
|
+
onChange: (value: number) => void
|
|
26
|
+
value?: number | null
|
|
27
|
+
}) {
|
|
28
|
+
return (
|
|
29
|
+
<div role="radiogroup" {...props}>
|
|
30
|
+
{[1, 2, 3].map((star) => (
|
|
31
|
+
// biome-ignore lint/a11y/useSemanticElements: a custom radio group, as a star rating draws one.
|
|
32
|
+
<button
|
|
33
|
+
aria-checked={value === star}
|
|
34
|
+
disabled={disabled}
|
|
35
|
+
key={star}
|
|
36
|
+
onClick={() => onChange(star)}
|
|
37
|
+
role="radio"
|
|
38
|
+
type="button"
|
|
39
|
+
>
|
|
40
|
+
{star}
|
|
41
|
+
</button>
|
|
42
|
+
))}
|
|
43
|
+
</div>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// A text control that reports its value, not a change event.
|
|
48
|
+
function PhoneInput({
|
|
49
|
+
onChange,
|
|
50
|
+
...props
|
|
51
|
+
}: Omit<ComponentProps<"input">, "onChange" | "value"> & {
|
|
52
|
+
onChange: (value: string) => void
|
|
53
|
+
value: string
|
|
54
|
+
}) {
|
|
55
|
+
return (
|
|
56
|
+
<input
|
|
57
|
+
{...props}
|
|
58
|
+
onChange={(event) => onChange(event.target.value)}
|
|
59
|
+
type="tel"
|
|
60
|
+
/>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe("ControlField", () => {
|
|
65
|
+
test("names and describes its control, and flags it once invalid", async () => {
|
|
66
|
+
const user = userEvent.setup()
|
|
67
|
+
const schema = z.object({ rating: z.number("Rate it") })
|
|
68
|
+
function H() {
|
|
69
|
+
const form = useForm({ onSubmit: vi.fn(), schema })
|
|
70
|
+
const { ControlField, SubmitButton } = form
|
|
71
|
+
return (
|
|
72
|
+
<FormProvider form={form}>
|
|
73
|
+
<Form>
|
|
74
|
+
<ControlField
|
|
75
|
+
description="Out of three."
|
|
76
|
+
label="Rating"
|
|
77
|
+
name="rating"
|
|
78
|
+
render={(control) => <StarRating {...control} />}
|
|
79
|
+
/>
|
|
80
|
+
<SubmitButton>Submit</SubmitButton>
|
|
81
|
+
</Form>
|
|
82
|
+
</FormProvider>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
const { container } = render(<H />)
|
|
86
|
+
const group = screen.getByRole("radiogroup", { name: "Rating" })
|
|
87
|
+
const description = screen.getByText("Out of three.")
|
|
88
|
+
expect(group.getAttribute("aria-describedby")).toBe(description.id)
|
|
89
|
+
expect(group.hasAttribute("aria-invalid")).toBe(false)
|
|
90
|
+
|
|
91
|
+
await user.click(screen.getByRole("button", SUBMIT))
|
|
92
|
+
const error = await screen.findByRole("alert")
|
|
93
|
+
expect(error.textContent).toBe("Rate it")
|
|
94
|
+
expect(group.getAttribute("aria-invalid")).toBe("true")
|
|
95
|
+
expect(group.getAttribute("aria-describedby")).toBe(
|
|
96
|
+
`${description.id} ${error.id}`
|
|
97
|
+
)
|
|
98
|
+
expect(
|
|
99
|
+
container
|
|
100
|
+
.querySelector('[data-field="rating"]')
|
|
101
|
+
?.getAttribute("data-invalid")
|
|
102
|
+
).toBe("true")
|
|
103
|
+
|
|
104
|
+
await user.click(screen.getByRole("radio", { name: "2" }))
|
|
105
|
+
expect(screen.queryByRole("alert")).toBeNull()
|
|
106
|
+
expect(group.hasAttribute("aria-invalid")).toBe(false)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test("marks the field touched on blur, so its error shows", async () => {
|
|
110
|
+
const user = userEvent.setup()
|
|
111
|
+
const schema = z.object({ phone: z.string().min(1, "Enter a phone") })
|
|
112
|
+
function H() {
|
|
113
|
+
const form = useForm({ onSubmit: vi.fn(), schema })
|
|
114
|
+
const { ControlField } = form
|
|
115
|
+
return (
|
|
116
|
+
<FormProvider form={form}>
|
|
117
|
+
<Form>
|
|
118
|
+
<ControlField
|
|
119
|
+
label="Phone"
|
|
120
|
+
name="phone"
|
|
121
|
+
render={(control) => <PhoneInput {...control} />}
|
|
122
|
+
/>
|
|
123
|
+
</Form>
|
|
124
|
+
</FormProvider>
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
render(<H />)
|
|
128
|
+
await user.click(screen.getByLabelText(PHONE))
|
|
129
|
+
expect(screen.queryByRole("alert")).toBeNull()
|
|
130
|
+
await user.tab()
|
|
131
|
+
expect((await screen.findByRole("alert")).textContent).toBe("Enter a phone")
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test("takes required from the schema unless `required` says otherwise", () => {
|
|
135
|
+
const schema = z.object({
|
|
136
|
+
forced: z.string().optional(),
|
|
137
|
+
kept: z.string().min(1),
|
|
138
|
+
lifted: z.string().min(1),
|
|
139
|
+
open: z.string().optional(),
|
|
140
|
+
})
|
|
141
|
+
const FIELDS = [
|
|
142
|
+
{ name: "kept" },
|
|
143
|
+
{ name: "open" },
|
|
144
|
+
{ name: "forced", required: true },
|
|
145
|
+
{ name: "lifted", required: false },
|
|
146
|
+
] as const
|
|
147
|
+
function H({ requiredIndicator }: { requiredIndicator: boolean }) {
|
|
148
|
+
const form = useForm({ onSubmit: vi.fn(), requiredIndicator, schema })
|
|
149
|
+
const { ControlField } = form
|
|
150
|
+
return (
|
|
151
|
+
<FormProvider form={form}>
|
|
152
|
+
<Form>
|
|
153
|
+
{FIELDS.map((props) => (
|
|
154
|
+
<ControlField
|
|
155
|
+
key={props.name}
|
|
156
|
+
label={props.name}
|
|
157
|
+
render={(control) => (
|
|
158
|
+
<PhoneInput {...control} value={control.value ?? ""} />
|
|
159
|
+
)}
|
|
160
|
+
{...props}
|
|
161
|
+
/>
|
|
162
|
+
))}
|
|
163
|
+
</Form>
|
|
164
|
+
</FormProvider>
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
for (const requiredIndicator of [true, false]) {
|
|
168
|
+
const { container, unmount } = render(
|
|
169
|
+
<H requiredIndicator={requiredIndicator} />
|
|
170
|
+
)
|
|
171
|
+
const required = screen
|
|
172
|
+
.getAllByRole("textbox")
|
|
173
|
+
.filter((input) => input.getAttribute("aria-required") === "true")
|
|
174
|
+
.map((input) => input.getAttribute("name"))
|
|
175
|
+
expect(required).toEqual(["kept", "forced"])
|
|
176
|
+
// `required` overrides `requiredIndicator: false` too.
|
|
177
|
+
expect(
|
|
178
|
+
container.querySelectorAll('[data-slot="required-indicator"]')
|
|
179
|
+
).toHaveLength(requiredIndicator ? 2 : 1)
|
|
180
|
+
unmount()
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test("clearing stores the value the schema accepts as empty", async () => {
|
|
185
|
+
const user = userEvent.setup()
|
|
186
|
+
const schema = z.object({
|
|
187
|
+
count: z.number().nullable(),
|
|
188
|
+
nullable: z.string().min(1).nullable(),
|
|
189
|
+
optional: z.string().min(1).optional(),
|
|
190
|
+
tags: z.array(z.string()),
|
|
191
|
+
text: z.string(),
|
|
192
|
+
})
|
|
193
|
+
const captured: { values?: unknown } = {}
|
|
194
|
+
function H() {
|
|
195
|
+
const form = useForm({
|
|
196
|
+
defaultValues: {
|
|
197
|
+
count: 1,
|
|
198
|
+
nullable: "a",
|
|
199
|
+
optional: "b",
|
|
200
|
+
tags: ["c"],
|
|
201
|
+
text: "d",
|
|
202
|
+
},
|
|
203
|
+
onSubmit: vi.fn(),
|
|
204
|
+
schema,
|
|
205
|
+
})
|
|
206
|
+
const { ControlField, Subscribe } = form
|
|
207
|
+
return (
|
|
208
|
+
<FormProvider form={form}>
|
|
209
|
+
<Form>
|
|
210
|
+
{(["nullable", "optional", "text"] as const).map((name) => (
|
|
211
|
+
<ControlField
|
|
212
|
+
key={name}
|
|
213
|
+
label={name}
|
|
214
|
+
name={name}
|
|
215
|
+
render={(control) => (
|
|
216
|
+
<PhoneInput {...control} value={control.value ?? ""} />
|
|
217
|
+
)}
|
|
218
|
+
/>
|
|
219
|
+
))}
|
|
220
|
+
<ControlField
|
|
221
|
+
label="Tags"
|
|
222
|
+
name="tags"
|
|
223
|
+
render={({ onChange }) => (
|
|
224
|
+
<button onClick={() => onChange([])} type="button">
|
|
225
|
+
Clear tags
|
|
226
|
+
</button>
|
|
227
|
+
)}
|
|
228
|
+
/>
|
|
229
|
+
{/* A number control reports `NaN` once cleared. */}
|
|
230
|
+
<ControlField
|
|
231
|
+
label="Count"
|
|
232
|
+
name="count"
|
|
233
|
+
render={({ onChange }) => (
|
|
234
|
+
<button onClick={() => onChange(Number.NaN)} type="button">
|
|
235
|
+
Clear count
|
|
236
|
+
</button>
|
|
237
|
+
)}
|
|
238
|
+
/>
|
|
239
|
+
<Subscribe selector={(state) => state.values}>
|
|
240
|
+
{(values) => {
|
|
241
|
+
captured.values = values
|
|
242
|
+
return null
|
|
243
|
+
}}
|
|
244
|
+
</Subscribe>
|
|
245
|
+
</Form>
|
|
246
|
+
</FormProvider>
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
render(<H />)
|
|
250
|
+
await user.clear(screen.getByLabelText("nullable"))
|
|
251
|
+
await user.clear(screen.getByLabelText("optional"))
|
|
252
|
+
await user.clear(screen.getByLabelText("text"))
|
|
253
|
+
await user.click(screen.getByRole("button", { name: "Clear tags" }))
|
|
254
|
+
await user.click(screen.getByRole("button", { name: "Clear count" }))
|
|
255
|
+
expect(captured.values).toStrictEqual({
|
|
256
|
+
count: null,
|
|
257
|
+
nullable: null,
|
|
258
|
+
optional: undefined,
|
|
259
|
+
tags: [],
|
|
260
|
+
text: "",
|
|
261
|
+
})
|
|
262
|
+
expect(schema.safeParse(captured.values).success).toBe(true)
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
test("keeps one `onChange` across renders, so a control can memoize on it", async () => {
|
|
266
|
+
const user = userEvent.setup()
|
|
267
|
+
const handlers = new Set<unknown>()
|
|
268
|
+
function H() {
|
|
269
|
+
const form = useForm({ defaultValues: { phone: "" }, onSubmit: vi.fn() })
|
|
270
|
+
const { ControlField } = form
|
|
271
|
+
return (
|
|
272
|
+
<FormProvider form={form}>
|
|
273
|
+
<Form>
|
|
274
|
+
<ControlField
|
|
275
|
+
label="Phone"
|
|
276
|
+
name="phone"
|
|
277
|
+
render={(control) => {
|
|
278
|
+
handlers.add(control.onChange)
|
|
279
|
+
return <PhoneInput {...control} />
|
|
280
|
+
}}
|
|
281
|
+
/>
|
|
282
|
+
</Form>
|
|
283
|
+
</FormProvider>
|
|
284
|
+
)
|
|
285
|
+
}
|
|
286
|
+
render(<H />)
|
|
287
|
+
await user.type(screen.getByLabelText(PHONE), "079")
|
|
288
|
+
expect(handlers.size).toBe(1)
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
test("refuses a change event, rather than storing it", async () => {
|
|
292
|
+
const user = userEvent.setup()
|
|
293
|
+
const errors: unknown[] = []
|
|
294
|
+
const onError = (event: ErrorEvent) => {
|
|
295
|
+
event.preventDefault()
|
|
296
|
+
errors.push(event.error)
|
|
297
|
+
}
|
|
298
|
+
window.addEventListener("error", onError)
|
|
299
|
+
const captured: { values?: unknown } = {}
|
|
300
|
+
function H() {
|
|
301
|
+
const form = useForm({ defaultValues: { phone: "" }, onSubmit: vi.fn() })
|
|
302
|
+
const { ControlField, Subscribe } = form
|
|
303
|
+
return (
|
|
304
|
+
<FormProvider form={form}>
|
|
305
|
+
<Form>
|
|
306
|
+
<ControlField
|
|
307
|
+
label="Phone"
|
|
308
|
+
name="phone"
|
|
309
|
+
render={({ onChange }) => (
|
|
310
|
+
// The mistake: an event handed to `onChange` as the value.
|
|
311
|
+
<button
|
|
312
|
+
onClick={(event) => onChange(event as never)}
|
|
313
|
+
type="button"
|
|
314
|
+
>
|
|
315
|
+
Set
|
|
316
|
+
</button>
|
|
317
|
+
)}
|
|
318
|
+
/>
|
|
319
|
+
<Subscribe selector={(state) => state.values}>
|
|
320
|
+
{(values) => {
|
|
321
|
+
captured.values = values
|
|
322
|
+
return null
|
|
323
|
+
}}
|
|
324
|
+
</Subscribe>
|
|
325
|
+
</Form>
|
|
326
|
+
</FormProvider>
|
|
327
|
+
)
|
|
328
|
+
}
|
|
329
|
+
try {
|
|
330
|
+
render(<H />)
|
|
331
|
+
await user.click(screen.getByRole("button", { name: "Set" }))
|
|
332
|
+
} finally {
|
|
333
|
+
window.removeEventListener("error", onError)
|
|
334
|
+
}
|
|
335
|
+
expect(errors).toEqual([expect.any(TypeError)])
|
|
336
|
+
expect(captured.values).toStrictEqual({ phone: "" })
|
|
337
|
+
})
|
|
338
|
+
})
|
package/src/create-form.tsx
CHANGED
|
@@ -6,6 +6,7 @@ import { createZenoForm } from "@zeno-lib/forms"
|
|
|
6
6
|
import { CheckboxField } from "./fields/checkbox-field"
|
|
7
7
|
import { CheckboxGroupField } from "./fields/checkbox-group-field"
|
|
8
8
|
import { ComboboxField } from "./fields/combobox-field"
|
|
9
|
+
import { ControlField } from "./fields/control-field"
|
|
9
10
|
import { DatePickerField } from "./fields/date-picker-field"
|
|
10
11
|
import { EmailField } from "./fields/email-field"
|
|
11
12
|
import { FormError } from "./fields/form-error"
|
|
@@ -32,6 +33,7 @@ const { useAppForm, useForm, withFieldGroup, withForm } = createZenoForm({
|
|
|
32
33
|
CheckboxField,
|
|
33
34
|
CheckboxGroupField,
|
|
34
35
|
ComboboxField,
|
|
36
|
+
ControlField,
|
|
35
37
|
DatePickerField,
|
|
36
38
|
EmailField,
|
|
37
39
|
InputField,
|
|
@@ -57,5 +59,6 @@ const { useAppForm, useForm, withFieldGroup, withForm } = createZenoForm({
|
|
|
57
59
|
|
|
58
60
|
export * from "@zeno-lib/forms"
|
|
59
61
|
export { ValidationSpinner } from "./addons/validation-spinner"
|
|
62
|
+
export { FieldShell } from "./fields/field-shell"
|
|
60
63
|
export { RadioGroupFieldItem } from "./fields/radio-group-field"
|
|
61
64
|
export { useAppForm, useForm, withFieldGroup, withForm }
|
package/src/create-zeno-form.tsx
CHANGED
|
@@ -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,
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
19
20
|
type StandardSchemaLike,
|
|
20
21
|
} from "./lib/schema-required"
|
|
21
22
|
import { setSubmitError } from "./lib/submit-error"
|
|
23
|
+
import type { FieldControlProps } from "./lib/use-field-control"
|
|
22
24
|
import { useRebasedDefaultValues } from "./lib/use-rebased-default-values"
|
|
23
25
|
import { useUnsavedChangesWarning } from "./lib/use-unsaved-changes-warning"
|
|
24
26
|
import { ValidationError } from "./lib/validation-error"
|
|
@@ -273,9 +275,26 @@ type WithValidators = {
|
|
|
273
275
|
// biome-ignore lint/suspicious/noExplicitAny: field components accept arbitrary props.
|
|
274
276
|
type AnyComponent = React.ComponentType<any>
|
|
275
277
|
|
|
278
|
+
// A field that hands `render` a `FieldControlProps` (the kit's `ControlField`)
|
|
279
|
+
// gets the control typed with the value at `name`, which its own generic
|
|
280
|
+
// can't see. Matching on the parameter, not on `render` as a whole, leaves a
|
|
281
|
+
// field whose `render` takes something else (or nothing) as declared.
|
|
282
|
+
type WithControlValue<P, V> = P extends {
|
|
283
|
+
render: (control: infer C) => ReactNode
|
|
284
|
+
}
|
|
285
|
+
? // biome-ignore lint/suspicious/noExplicitAny: matches any value type.
|
|
286
|
+
[C] extends [FieldControlProps<any>]
|
|
287
|
+
? Omit<P, "render"> & {
|
|
288
|
+
render: (control: FieldControlProps<V>) => ReactNode
|
|
289
|
+
}
|
|
290
|
+
: P
|
|
291
|
+
: P
|
|
292
|
+
|
|
276
293
|
type FieldWrappers<FC extends Record<string, AnyComponent>, T> = {
|
|
277
294
|
[K in keyof FC]: <N extends DeepKeys<T>>(
|
|
278
|
-
props: ComponentProps<FC[K]
|
|
295
|
+
props: WithControlValue<ComponentProps<FC[K]>, DeepValue<T, N>> & {
|
|
296
|
+
name: N
|
|
297
|
+
} & WithValidators
|
|
279
298
|
) => ReactNode
|
|
280
299
|
}
|
|
281
300
|
|