@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.
@@ -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 FieldWrappers<FC extends Record<string, AnyComponent>, T> = { [K in keyof FC]: <N extends DeepKeys<T>>(props: ComponentProps<FC[K]> & {
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, 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,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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeno-lib/forms",
3
- "version": "0.5.0",
3
+ "version": "0.6.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"
@@ -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 }
@@ -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]> & { name: N } & WithValidators
295
+ props: WithControlValue<ComponentProps<FC[K]>, DeepValue<T, N>> & {
296
+ name: N
297
+ } & WithValidators
279
298
  ) => ReactNode
280
299
  }
281
300