@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
@@ -102,6 +102,22 @@ function settleEmptied(emptied, failing, probe, required, cleared) {
102
102
  next
103
103
  };
104
104
  }
105
+ function clearNullableObjects(schema, filled, probe, required, cleared) {
106
+ for (const fill of filled) {
107
+ if (isEmptyProbe(fill.value) || cleared.has(fill.path)) continue;
108
+ fillAt(probe, fill.keys, null, true);
109
+ const issues = validateSync(schema, probe);
110
+ fillAt(probe, fill.keys, fill.value, true);
111
+ const rejected = issues?.some((issue) => {
112
+ const path = joinPath(issue.path?.map(pathKey) ?? []);
113
+ return path === fill.path || path.startsWith(`${fill.path}.`) || path.startsWith(`${fill.path}[`);
114
+ });
115
+ if (issues && !rejected) {
116
+ required.delete(fill.path);
117
+ cleared.add(fill.path);
118
+ }
119
+ }
120
+ }
105
121
  function getRequiredPaths(schema) {
106
122
  const required = /* @__PURE__ */ new Set();
107
123
  const cleared = /* @__PURE__ */ new Set();
@@ -111,6 +127,7 @@ function getRequiredPaths(schema) {
111
127
  const issues = validateSync(schema, probe);
112
128
  if (!issues) return required;
113
129
  const { failing, filled } = recordPass(issues, probe, required, cleared);
130
+ clearNullableObjects(schema, filled, probe, required, cleared);
114
131
  const settled = settleEmptied(emptied, failing, probe, required, cleared);
115
132
  emptied = [...filled.filter(({ value }) => isEmptyProbe(value)), ...settled.next];
116
133
  if (filled.length === 0 && !settled.descended) break;
@@ -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, schema })
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 (options.schema) {
20
- const parsed = await options.schema["~standard"].validate(value);
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,83 @@
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
+ /**
11
+ * Disable the control. A disabled form (`useForm({ disabled })`,
12
+ * `<Form disabled>`, `FormDialog`'s `disabled`) disables it either way.
13
+ */
14
+ disabled?: boolean;
15
+ /**
16
+ * The label the field renders, if any. The control gets `aria-labelledby`,
17
+ * which names a control a `<label htmlFor>` can't, such as a
18
+ * `role="radiogroup"`.
19
+ */
20
+ label?: ReactNode;
21
+ /**
22
+ * Mark the field required, or not, over the schema. Drives the `*` and
23
+ * `aria-required`.
24
+ */
25
+ required?: boolean;
26
+ };
27
+ /** The props that bind a control to its field. Spread them on the control. */
28
+ type FieldControlProps<TValue = unknown> = {
29
+ "aria-describedby": string | undefined;
30
+ "aria-invalid": true | undefined;
31
+ "aria-labelledby": string | undefined;
32
+ "aria-required": true | undefined;
33
+ /** The field's own `disabled`, or the form's. */
34
+ disabled: boolean | undefined;
35
+ id: string;
36
+ name: string;
37
+ /** Marks the field touched. */
38
+ onBlur: () => void;
39
+ /**
40
+ * Stores `value`, not a change event: a control that reports one maps it
41
+ * (`onChange(event.target.value)`). An empty value (`null`, `undefined`,
42
+ * `""`, `NaN` or `[]`) stores the field's empty value instead, which its
43
+ * schema picks. Stable across renders.
44
+ */
45
+ onChange: (value: TValue) => void;
46
+ /**
47
+ * The value as stored, the field's empty value (`undefined` or `null`)
48
+ * included. A control that needs a defined value maps it
49
+ * (`value={value ?? ""}`), or it turns uncontrolled once the field empties.
50
+ */
51
+ value: TValue;
52
+ };
53
+ type FieldControl<TValue = unknown> = {
54
+ controlProps: FieldControlProps<TValue>;
55
+ descriptionId: string;
56
+ errorId: string;
57
+ /**
58
+ * The errors to render: none while the field's errors are hidden, by the
59
+ * form's validation mode or `hideFieldErrors`.
60
+ */
61
+ errors: AnyFieldMeta["errors"];
62
+ /** The control's id, for the label's `htmlFor`. */
63
+ id: string;
64
+ /** The field is disabled, by its own `disabled` or the form's. */
65
+ isDisabled: boolean;
66
+ isInvalid: boolean;
67
+ /** The field requires a value, by the schema or `required`. */
68
+ isRequired: boolean;
69
+ /** The label's id, when `label` is passed. */
70
+ labelId: string | undefined;
71
+ name: string;
72
+ /** Show the `*`: as `isRequired`, but off under `requiredIndicator: false`. */
73
+ showRequiredIndicator: boolean;
74
+ };
75
+ /**
76
+ * Everything a control needs to act as a field of the form, inside a field
77
+ * component the form renders (`form.AppField`, or one registered with
78
+ * `createZenoForm`): the props to spread on the control, and the ids and
79
+ * state its label, description and error need.
80
+ */
81
+ declare function useFieldControl<TValue = unknown>({ description, disabled, label, required }?: UseFieldControlOptions): FieldControl<TValue>;
82
+ //#endregion
83
+ export { type FieldControl, type FieldControlProps, type UseFieldControlOptions, useFieldControl };
@@ -0,0 +1,70 @@
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 { useIsFormDisabled } from "./form-disabled.mjs";
6
+ import { describedBy } from "./aria.mjs";
7
+ import { useHideFieldErrors, useIsFieldRequired, useIsFieldRequiredBySchema, useIsInvalid } from "./use-is-invalid.mjs";
8
+ import { useCallback, useId } from "react";
9
+ //#region src/lib/use-field-control.ts
10
+ function isEmpty(value) {
11
+ return value === null || value === void 0 || value === "" || Number.isNaN(value) || Array.isArray(value) && value.length === 0;
12
+ }
13
+ function isSyntheticEvent(value) {
14
+ return typeof value === "object" && value !== null && "nativeEvent" in value && "target" in value;
15
+ }
16
+ /**
17
+ * Everything a control needs to act as a field of the form, inside a field
18
+ * component the form renders (`form.AppField`, or one registered with
19
+ * `createZenoForm`): the props to spread on the control, and the ids and
20
+ * state its label, description and error need.
21
+ */
22
+ function useFieldControl({ description, disabled, label, required } = {}) {
23
+ const field = useFieldContext();
24
+ const id = useId();
25
+ const labelId = label ? `${id}-label` : void 0;
26
+ const descriptionId = `${id}-description`;
27
+ const errorId = `${id}-error`;
28
+ const isInvalid = useIsInvalid(field);
29
+ const hideErrors = useHideFieldErrors(field);
30
+ const showError = isInvalid && !hideErrors;
31
+ const schemaRequired = useIsFieldRequired(field);
32
+ const schemaRequiresValue = useIsFieldRequiredBySchema(field);
33
+ const isRequired = required ?? schemaRequiresValue;
34
+ const isDisabled = useIsFormDisabled(field.form) || Boolean(disabled);
35
+ const { form, handleBlur, handleChange, name } = field;
36
+ const onChange = useCallback((value) => {
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).`);
38
+ handleChange(isEmpty(value) ? emptyValueFor(getFormSchema(form), form.state.values, name, value) : value);
39
+ }, [
40
+ form,
41
+ handleChange,
42
+ name
43
+ ]);
44
+ return {
45
+ controlProps: {
46
+ "aria-describedby": describedBy([description, descriptionId], [showError, errorId]),
47
+ "aria-invalid": isInvalid || void 0,
48
+ "aria-labelledby": labelId,
49
+ "aria-required": isRequired || void 0,
50
+ disabled: isDisabled,
51
+ id,
52
+ name,
53
+ onBlur: handleBlur,
54
+ onChange,
55
+ value: field.state.value
56
+ },
57
+ descriptionId,
58
+ errorId,
59
+ errors: showError ? field.state.meta.errors : [],
60
+ id,
61
+ isDisabled,
62
+ isInvalid,
63
+ isRequired,
64
+ labelId,
65
+ name,
66
+ showRequiredIndicator: required ?? schemaRequired
67
+ };
68
+ }
69
+ //#endregion
70
+ export { useFieldControl };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeno-lib/forms",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "repository": {
5
5
  "url": "https://github.com/zeno-lib/zeno.git",
6
6
  "directory": "packages/forms"
@@ -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
+ })
@@ -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"
@@ -27,35 +28,42 @@ import { YearField } from "./fields/year-field"
27
28
 
28
29
  // Wire the dropped-in field components into the headless factory. Edit the
29
30
  // field files under `./fields` freely — they use your own shadcn primitives.
30
- const { useAppForm, useForm, withFieldGroup, withForm } = createZenoForm({
31
- fieldComponents: {
32
- CheckboxField,
33
- CheckboxGroupField,
34
- ComboboxField,
35
- DatePickerField,
36
- EmailField,
37
- InputField,
38
- MoneyField,
39
- MultiSelectField,
40
- NumberField,
41
- OtpField,
42
- PasswordField,
43
- PercentageField,
44
- RadioGroupField,
45
- SelectField,
46
- SliderField,
47
- SwitchField,
48
- TextAreaField,
49
- YearField,
50
- },
51
- formComponents: {
52
- FormError,
53
- ResetButton,
54
- SubmitButton,
55
- },
56
- })
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
+ })
57
64
 
58
65
  export * from "@zeno-lib/forms"
59
66
  export { ValidationSpinner } from "./addons/validation-spinner"
67
+ export { FieldShell } from "./fields/field-shell"
60
68
  export { RadioGroupFieldItem } from "./fields/radio-group-field"
61
- export { useAppForm, useForm, withFieldGroup, withForm }
69
+ export { useAppFields, useAppForm, useForm, withFieldGroup, withForm }