@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
@@ -0,0 +1,165 @@
1
+ // The items of `ComboboxField` and `MultiSelectField`: plain values, which
2
+ // are their own label, `{ value, label }` objects, or `{ label, items }`
3
+ // groups of either.
4
+
5
+ /** An item whose value isn't its label. */
6
+ type ItemObject<V> = { value: V; label: string }
7
+
8
+ /** Items under a heading. */
9
+ type ItemGroup<T> = { label: string; items: readonly T[] }
10
+
11
+ /** An item of `items`, or of one of its groups. */
12
+ type ItemOption<T> = T extends ItemGroup<infer I> ? I : T
13
+
14
+ /** What picking an item stores: `item.value` for an object, else the item. */
15
+ type ItemValue<T> =
16
+ T extends ItemGroup<infer I>
17
+ ? ItemValue<I>
18
+ : T extends ItemObject<infer V>
19
+ ? V
20
+ : T
21
+
22
+ /** Whether an item's value is a stored one, as `isItemEqualToValue` says. */
23
+ type ValueComparator = (itemValue: unknown, value: unknown) => boolean
24
+
25
+ function isItemObject(item: unknown): item is ItemObject<unknown> {
26
+ return (
27
+ typeof item === "object" &&
28
+ item !== null &&
29
+ "value" in item &&
30
+ "label" in item
31
+ )
32
+ }
33
+
34
+ /** What Base UI takes for a group: an object with an `items` array. */
35
+ function isItemGroup(item: unknown): item is ItemGroup<unknown> {
36
+ return (
37
+ typeof item === "object" &&
38
+ item !== null &&
39
+ Array.isArray((item as { items?: unknown }).items)
40
+ )
41
+ }
42
+
43
+ function itemValue(item: unknown): unknown {
44
+ return isItemObject(item) ? item.value : item
45
+ }
46
+
47
+ function itemLabel(item: unknown): string {
48
+ return isItemObject(item) ? item.label : String(item)
49
+ }
50
+
51
+ /** A React key per value: `1` and `"1"` get two, an object gets its JSON. */
52
+ function valueKey(value: unknown): string {
53
+ if (typeof value !== "object" || value === null) {
54
+ return `${typeof value}:${String(value)}`
55
+ }
56
+ try {
57
+ return `object:${JSON.stringify(value)}`
58
+ } catch {
59
+ return `object:${String(value)}`
60
+ }
61
+ }
62
+
63
+ type ItemList<T> = {
64
+ /**
65
+ * `items` as Base UI takes them. It tells a grouped list by its first entry
66
+ * alone, so once there is a group, each run of plain items beside one goes
67
+ * under a group with no heading (`label: ""`).
68
+ */
69
+ readonly items: readonly T[]
70
+ /** Every option, the grouped ones included, in order. */
71
+ readonly options: readonly ItemOption<T>[]
72
+ /** The first option of each `valueKey`, so finding a value is no scan. */
73
+ readonly byKey: ReadonlyMap<string, ItemOption<T>>
74
+ }
75
+
76
+ function toItemList<T>(items: readonly T[]): ItemList<T> {
77
+ const hasGroup = items.some(isItemGroup)
78
+ const options: ItemOption<T>[] = []
79
+ const groups: ItemGroup<unknown>[] = []
80
+ let run: unknown[] | undefined
81
+ for (const item of items) {
82
+ if (isItemGroup(item)) {
83
+ for (const option of item.items) {
84
+ options.push(option as ItemOption<T>)
85
+ }
86
+ groups.push(item)
87
+ run = undefined
88
+ } else {
89
+ options.push(item as ItemOption<T>)
90
+ if (hasGroup && !run) {
91
+ run = []
92
+ groups.push({ items: run, label: "" })
93
+ }
94
+ run?.push(item)
95
+ }
96
+ }
97
+ const byKey = new Map<string, ItemOption<T>>()
98
+ for (const option of options) {
99
+ const key = valueKey(itemValue(option))
100
+ if (!byKey.has(key)) {
101
+ byKey.set(key, option)
102
+ }
103
+ }
104
+ return {
105
+ byKey,
106
+ items: hasGroup ? (groups as unknown as readonly T[]) : items,
107
+ options,
108
+ }
109
+ }
110
+
111
+ /**
112
+ * `Object.is`, then `isItemEqualToValue`, which never sees an empty value.
113
+ */
114
+ function isSameValue(
115
+ a: unknown,
116
+ b: unknown,
117
+ isItemEqualToValue: ValueComparator | undefined
118
+ ): boolean {
119
+ if (Object.is(a, b)) {
120
+ return true
121
+ }
122
+ if (a === null || a === undefined || b === null || b === undefined) {
123
+ return false
124
+ }
125
+ return isItemEqualToValue?.(a, b) ?? false
126
+ }
127
+
128
+ /** The option whose value is `value`, if `items` has it. */
129
+ function findOption<T>(
130
+ list: ItemList<T>,
131
+ value: unknown,
132
+ isItemEqualToValue: ValueComparator | undefined
133
+ ): ItemOption<T> | undefined {
134
+ const hit = list.byKey.get(valueKey(value))
135
+ if (
136
+ hit !== undefined &&
137
+ isSameValue(itemValue(hit), value, isItemEqualToValue)
138
+ ) {
139
+ return hit
140
+ }
141
+ // A comparator can match values whose keys differ (`{ id }` against
142
+ // `{ id, name }`).
143
+ return list.options.find((option) =>
144
+ isSameValue(itemValue(option), value, isItemEqualToValue)
145
+ )
146
+ }
147
+
148
+ export type {
149
+ ItemGroup,
150
+ ItemList,
151
+ ItemObject,
152
+ ItemOption,
153
+ ItemValue,
154
+ ValueComparator,
155
+ }
156
+ export {
157
+ findOption,
158
+ isItemGroup,
159
+ isItemObject,
160
+ isSameValue,
161
+ itemLabel,
162
+ itemValue,
163
+ toItemList,
164
+ valueKey,
165
+ }
@@ -87,6 +87,15 @@ describe("getRequiredPaths", () => {
87
87
  expect(required.has("profile.bio")).toBe(false)
88
88
  })
89
89
 
90
+ test("a nullable object is not required, though its own fields are", () => {
91
+ const schema = z.object({
92
+ payer: z.object({ id: z.number(), kind: z.string().min(1) }).nullable(),
93
+ })
94
+ const required = getRequiredPaths(schema)
95
+ expect(required.has("payer")).toBe(false)
96
+ expect(required.has("payer.kind")).toBe(true)
97
+ })
98
+
90
99
  test("optional nested object is not descended into", () => {
91
100
  const schema = z.object({
92
101
  profile: z.object({ email: z.email() }).optional(),
@@ -221,6 +221,38 @@ function settleEmptied(
221
221
  return { descended, next }
222
222
  }
223
223
 
224
+ // The probe descends into a missing object with `{}`, so it never tries
225
+ // `null` there. Try it on the side: a `.nullable()` object accepts it, so it
226
+ // isn't required, though its own fields still are once it is set.
227
+ function clearNullableObjects(
228
+ schema: StandardSchemaLike,
229
+ filled: readonly ProbeFill[],
230
+ probe: Record<PropertyKey, unknown>,
231
+ required: Set<string>,
232
+ cleared: Set<string>
233
+ ): void {
234
+ for (const fill of filled) {
235
+ if (isEmptyProbe(fill.value) || cleared.has(fill.path)) {
236
+ continue
237
+ }
238
+ fillAt(probe, fill.keys, null, true)
239
+ const issues = validateSync(schema, probe)
240
+ fillAt(probe, fill.keys, fill.value, true)
241
+ const rejected = issues?.some((issue) => {
242
+ const path = joinPath(issue.path?.map(pathKey) ?? [])
243
+ return (
244
+ path === fill.path ||
245
+ path.startsWith(`${fill.path}.`) ||
246
+ path.startsWith(`${fill.path}[`)
247
+ )
248
+ })
249
+ if (issues && !rejected) {
250
+ required.delete(fill.path)
251
+ cleared.add(fill.path)
252
+ }
253
+ }
254
+ }
255
+
224
256
  function getRequiredPaths(schema: StandardSchemaLike): Set<string> {
225
257
  const required = new Set<string>()
226
258
  const cleared = new Set<string>()
@@ -234,6 +266,7 @@ function getRequiredPaths(schema: StandardSchemaLike): Set<string> {
234
266
  return required
235
267
  }
236
268
  const { failing, filled } = recordPass(issues, probe, required, cleared)
269
+ clearNullableObjects(schema, filled, probe, required, cleared)
237
270
  const settled = settleEmptied(emptied, failing, probe, required, cleared)
238
271
  emptied = [
239
272
  ...filled.filter(({ value }) => isEmptyProbe(value)),
@@ -8,6 +8,7 @@ import {
8
8
  } from "./action-result"
9
9
  import { applyValidationError } from "./apply-validation-error"
10
10
  import { ValidationError } from "./validation-error"
11
+ import { getFormSchema } from "./validation-modes"
11
12
 
12
13
  // Write an action's failure onto the form. Field messages go through
13
14
  // `applyValidationError`, so they live in `errorMap.onChange` and clear the
@@ -110,7 +111,9 @@ type SubmitActionSchemaOptions<TValues, TOutput, TData> = SubmitActionOptions<
110
111
  /**
111
112
  * Parsed on the client before the call: `onSubmit` receives the form's
112
113
  * input values, and the action gets the schema's output. A failure is
113
- * applied to the form and the action is never called.
114
+ * applied to the form and the action is never called. Replaces the form's
115
+ * own `schema` for this call; pass one only when the action takes a
116
+ * different schema than the form.
114
117
  */
115
118
  schema: SubmitActionSchema<TOutput>
116
119
  }
@@ -120,9 +123,13 @@ type SubmitActionSchemaOptions<TValues, TOutput, TData> = SubmitActionOptions<
120
123
  * form. Pass `onSubmit`'s own argument straight through:
121
124
  *
122
125
  * ```ts
123
- * onSubmit: (submit) => submitAction(submit, saveProfile, { reset: true, schema })
126
+ * onSubmit: (submit) => submitAction(submit, saveProfile, { reset: true })
124
127
  * ```
125
128
  *
129
+ * The values are parsed with `options.schema`, else with the form's own
130
+ * `useForm({ schema })`, and the action gets the schema's output. A form with
131
+ * no schema (`useForm` without one, `useAppForm`) sends its values as they are.
132
+ *
126
133
  * On `{ ok: false }` the field errors land on their fields (array paths like
127
134
  * `owners[0].percentage` included) and clear as each field is edited. On
128
135
  * `{ ok: true }` the form is optionally reset. Either way the result is
@@ -145,10 +152,17 @@ async function submitAction(
145
152
  options: { reset?: unknown; schema?: SubmitActionSchema<unknown> } = {}
146
153
  ): Promise<ActionResult<unknown>> {
147
154
  const { formApi, value } = props
155
+ // `useForm` types its schema as `StandardSchema<Values>`, so the output
156
+ // still fits the `TValues` the action is typed against.
157
+ const schema =
158
+ options.schema ??
159
+ (getFormSchema(formApi as AnyFormApi) as
160
+ | SubmitActionSchema<unknown>
161
+ | undefined)
148
162
  let input: unknown = value
149
163
 
150
- if (options.schema) {
151
- const parsed = await options.schema["~standard"].validate(value)
164
+ if (schema) {
165
+ const parsed = await schema["~standard"].validate(value)
152
166
  if (parsed.issues) {
153
167
  const error = toActionError(parsed.issues)
154
168
  applyActionError(formApi, error)
@@ -0,0 +1,191 @@
1
+ "use client"
2
+
3
+ import type { AnyFieldMeta } from "@tanstack/react-form"
4
+ import { type ReactNode, useCallback, useId } from "react"
5
+
6
+ import { describedBy } from "./aria"
7
+ import { useFieldContext } from "./contexts"
8
+ import { emptyValueFor } from "./empty-value"
9
+ import { useIsFormDisabled } from "./form-disabled"
10
+ import {
11
+ useHideFieldErrors,
12
+ useIsFieldRequired,
13
+ useIsFieldRequiredBySchema,
14
+ useIsInvalid,
15
+ } from "./use-is-invalid"
16
+ import { getFormSchema } from "./validation-modes"
17
+
18
+ type UseFieldControlOptions = {
19
+ /**
20
+ * The description the field renders, if any. The control is described by
21
+ * it, and by the error while one shows.
22
+ */
23
+ description?: ReactNode
24
+ /**
25
+ * Disable the control. A disabled form (`useForm({ disabled })`,
26
+ * `<Form disabled>`, `FormDialog`'s `disabled`) disables it either way.
27
+ */
28
+ disabled?: boolean
29
+ /**
30
+ * The label the field renders, if any. The control gets `aria-labelledby`,
31
+ * which names a control a `<label htmlFor>` can't, such as a
32
+ * `role="radiogroup"`.
33
+ */
34
+ label?: ReactNode
35
+ /**
36
+ * Mark the field required, or not, over the schema. Drives the `*` and
37
+ * `aria-required`.
38
+ */
39
+ required?: boolean
40
+ }
41
+
42
+ /** The props that bind a control to its field. Spread them on the control. */
43
+ type FieldControlProps<TValue = unknown> = {
44
+ "aria-describedby": string | undefined
45
+ "aria-invalid": true | undefined
46
+ "aria-labelledby": string | undefined
47
+ "aria-required": true | undefined
48
+ /** The field's own `disabled`, or the form's. */
49
+ disabled: boolean | undefined
50
+ id: string
51
+ name: string
52
+ /** Marks the field touched. */
53
+ onBlur: () => void
54
+ /**
55
+ * Stores `value`, not a change event: a control that reports one maps it
56
+ * (`onChange(event.target.value)`). An empty value (`null`, `undefined`,
57
+ * `""`, `NaN` or `[]`) stores the field's empty value instead, which its
58
+ * schema picks. Stable across renders.
59
+ */
60
+ onChange: (value: TValue) => void
61
+ /**
62
+ * The value as stored, the field's empty value (`undefined` or `null`)
63
+ * included. A control that needs a defined value maps it
64
+ * (`value={value ?? ""}`), or it turns uncontrolled once the field empties.
65
+ */
66
+ value: TValue
67
+ }
68
+
69
+ type FieldControl<TValue = unknown> = {
70
+ controlProps: FieldControlProps<TValue>
71
+ descriptionId: string
72
+ errorId: string
73
+ /**
74
+ * The errors to render: none while the field's errors are hidden, by the
75
+ * form's validation mode or `hideFieldErrors`.
76
+ */
77
+ errors: AnyFieldMeta["errors"]
78
+ /** The control's id, for the label's `htmlFor`. */
79
+ id: string
80
+ /** The field is disabled, by its own `disabled` or the form's. */
81
+ isDisabled: boolean
82
+ isInvalid: boolean
83
+ /** The field requires a value, by the schema or `required`. */
84
+ isRequired: boolean
85
+ /** The label's id, when `label` is passed. */
86
+ labelId: string | undefined
87
+ name: string
88
+ /** Show the `*`: as `isRequired`, but off under `requiredIndicator: false`. */
89
+ showRequiredIndicator: boolean
90
+ }
91
+
92
+ function isEmpty(value: unknown): boolean {
93
+ return (
94
+ value === null ||
95
+ value === undefined ||
96
+ value === "" ||
97
+ Number.isNaN(value) ||
98
+ (Array.isArray(value) && value.length === 0)
99
+ )
100
+ }
101
+
102
+ // A React synthetic event, from spreading the control props on a native input
103
+ // or an event-emitting component. Storing it would put the event in the form.
104
+ function isSyntheticEvent(value: unknown): boolean {
105
+ return (
106
+ typeof value === "object" &&
107
+ value !== null &&
108
+ "nativeEvent" in value &&
109
+ "target" in value
110
+ )
111
+ }
112
+
113
+ /**
114
+ * Everything a control needs to act as a field of the form, inside a field
115
+ * component the form renders (`form.AppField`, or one registered with
116
+ * `createZenoForm`): the props to spread on the control, and the ids and
117
+ * state its label, description and error need.
118
+ */
119
+ function useFieldControl<TValue = unknown>({
120
+ description,
121
+ disabled,
122
+ label,
123
+ required,
124
+ }: UseFieldControlOptions = {}): FieldControl<TValue> {
125
+ const field = useFieldContext<TValue>()
126
+ const id = useId()
127
+ const labelId = label ? `${id}-label` : undefined
128
+ const descriptionId = `${id}-description`
129
+ const errorId = `${id}-error`
130
+ const isInvalid = useIsInvalid(field)
131
+ const hideErrors = useHideFieldErrors(field)
132
+ const showError = isInvalid && !hideErrors
133
+ const schemaRequired = useIsFieldRequired(field)
134
+ const schemaRequiresValue = useIsFieldRequiredBySchema(field)
135
+ const isRequired = required ?? schemaRequiresValue
136
+ const isDisabled = useIsFormDisabled(field.form) || Boolean(disabled)
137
+ // `field` is rebuilt on every value change; these members aren't, so a
138
+ // control that memoizes on `onChange` doesn't see a new one each render.
139
+ const { form, handleBlur, handleChange, name } = field
140
+ const onChange = useCallback(
141
+ (value: TValue) => {
142
+ if (isSyntheticEvent(value)) {
143
+ throw new TypeError(
144
+ `Field "${name}" got a change event, not a value. Map it in the control: onChange(event.target.value).`
145
+ )
146
+ }
147
+ handleChange(
148
+ isEmpty(value)
149
+ ? (emptyValueFor(
150
+ getFormSchema(form),
151
+ form.state.values,
152
+ name,
153
+ value
154
+ ) as TValue)
155
+ : value
156
+ )
157
+ },
158
+ [form, handleChange, name]
159
+ )
160
+
161
+ return {
162
+ controlProps: {
163
+ "aria-describedby": describedBy(
164
+ [description, descriptionId],
165
+ [showError, errorId]
166
+ ),
167
+ "aria-invalid": isInvalid || undefined,
168
+ "aria-labelledby": labelId,
169
+ "aria-required": isRequired || undefined,
170
+ disabled: isDisabled,
171
+ id,
172
+ name,
173
+ onBlur: handleBlur,
174
+ onChange,
175
+ value: field.state.value,
176
+ },
177
+ descriptionId,
178
+ errorId,
179
+ errors: showError ? field.state.meta.errors : [],
180
+ id,
181
+ isDisabled,
182
+ isInvalid,
183
+ isRequired,
184
+ labelId,
185
+ name,
186
+ showRequiredIndicator: required ?? schemaRequired,
187
+ }
188
+ }
189
+
190
+ export type { FieldControl, FieldControlProps, UseFieldControlOptions }
191
+ export { useFieldControl }
@@ -244,6 +244,109 @@ describe("submitAction — field errors from the action", () => {
244
244
  })
245
245
 
246
246
  describe("submitAction — schema", () => {
247
+ test("parses with the form's own schema, and reset: true rebases on what the action saved", async () => {
248
+ const user = userEvent.setup()
249
+ const schema = z.object({ age: z.coerce.number(), name: z.string().trim() })
250
+ type Values = z.input<typeof schema>
251
+ const action = vi.fn((input: Values) =>
252
+ Promise.resolve<ActionResult<Values>>({ data: input, ok: true })
253
+ )
254
+ const formRef: { current: unknown } = { current: undefined }
255
+ function Harness() {
256
+ const form = useForm({
257
+ defaultValues: { age: "42", name: " Ada " },
258
+ onSubmit: (submit) => submitAction(submit, action, { reset: true }),
259
+ schema,
260
+ })
261
+ formRef.current = form
262
+ const { InputField, SubmitButton } = form
263
+ return (
264
+ <FormProvider form={form}>
265
+ <Form>
266
+ <InputField label="Name" name="name" />
267
+ <SubmitButton>Submit</SubmitButton>
268
+ </Form>
269
+ </FormProvider>
270
+ )
271
+ }
272
+ render(<Harness />)
273
+
274
+ await user.click(screen.getByRole("button", SUBMIT))
275
+
276
+ await vi.waitFor(() =>
277
+ expect(action).toHaveBeenCalledWith({ age: 42, name: "Ada" })
278
+ )
279
+ const form = formRef.current as {
280
+ state: { isDefaultValue: boolean; values: Values }
281
+ }
282
+ await vi.waitFor(() =>
283
+ expect(form.state.values).toEqual({ age: 42, name: "Ada" })
284
+ )
285
+ expect(form.state.isDefaultValue).toBe(true)
286
+ })
287
+
288
+ test("an explicit schema wins over the form's", async () => {
289
+ const user = userEvent.setup()
290
+ const action = vi.fn((_input: { name: string }) =>
291
+ Promise.resolve<ActionResult<null>>({ data: null, ok: true })
292
+ )
293
+ const actionSchema = z.object({ name: z.string().toUpperCase() })
294
+ function Harness() {
295
+ const form = useForm({
296
+ defaultValues: { name: " Ada " },
297
+ onSubmit: (submit) =>
298
+ submitAction(submit, action, { schema: actionSchema }),
299
+ schema: z.object({ name: z.string().trim() }),
300
+ })
301
+ const { InputField, SubmitButton } = form
302
+ return (
303
+ <FormProvider form={form}>
304
+ <Form>
305
+ <InputField label="Name" name="name" />
306
+ <SubmitButton>Submit</SubmitButton>
307
+ </Form>
308
+ </FormProvider>
309
+ )
310
+ }
311
+ render(<Harness />)
312
+
313
+ await user.click(screen.getByRole("button", SUBMIT))
314
+
315
+ await vi.waitFor(() =>
316
+ expect(action).toHaveBeenCalledWith({ name: " ADA " })
317
+ )
318
+ })
319
+
320
+ // A schema passed as a plain TanStack validator is not the form's schema:
321
+ // the action gets the values as they are, as before.
322
+ const trimmed = z.object({ name: z.string().trim() })
323
+
324
+ test("useForm without a schema sends the values as they are", async () => {
325
+ const user = userEvent.setup()
326
+ const action = vi.fn((_input: { name: string }) =>
327
+ Promise.resolve<ActionResult<null>>({ data: null, ok: true })
328
+ )
329
+ function Harness() {
330
+ const form = useForm({
331
+ defaultValues: { name: " Ada " },
332
+ onSubmit: (submit) => submitAction(submit, action),
333
+ validators: { onChange: trimmed },
334
+ })
335
+ return (
336
+ <button onClick={() => form.handleSubmit()} type="button">
337
+ Submit
338
+ </button>
339
+ )
340
+ }
341
+ render(<Harness />)
342
+
343
+ await user.click(screen.getByRole("button", SUBMIT))
344
+
345
+ await vi.waitFor(() =>
346
+ expect(action).toHaveBeenCalledWith({ name: " Ada " })
347
+ )
348
+ })
349
+
247
350
  test("passes the schema output to the action, not the input values", async () => {
248
351
  const user = userEvent.setup()
249
352
  const schema = z.object({
@@ -1,6 +1,8 @@
1
- import { test } from "vitest"
1
+ import type { ReactNode } from "react"
2
+ import { expectTypeOf, test } from "vitest"
2
3
  import { z } from "zod"
3
4
  import { useForm } from "./create-form"
5
+ import { createZenoForm } from "./create-zeno-form"
4
6
 
5
7
  const schema = z.object({
6
8
  accept: z.boolean(),
@@ -93,3 +95,51 @@ test("listeners prop is accepted on every field wrapper", () => {
93
95
  name: "email",
94
96
  })
95
97
  })
98
+
99
+ test("ControlField types its control with the value at `name`", () => {
100
+ const form = useForm({
101
+ onSubmit: () => undefined,
102
+ schema: z.object({
103
+ phone: z.string().nullable(),
104
+ rating: z.number(),
105
+ }),
106
+ })
107
+ form.ControlField({
108
+ name: "phone",
109
+ render: (control) => {
110
+ expectTypeOf(control.value).toEqualTypeOf<string | null>()
111
+ return null
112
+ },
113
+ })
114
+ form.ControlField({
115
+ name: "rating",
116
+ render: (control) => {
117
+ control.onChange(3)
118
+ // @ts-expect-error — the field holds a number
119
+ control.onChange("3")
120
+ return null
121
+ },
122
+ })
123
+ // @ts-expect-error — not a valid path
124
+ form.ControlField({ name: "nonexistent", render: () => null })
125
+ })
126
+
127
+ test("a field whose `render` takes another argument keeps its own type", () => {
128
+ const { useForm: useListForm } = createZenoForm({
129
+ fieldComponents: {
130
+ ListField: (_: { render: (row: { id: string }) => ReactNode }) => null,
131
+ },
132
+ formComponents: {},
133
+ })
134
+ const form = useListForm({
135
+ onSubmit: () => undefined,
136
+ schema: z.object({ rows: z.array(z.string()) }),
137
+ })
138
+ form.ListField({
139
+ name: "rows",
140
+ render: (row) => {
141
+ expectTypeOf(row).toEqualTypeOf<{ id: string }>()
142
+ return null
143
+ },
144
+ })
145
+ })