@zeno-lib/forms 0.9.0 → 0.10.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,6 +1,7 @@
1
1
  import { StandardSchemaLike } from "./schema-required.mjs";
2
2
  import { AnyFieldApi } from "@tanstack/react-form";
3
3
  //#region src/lib/empty-value.d.ts
4
+ declare function toPathKeys(name: string): PropertyKey[];
4
5
  declare function emptyValueFor(schema: StandardSchemaLike | undefined, values: unknown, name: string, fallback: unknown): unknown;
5
6
  declare function withEmptyStartValues(schema: StandardSchemaLike, defaults: Record<string, unknown>): Record<string, unknown>;
6
7
  /**
@@ -8,5 +9,12 @@ declare function withEmptyStartValues(schema: StandardSchemaLike, defaults: Reco
8
9
  * the field's value, since the same schema decides both.
9
10
  */
10
11
  declare function getEmptyValue<T>(field: AnyFieldApi, fallback: T): T;
12
+ /**
13
+ * What a text control stores once its text is cleared: `null` where the field
14
+ * starts at `null` (a nullable string), so it saves as none, and `""`
15
+ * otherwise. Read from the schema's shape rather than by validating the form,
16
+ * so the other fields' values never change it.
17
+ */
18
+ declare function getNoTextValue(field: AnyFieldApi): string | null;
11
19
  //#endregion
12
- export { emptyValueFor, getEmptyValue, withEmptyStartValues };
20
+ export { emptyValueFor, getEmptyValue, getNoTextValue, toPathKeys, withEmptyStartValues };
@@ -88,5 +88,16 @@ function withEmptyStartValues(schema, defaults) {
88
88
  function getEmptyValue(field, fallback) {
89
89
  return emptyValueFor(getFormSchema(field.form), field.form.state.values, field.name, fallback);
90
90
  }
91
+ /**
92
+ * What a text control stores once its text is cleared: `null` where the field
93
+ * starts at `null` (a nullable string), so it saves as none, and `""`
94
+ * otherwise. Read from the schema's shape rather than by validating the form,
95
+ * so the other fields' values never change it.
96
+ */
97
+ function getNoTextValue(field) {
98
+ const schema = getFormSchema(field.form);
99
+ const start = schema ? extractZodDefaultAt(schema, toPathKeys(field.name)) : { ok: false };
100
+ return start.ok && start.value === null ? null : "";
101
+ }
91
102
  //#endregion
92
- export { emptyValueFor, getEmptyValue, withEmptyStartValues };
103
+ export { emptyValueFor, getEmptyValue, getNoTextValue, toPathKeys, withEmptyStartValues };
@@ -0,0 +1,18 @@
1
+ import { AnyFieldApi } from "@tanstack/react-form";
2
+ //#region src/lib/field-hints.d.ts
3
+ /**
4
+ * What a field reads from the column its schema comes from, when the column's
5
+ * hint names it. The `percentage()` and `year()` columns of
6
+ * `@zeno-lib/db/schema` give one, so `PercentageField` and `YearField` show
7
+ * and clamp what the column accepts with no props: the scale from the hint,
8
+ * the bounds from the schema's own `.min()` and `.max()`, a caller's
9
+ * narrowing included. A prop the field is given wins.
10
+ */
11
+ type FieldHints = {
12
+ readonly max?: number | undefined;
13
+ readonly min?: number | undefined;
14
+ readonly scale?: "fraction" | "percent" | undefined;
15
+ };
16
+ declare function getFieldHints(field: AnyFieldApi, kind: string): FieldHints;
17
+ //#endregion
18
+ export { type FieldHints, getFieldHints };
@@ -0,0 +1,24 @@
1
+ import { findZodSchemaAt } from "./schema-defaults.mjs";
2
+ import { getFormSchema } from "./validation-modes.mjs";
3
+ import { toPathKeys } from "./empty-value.mjs";
4
+ //#region src/lib/field-hints.ts
5
+ const FIELD_HINTS_KEY = Symbol.for("@zeno-lib/field-hints");
6
+ const readHint = (node) => {
7
+ const hint = globalThis[FIELD_HINTS_KEY]?.get(node);
8
+ return typeof hint?.field === "string" ? hint : void 0;
9
+ };
10
+ const toBound = (value) => typeof value === "number" && Number.isFinite(value) ? value : void 0;
11
+ function getFieldHints(field, kind) {
12
+ const schema = getFormSchema(field.form);
13
+ const found = schema ? findZodSchemaAt(schema, toPathKeys(field.name), readHint) : void 0;
14
+ if (found?.value.field !== kind) return {};
15
+ const { scale } = found.value;
16
+ const { maxValue, minValue } = found.node;
17
+ return {
18
+ max: toBound(maxValue),
19
+ min: toBound(minValue),
20
+ scale: scale === "fraction" || scale === "percent" ? scale : void 0
21
+ };
22
+ }
23
+ //#endregion
24
+ export { getFieldHints };
@@ -10,6 +10,7 @@ type SchemaDef = {
10
10
  readonly shape?: Record<string, SchemaNode>;
11
11
  readonly catchall?: SchemaNode;
12
12
  readonly in?: SchemaNode;
13
+ readonly out?: SchemaNode;
13
14
  readonly element?: SchemaNode;
14
15
  readonly defaultValue?: unknown;
15
16
  };
@@ -21,7 +22,11 @@ type ExtractResult = {
21
22
  };
22
23
  declare function extractZodDefaults(schema: SchemaNode): Record<string, unknown>;
23
24
  declare function extractZodDefaultAt(schema: SchemaNode, keys: readonly PropertyKey[]): ExtractResult;
25
+ declare function findZodSchemaAt<T>(schema: SchemaNode, keys: readonly PropertyKey[], read: (node: SchemaNode) => T | undefined): {
26
+ node: SchemaNode;
27
+ value: T;
28
+ } | undefined;
24
29
  declare function pickSchemaShape<T>(schema: SchemaNode, value: T): T;
25
30
  declare function deepMergeDefaults(schemaDefaults: Record<string, unknown>, userDefaults: Record<string, unknown> | undefined): Record<string, unknown>;
26
31
  //#endregion
27
- export { deepMergeDefaults, extractZodDefaultAt, extractZodDefaults, pickSchemaShape };
32
+ export { deepMergeDefaults, extractZodDefaultAt, extractZodDefaults, findZodSchemaAt, pickSchemaShape };
@@ -5,6 +5,7 @@ function getDef(node) {
5
5
  }
6
6
  function extractDefaultForField(node) {
7
7
  let current = node;
8
+ let isNullable = false;
8
9
  for (let depth = 0; depth < MAX_WRAPPER_DEPTH; depth += 1) {
9
10
  const def = getDef(current);
10
11
  if (!def?.type) return { ok: false };
@@ -14,8 +15,11 @@ function extractDefaultForField(node) {
14
15
  ok: true,
15
16
  value: def.defaultValue
16
17
  };
17
- case "optional":
18
18
  case "nullable":
19
+ isNullable = true;
20
+ current = def.innerType;
21
+ continue;
22
+ case "optional":
19
23
  case "nonoptional":
20
24
  case "readonly":
21
25
  current = def.innerType;
@@ -25,7 +29,7 @@ function extractDefaultForField(node) {
25
29
  continue;
26
30
  case "string": return {
27
31
  ok: true,
28
- value: ""
32
+ value: isNullable ? null : ""
29
33
  };
30
34
  case "array": return {
31
35
  ok: true,
@@ -89,6 +93,35 @@ function extractZodDefaultAt(schema, keys) {
89
93
  return { ok: false };
90
94
  }
91
95
  }
96
+ function unwrapped(node) {
97
+ const def = getDef(node);
98
+ switch (def?.type) {
99
+ case "catch":
100
+ case "default":
101
+ case "nonoptional":
102
+ case "nullable":
103
+ case "optional":
104
+ case "prefault":
105
+ case "readonly": return def.innerType;
106
+ case "pipe": return getDef(def.in)?.type === "transform" ? def.out : def.in;
107
+ default: return;
108
+ }
109
+ }
110
+ function findZodSchemaAt(schema, keys, read) {
111
+ try {
112
+ let node = schema;
113
+ for (const key of keys) node = node && childAt(node, key);
114
+ let current = node && keys.length > 0 ? node : void 0;
115
+ for (let depth = 0; current && depth < MAX_WRAPPER_DEPTH; depth += 1) {
116
+ const value = read(current);
117
+ if (value !== void 0) return {
118
+ node: current,
119
+ value
120
+ };
121
+ current = unwrapped(current);
122
+ }
123
+ } catch {}
124
+ }
92
125
  function isPlainObject(value) {
93
126
  return typeof value === "object" && value !== null && Object.getPrototypeOf(value) === Object.prototype;
94
127
  }
@@ -146,4 +179,4 @@ function deepMergeDefaults(schemaDefaults, userDefaults) {
146
179
  return result;
147
180
  }
148
181
  //#endregion
149
- export { deepMergeDefaults, extractZodDefaultAt, extractZodDefaults, pickSchemaShape };
182
+ export { deepMergeDefaults, extractZodDefaultAt, extractZodDefaults, findZodSchemaAt, pickSchemaShape };
@@ -1,14 +1,15 @@
1
1
  "use client";
2
2
  import { evaluate } from "@tanstack/react-form";
3
- import { useCallback, useRef } from "react";
3
+ import { useCallback, useLayoutEffect, useRef } from "react";
4
4
  //#region src/lib/use-rebased-default-values.ts
5
5
  function useRebasedDefaultValues(callerDefaults) {
6
6
  const liveForm = useRef(null);
7
7
  const lastCallerDefaults = useRef(callerDefaults);
8
8
  let defaultValues = callerDefaults;
9
- if (evaluate(lastCallerDefaults.current, callerDefaults)) {
10
- if (liveForm.current) defaultValues = liveForm.current.options.defaultValues;
11
- } else lastCallerDefaults.current = callerDefaults;
9
+ if (liveForm.current && evaluate(lastCallerDefaults.current, callerDefaults)) defaultValues = liveForm.current.options.defaultValues;
10
+ useLayoutEffect(() => {
11
+ lastCallerDefaults.current = callerDefaults;
12
+ }, [callerDefaults]);
12
13
  const onMount = useCallback((props) => {
13
14
  liveForm.current = props.formApi;
14
15
  }, []);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeno-lib/forms",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "repository": {
5
5
  "url": "https://github.com/zeno-lib/zeno.git",
6
6
  "directory": "packages/forms"
@@ -0,0 +1,118 @@
1
+ import { cleanup, render, screen } from "@zeno-lib/test/testing-library"
2
+ import userEvent from "@zeno-lib/test/user-event"
3
+ import { afterEach, expect, test, vi } from "vitest"
4
+ import { z } from "zod"
5
+ import { Form, FormProvider, useForm } from "./create-form"
6
+
7
+ afterEach(cleanup)
8
+
9
+ type Hint = { field: string; scale?: "fraction" | "percent" }
10
+
11
+ // Where `@zeno-lib/schema` registers a column's hint.
12
+ const HINTS_KEY = Symbol.for("@zeno-lib/field-hints")
13
+ const holder = globalThis as {
14
+ [HINTS_KEY]?: z.core.$ZodRegistry<Hint>
15
+ }
16
+ const hints = holder[HINTS_KEY] ?? z.registry<Hint>()
17
+ holder[HINTS_KEY] = hints
18
+
19
+ const hinted = <T extends z.ZodType>(column: T, hint: Hint) => {
20
+ hints.add(column, hint)
21
+ return column
22
+ }
23
+
24
+ // What a `percentage()` or `year()` column of `@zeno-lib/db/schema` hands a
25
+ // form: the rule, its hint, and the wrappers of a nullable column.
26
+ const schema = z.object({
27
+ built: hinted(z.int().min(1900).max(2100), { field: "year" })
28
+ .nullable()
29
+ .optional(),
30
+ occupancy: hinted(z.number().min(0).max(1), {
31
+ field: "percentage",
32
+ scale: "fraction",
33
+ })
34
+ .nullable()
35
+ .optional()
36
+ // Metadata of the form's own, around the column's schema.
37
+ .describe("Share of the floor area let"),
38
+ rate: hinted(z.number().min(-1).max(1), {
39
+ field: "percentage",
40
+ scale: "fraction",
41
+ })
42
+ // A caller's refinement narrows the column's bounds.
43
+ .max(0.5)
44
+ .nullable()
45
+ .optional(),
46
+ })
47
+
48
+ const captured: { values?: z.input<typeof schema> } = {}
49
+
50
+ function Harness({ occupancyMax }: { occupancyMax?: number }) {
51
+ const form = useForm({
52
+ defaultValues: { occupancy: 0.125 },
53
+ onSubmit: vi.fn(),
54
+ schema,
55
+ })
56
+ const { PercentageField, Subscribe, YearField } = form
57
+ return (
58
+ <FormProvider form={form}>
59
+ <Form>
60
+ <PercentageField
61
+ label="Occupancy"
62
+ locale="en-US"
63
+ max={occupancyMax}
64
+ name="occupancy"
65
+ />
66
+ <PercentageField label="Rate" locale="en-US" name="rate" />
67
+ <YearField label="Built" name="built" />
68
+ <Subscribe selector={(state) => state.values}>
69
+ {(values) => {
70
+ captured.values = values
71
+ return null
72
+ }}
73
+ </Subscribe>
74
+ </Form>
75
+ </FormProvider>
76
+ )
77
+ }
78
+
79
+ test("a percentage field takes its scale and bounds from the column's hint", async () => {
80
+ const user = userEvent.setup()
81
+ render(<Harness />)
82
+ const occupancy = screen.getByLabelText("Occupancy") as HTMLInputElement
83
+ expect(occupancy.value).toBe("12.5")
84
+
85
+ await user.clear(occupancy)
86
+ await user.type(occupancy, "150")
87
+ await user.tab()
88
+ expect(captured.values?.occupancy).toBe(1)
89
+
90
+ // A bound below 0 lets the field take a negative rate, and the field
91
+ // clamps to the bound the schema narrowed.
92
+ const rate = screen.getByLabelText("Rate")
93
+ await user.type(rate, "-25")
94
+ await user.tab()
95
+ expect(captured.values?.rate).toBe(-0.25)
96
+ await user.clear(rate)
97
+ await user.type(rate, "90")
98
+ await user.tab()
99
+ expect(captured.values?.rate).toBe(0.5)
100
+ })
101
+
102
+ test("a year field clamps to the column's hinted bounds", async () => {
103
+ const user = userEvent.setup()
104
+ render(<Harness />)
105
+ await user.type(screen.getByLabelText("Built"), "1850")
106
+ await user.tab()
107
+ expect(captured.values?.built).toBe(1900)
108
+ })
109
+
110
+ test("a prop wins over the hint", async () => {
111
+ const user = userEvent.setup()
112
+ render(<Harness occupancyMax={0.5} />)
113
+ const occupancy = screen.getByLabelText("Occupancy")
114
+ await user.clear(occupancy)
115
+ await user.type(occupancy, "90")
116
+ await user.tab()
117
+ expect(captured.values?.occupancy).toBe(0.5)
118
+ })
@@ -1,6 +1,7 @@
1
1
  "use client"
2
2
 
3
3
  import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
4
+ import { getNoTextValue } from "@zeno-lib/forms/lib/empty-value"
4
5
  import { useFieldControl } from "@zeno-lib/forms/lib/use-field-control"
5
6
  import { Children, type ComponentProps, type ReactNode } from "react"
6
7
  import { Input } from "@/components/ui/input"
@@ -29,10 +30,10 @@ function InputField({
29
30
  required,
30
31
  ...props
31
32
  }: InputFieldProps) {
32
- const field = useFieldContext<string>()
33
+ const field = useFieldContext<string | null>()
33
34
  // The label names the input through `htmlFor`, so the input needs no
34
35
  // `aria-labelledby` and the hook doesn't get `label`.
35
- const { controlProps, ...shell } = useFieldControl<string>({
36
+ const { controlProps, ...shell } = useFieldControl<string | null>({
36
37
  description,
37
38
  disabled,
38
39
  required,
@@ -40,11 +41,12 @@ function InputField({
40
41
  const hasAddons = Children.count(children) > 0
41
42
 
42
43
  // A text input keeps the `""` the user typed, where `controlProps.onChange`
43
- // would store the field's empty value.
44
+ // would store the field's empty value, unless the schema takes `null`: then
45
+ // no text is `null`, as the field starts, so it saves as none.
44
46
  const inputProps = {
45
47
  ...controlProps,
46
48
  onChange: (event: React.ChangeEvent<HTMLInputElement>) =>
47
- field.handleChange(event.target.value),
49
+ field.handleChange(event.target.value || getNoTextValue(field)),
48
50
  value: field.state.value ?? "",
49
51
  ...props,
50
52
  }
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
4
4
  import { getEmptyValue } from "@zeno-lib/forms/lib/empty-value"
5
+ import { getFieldHints } from "@zeno-lib/forms/lib/field-hints"
5
6
  import { useFormattedNumber } from "@zeno-lib/forms/lib/use-formatted-number"
6
7
  import { InputGroupAddon, InputGroupText } from "@/components/ui/input-group"
7
8
  import { InputField, type InputFieldProps } from "./input-field"
@@ -14,13 +15,16 @@ type PercentageFieldProps = Omit<
14
15
  * How the form value relates to what the user types:
15
16
  * - `"percent"` (default): the value is the number shown, `12.5` ↔ `12.5 %`.
16
17
  * - `"fraction"`: the value is a ratio, `0.125` ↔ `12.5 %`.
18
+ *
19
+ * A `percentage()` column of `@zeno-lib/db/schema` sets it, as it sets
20
+ * `min` and `max`.
17
21
  */
18
22
  scale?: "fraction" | "percent"
19
23
  /** BCP 47 locale driving the decimal separator. Defaults to the runtime locale. */
20
24
  locale?: string
21
25
  /** Digits allowed after the decimal separator (of the shown percentage). Defaults to `2`. */
22
26
  fractionDigits?: number
23
- /** Accept negative percentages. Defaults to `false`. */
27
+ /** Accept negative percentages. Defaults to whether `min` is below 0. */
24
28
  allowNegative?: boolean
25
29
  /** Clamp on blur, in the form value's scale. */
26
30
  min?: number
@@ -33,23 +37,27 @@ const fromPercent = (value: number) => value / 100
33
37
 
34
38
  /**
35
39
  * Percentage input with a `%` add-on. The form value is `number | null`, on
36
- * the `scale` you pick (`0–100` by default, `0–1` with `scale="fraction"`).
40
+ * the `scale` you pick, or the one the schema's `percentage()` column hints
41
+ * (`0–100` by default, `0–1` with `scale="fraction"`).
37
42
  */
38
43
  function PercentageField({
39
- allowNegative = false,
44
+ allowNegative,
40
45
  children,
41
46
  fractionDigits = 2,
42
47
  locale,
43
- max,
44
- min,
45
- scale = "percent",
48
+ max: maxProp,
49
+ min: minProp,
50
+ scale: scaleProp,
46
51
  ...props
47
52
  }: PercentageFieldProps) {
48
53
  const field = useFieldContext<number | null | undefined>()
49
- const isFraction = scale === "fraction"
54
+ const hints = getFieldHints(field, "percentage")
55
+ const max = maxProp ?? hints.max
56
+ const min = minProp ?? hints.min
57
+ const isFraction = (scaleProp ?? hints.scale ?? "percent") === "fraction"
50
58
 
51
59
  const inputProps = useFormattedNumber({
52
- allowNegative,
60
+ allowNegative: allowNegative ?? (min !== undefined && min < 0),
53
61
  locale,
54
62
  max,
55
63
  maximumFractionDigits: fractionDigits,
@@ -1,6 +1,7 @@
1
1
  "use client"
2
2
 
3
3
  import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
4
+ import { getNoTextValue } from "@zeno-lib/forms/lib/empty-value"
4
5
  import { useFieldControl } from "@zeno-lib/forms/lib/use-field-control"
5
6
  import { Children, type ComponentProps, type ReactNode } from "react"
6
7
  import { InputGroup, InputGroupTextarea } from "@/components/ui/input-group"
@@ -29,10 +30,10 @@ function TextAreaField({
29
30
  required,
30
31
  ...props
31
32
  }: TextAreaFieldProps) {
32
- const field = useFieldContext<string>()
33
+ const field = useFieldContext<string | null>()
33
34
  // The label names the textarea through `htmlFor`, so the textarea needs no
34
35
  // `aria-labelledby` and the hook doesn't get `label`.
35
- const { controlProps, ...shell } = useFieldControl<string>({
36
+ const { controlProps, ...shell } = useFieldControl<string | null>({
36
37
  description,
37
38
  disabled,
38
39
  required,
@@ -40,11 +41,12 @@ function TextAreaField({
40
41
  const hasAddons = Children.count(children) > 0
41
42
 
42
43
  // A textarea keeps the `""` the user typed, where `controlProps.onChange`
43
- // would store the field's empty value.
44
+ // would store the field's empty value, unless the schema takes `null`: then
45
+ // no text is `null`, as the field starts, so it saves as none.
44
46
  const textareaProps = {
45
47
  ...controlProps,
46
48
  onChange: (event: React.ChangeEvent<HTMLTextAreaElement>) =>
47
- field.handleChange(event.target.value),
49
+ field.handleChange(event.target.value || getNoTextValue(field)),
48
50
  value: field.state.value ?? "",
49
51
  ...props,
50
52
  }
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
4
4
  import { getEmptyValue } from "@zeno-lib/forms/lib/empty-value"
5
+ import { getFieldHints } from "@zeno-lib/forms/lib/field-hints"
5
6
  import { useFormattedNumber } from "@zeno-lib/forms/lib/use-formatted-number"
6
7
  import { InputField, type InputFieldProps } from "./input-field"
7
8
 
@@ -25,13 +26,17 @@ type YearFieldProps = Omit<
25
26
 
26
27
  /**
27
28
  * Four-digit year input. The form value is an integer year or `null`. No
28
- * thousands separator, digits only, clamped to `min`/`max` on blur.
29
+ * thousands separator, digits only, clamped on blur to `min`/`max`, or to the
30
+ * bounds the schema's `year()` column hints.
29
31
  */
30
- function YearField({ max, min, ...props }: YearFieldProps) {
32
+ function YearField({ max: maxProp, min: minProp, ...props }: YearFieldProps) {
31
33
  const field = useFieldContext<number | null | undefined>()
34
+ const hints = getFieldHints(field, "year")
35
+ const max = maxProp ?? hints.max
36
+ const min = minProp ?? hints.min
32
37
 
33
38
  const inputProps = useFormattedNumber({
34
- allowNegative: false,
39
+ allowNegative: min !== undefined && min < 0,
35
40
  max,
36
41
  maxIntegerDigits: 4,
37
42
  maximumFractionDigits: 0,
@@ -7,7 +7,7 @@ import {
7
7
  within,
8
8
  } from "@zeno-lib/test/testing-library"
9
9
  import userEvent from "@zeno-lib/test/user-event"
10
- import { type ReactNode, useState } from "react"
10
+ import { type ReactNode, StrictMode, useState } from "react"
11
11
  import { afterEach, describe, expect, onTestFinished, test, vi } from "vitest"
12
12
  import { z } from "zod"
13
13
  import { useForm } from "./create-form"
@@ -178,6 +178,13 @@ describe("FormDialog", () => {
178
178
  expect(name.value).toBe("Ada")
179
179
  })
180
180
 
181
+ test("opens with per-session defaults under Strict Mode", async () => {
182
+ const user = userEvent.setup()
183
+ render(<Harness />, { wrapper: StrictMode })
184
+ const name = await openEdit(user)
185
+ expect(name.value).toBe("Ada")
186
+ })
187
+
181
188
  test("an edit opened on a record with keys the schema lacks saves to a .strict() schema", async () => {
182
189
  const user = userEvent.setup()
183
190
  const strictSchema = schema.strict()
@@ -171,4 +171,27 @@ function getEmptyValue<T>(field: AnyFieldApi, fallback: T): T {
171
171
  ) as T
172
172
  }
173
173
 
174
- export { emptyValueFor, getEmptyValue, withEmptyStartValues }
174
+ /**
175
+ * What a text control stores once its text is cleared: `null` where the field
176
+ * starts at `null` (a nullable string), so it saves as none, and `""`
177
+ * otherwise. Read from the schema's shape rather than by validating the form,
178
+ * so the other fields' values never change it.
179
+ */
180
+ function getNoTextValue(field: AnyFieldApi): string | null {
181
+ const schema = getFormSchema(field.form)
182
+ const start = schema
183
+ ? extractZodDefaultAt(
184
+ schema as Parameters<typeof extractZodDefaultAt>[0],
185
+ toPathKeys(field.name)
186
+ )
187
+ : { ok: false as const }
188
+ return start.ok && start.value === null ? null : ""
189
+ }
190
+
191
+ export {
192
+ emptyValueFor,
193
+ getEmptyValue,
194
+ getNoTextValue,
195
+ toPathKeys,
196
+ withEmptyStartValues,
197
+ }
@@ -0,0 +1,64 @@
1
+ import type { AnyFieldApi } from "@tanstack/react-form"
2
+ import { toPathKeys } from "./empty-value"
3
+ import { findZodSchemaAt } from "./schema-defaults"
4
+ import { getFormSchema } from "./validation-modes"
5
+
6
+ /**
7
+ * What a field reads from the column its schema comes from, when the column's
8
+ * hint names it. The `percentage()` and `year()` columns of
9
+ * `@zeno-lib/db/schema` give one, so `PercentageField` and `YearField` show
10
+ * and clamp what the column accepts with no props: the scale from the hint,
11
+ * the bounds from the schema's own `.min()` and `.max()`, a caller's
12
+ * narrowing included. A prop the field is given wins.
13
+ */
14
+ type FieldHints = {
15
+ readonly max?: number | undefined
16
+ readonly min?: number | undefined
17
+ readonly scale?: "fraction" | "percent" | undefined
18
+ }
19
+
20
+ // The registry `@zeno-lib/schema` puts each column's hint in, under a
21
+ // `globalThis` symbol both packages name, so this one reads it without
22
+ // importing that one. Absent until `@zeno-lib/schema` loads, and then no
23
+ // field has a hint.
24
+ const FIELD_HINTS_KEY = Symbol.for("@zeno-lib/field-hints")
25
+
26
+ type HintRegistry = { readonly get: (schema: object) => unknown }
27
+
28
+ type Bounded = { readonly maxValue?: unknown; readonly minValue?: unknown }
29
+
30
+ const readHint = (node: object) => {
31
+ const registry = (globalThis as { [FIELD_HINTS_KEY]?: HintRegistry })[
32
+ FIELD_HINTS_KEY
33
+ ]
34
+ const hint = registry?.get(node) as { field?: unknown } | undefined
35
+ return typeof hint?.field === "string" ? hint : undefined
36
+ }
37
+
38
+ // A schema with no `.min()` or `.max()` reports `null` or an infinity.
39
+ const toBound = (value: unknown) =>
40
+ typeof value === "number" && Number.isFinite(value) ? value : undefined
41
+
42
+ function getFieldHints(field: AnyFieldApi, kind: string): FieldHints {
43
+ const schema = getFormSchema(field.form)
44
+ const found = schema
45
+ ? findZodSchemaAt(
46
+ schema as Parameters<typeof findZodSchemaAt>[0],
47
+ toPathKeys(field.name),
48
+ readHint
49
+ )
50
+ : undefined
51
+ if (found?.value.field !== kind) {
52
+ return {}
53
+ }
54
+ const { scale } = found.value as { scale?: unknown }
55
+ const { maxValue, minValue } = found.node as Bounded
56
+ return {
57
+ max: toBound(maxValue),
58
+ min: toBound(minValue),
59
+ scale: scale === "fraction" || scale === "percent" ? scale : undefined,
60
+ }
61
+ }
62
+
63
+ export type { FieldHints }
64
+ export { getFieldHints }
@@ -65,9 +65,12 @@ describe("extractZodDefaults — wrappers", () => {
65
65
  expect(extractZodDefaults(schema)).toEqual({ x: "" })
66
66
  })
67
67
 
68
- test(".nullable() unwraps to inner string → ''", () => {
69
- const schema = z.object({ x: z.string().nullable() })
70
- expect(extractZodDefaults(schema)).toEqual({ x: "" })
68
+ test(".nullable() starts a string at null, which a nullable column stores", () => {
69
+ const schema = z.object({
70
+ x: z.string().nullable(),
71
+ y: z.string().nullable().optional(),
72
+ })
73
+ expect(extractZodDefaults(schema)).toEqual({ x: null, y: null })
71
74
  })
72
75
 
73
76
  test(".readonly() unwraps to inner string → ''", () => {
@@ -4,7 +4,8 @@
4
4
  // Tier A+ semantics:
5
5
  // - `.default(...)` / `.prefault(...)` values flow through.
6
6
  // - Strings (and string-format subtypes — email, url, uuid, regex, …) without
7
- // a default fall back to `""` so React inputs render controlled.
7
+ // a default fall back to `""` so React inputs render controlled, or to
8
+ // `null` under `.nullable()`, which the fields render as no text.
8
9
  // - Arrays without a default fall back to `[]`.
9
10
  // - Nested `z.object(...)` recurses; emits a sub-record only if at least one
10
11
  // leaf produced a value.
@@ -26,6 +27,7 @@ type SchemaDef = {
26
27
  readonly shape?: Record<string, SchemaNode>
27
28
  readonly catchall?: SchemaNode
28
29
  readonly in?: SchemaNode
30
+ readonly out?: SchemaNode
29
31
  readonly element?: SchemaNode
30
32
  readonly defaultValue?: unknown
31
33
  }
@@ -40,6 +42,7 @@ function getDef(node: SchemaNode | undefined): SchemaDef | undefined {
40
42
 
41
43
  function extractDefaultForField(node: SchemaNode): ExtractResult {
42
44
  let current: SchemaNode | undefined = node
45
+ let isNullable = false
43
46
  for (let depth = 0; depth < MAX_WRAPPER_DEPTH; depth += 1) {
44
47
  const def: SchemaDef | undefined = getDef(current)
45
48
  if (!def?.type) {
@@ -49,8 +52,11 @@ function extractDefaultForField(node: SchemaNode): ExtractResult {
49
52
  case "default":
50
53
  case "prefault":
51
54
  return { ok: true, value: def.defaultValue }
52
- case "optional":
53
55
  case "nullable":
56
+ isNullable = true
57
+ current = def.innerType
58
+ continue
59
+ case "optional":
54
60
  case "nonoptional":
55
61
  case "readonly":
56
62
  current = def.innerType
@@ -58,8 +64,10 @@ function extractDefaultForField(node: SchemaNode): ExtractResult {
58
64
  case "pipe":
59
65
  current = def.in
60
66
  continue
67
+ // No text is `null` where the schema takes it, as a nullable column
68
+ // stores it, so a field left blank saves as none rather than as `""`.
61
69
  case "string":
62
- return { ok: true, value: "" }
70
+ return { ok: true, value: isNullable ? null : "" }
63
71
  case "array":
64
72
  return { ok: true, value: [] }
65
73
  case "object": {
@@ -142,6 +150,54 @@ function extractZodDefaultAt(
142
150
  }
143
151
  }
144
152
 
153
+ // The schema inside a wrapper, or `undefined` past the last one.
154
+ function unwrapped(node: SchemaNode): SchemaNode | undefined {
155
+ const def: SchemaDef | undefined = getDef(node)
156
+ switch (def?.type) {
157
+ case "catch":
158
+ case "default":
159
+ case "nonoptional":
160
+ case "nullable":
161
+ case "optional":
162
+ case "prefault":
163
+ case "readonly":
164
+ return def.innerType
165
+ // `z.preprocess` pipes a transform into the schema it feeds.
166
+ case "pipe":
167
+ return getDef(def.in)?.type === "transform" ? def.out : def.in
168
+ default:
169
+ return undefined
170
+ }
171
+ }
172
+
173
+ // The schema at `keys`, or inside one of its wrappers, that `read` finds a
174
+ // value on, with that value. A Zod registry keeps an entry on the schema it
175
+ // was set on, not on the `.optional()` or `.nullable()` around it.
176
+ function findZodSchemaAt<T>(
177
+ schema: SchemaNode,
178
+ keys: readonly PropertyKey[],
179
+ read: (node: SchemaNode) => T | undefined
180
+ ): { node: SchemaNode; value: T } | undefined {
181
+ try {
182
+ let node: SchemaNode | null = schema
183
+ for (const key of keys) {
184
+ node = node && childAt(node, key)
185
+ }
186
+ let current: SchemaNode | undefined =
187
+ node && keys.length > 0 ? node : undefined
188
+ for (let depth = 0; current && depth < MAX_WRAPPER_DEPTH; depth += 1) {
189
+ const value = read(current)
190
+ if (value !== undefined) {
191
+ return { node: current, value }
192
+ }
193
+ current = unwrapped(current)
194
+ }
195
+ } catch {
196
+ // An unrecognised schema has nothing to offer.
197
+ }
198
+ return undefined
199
+ }
200
+
145
201
  function isPlainObject(value: unknown): value is Record<string, unknown> {
146
202
  return (
147
203
  typeof value === "object" &&
@@ -244,5 +300,6 @@ export {
244
300
  deepMergeDefaults,
245
301
  extractZodDefaultAt,
246
302
  extractZodDefaults,
303
+ findZodSchemaAt,
247
304
  pickSchemaShape,
248
305
  }
@@ -1,7 +1,7 @@
1
1
  "use client"
2
2
 
3
3
  import { type AnyFormApi, evaluate } from "@tanstack/react-form"
4
- import { useCallback, useRef } from "react"
4
+ import { useCallback, useLayoutEffect, useRef } from "react"
5
5
 
6
6
  // TanStack's `useForm` calls `formApi.update(options)` after every render. While
7
7
  // the form is untouched, `update` re-applies `options.defaultValues` whenever it
@@ -24,14 +24,19 @@ function useRebasedDefaultValues<T>(callerDefaults: T): {
24
24
  const lastCallerDefaults = useRef(callerDefaults)
25
25
 
26
26
  let defaultValues = callerDefaults
27
- if (evaluate(lastCallerDefaults.current, callerDefaults)) {
28
- if (liveForm.current) {
29
- defaultValues = liveForm.current.options.defaultValues as T
30
- }
31
- } else {
32
- lastCallerDefaults.current = callerDefaults
27
+ if (
28
+ liveForm.current &&
29
+ evaluate(lastCallerDefaults.current, callerDefaults)
30
+ ) {
31
+ defaultValues = liveForm.current.options.defaultValues as T
33
32
  }
34
33
 
34
+ // Write on commit, not during render: under Strict Mode the second render
35
+ // would see the new defaults as unchanged and forward the old ones.
36
+ useLayoutEffect(() => {
37
+ lastCallerDefaults.current = callerDefaults
38
+ }, [callerDefaults])
39
+
35
40
  const onMount = useCallback((props: { formApi: AnyFormApi }) => {
36
41
  liveForm.current = props.formApi
37
42
  }, [])
@@ -0,0 +1,80 @@
1
+ import { cleanup, render, screen } from "@zeno-lib/test/testing-library"
2
+ import userEvent from "@zeno-lib/test/user-event"
3
+ import { afterEach, expect, test, vi } from "vitest"
4
+ import { z } from "zod"
5
+ import { Form, FormProvider, useForm } from "./create-form"
6
+
7
+ afterEach(cleanup)
8
+
9
+ test("a nullable text field holds null for no text, from the start and once emptied", async () => {
10
+ const user = userEvent.setup()
11
+ const schema = z.object({
12
+ note: z.string().nullable(),
13
+ optional: z.string().optional(),
14
+ title: z.string(),
15
+ })
16
+ const captured: { values?: unknown } = {}
17
+ function H() {
18
+ const form = useForm({ onSubmit: vi.fn(), schema })
19
+ const { InputField, Subscribe, TextAreaField } = form
20
+ return (
21
+ <FormProvider form={form}>
22
+ <Form>
23
+ <TextAreaField label="Note" name="note" />
24
+ <InputField label="Optional" name="optional" />
25
+ <InputField label="Title" name="title" />
26
+ <Subscribe selector={(state) => state.values}>
27
+ {(values) => {
28
+ captured.values = values
29
+ return null
30
+ }}
31
+ </Subscribe>
32
+ </Form>
33
+ </FormProvider>
34
+ )
35
+ }
36
+ render(<H />)
37
+ // An optional field keeps `""`: an update skips an `undefined` column, so
38
+ // emptying it would keep the stored text.
39
+ const empty = { note: null, optional: "", title: "" }
40
+ expect(captured.values).toStrictEqual(empty)
41
+
42
+ for (const label of ["Note", "Optional", "Title"]) {
43
+ // biome-ignore lint/performance/noAwaitInLoops: one field after another, as a user types
44
+ await user.type(screen.getByLabelText(label), "x")
45
+ await user.clear(screen.getByLabelText(label))
46
+ }
47
+ expect(captured.values).toStrictEqual(empty)
48
+ })
49
+
50
+ test("an emptied nullable text field holds null whatever the other fields hold", async () => {
51
+ const user = userEvent.setup()
52
+ // Once every field passes, the async refinement keeps the form from being
53
+ // checked synchronously.
54
+ const schema = z
55
+ .object({ note: z.string().nullable() })
56
+ .refine(async () => await Promise.resolve(true))
57
+ const captured: { values?: unknown } = {}
58
+ function H() {
59
+ const form = useForm({ onSubmit: vi.fn(), schema })
60
+ const { InputField, Subscribe } = form
61
+ return (
62
+ <FormProvider form={form}>
63
+ <Form>
64
+ <InputField label="Note" name="note" />
65
+ <Subscribe selector={(state) => state.values}>
66
+ {(values) => {
67
+ captured.values = values
68
+ return null
69
+ }}
70
+ </Subscribe>
71
+ </Form>
72
+ </FormProvider>
73
+ )
74
+ }
75
+ render(<H />)
76
+
77
+ await user.type(screen.getByRole("textbox"), "x")
78
+ await user.clear(screen.getByRole("textbox"))
79
+ expect(captured.values).toStrictEqual({ note: null })
80
+ })
@@ -65,30 +65,35 @@ function titleInput() {
65
65
  }
66
66
 
67
67
  describe("useForm — reset(values) rebases defaults", () => {
68
- test("reset after submit sticks across rerenders with fixed defaults", async () => {
69
- const user = userEvent.setup()
70
- let state: Captured | undefined
71
- const capture = vi.fn((next: Captured) => {
72
- state = next
73
- })
74
- const record = { title: "Draft" }
75
- const { rerender } = render(<EditTitle capture={capture} record={record} />)
68
+ for (const reactStrictMode of [false, true]) {
69
+ test(`reset after submit sticks across rerenders with fixed defaults (reactStrictMode: ${reactStrictMode})`, async () => {
70
+ const user = userEvent.setup()
71
+ let state: Captured | undefined
72
+ const capture = vi.fn((next: Captured) => {
73
+ state = next
74
+ })
75
+ const record = { title: "Draft" }
76
+ const { rerender } = render(
77
+ <EditTitle capture={capture} record={record} />,
78
+ { reactStrictMode }
79
+ )
76
80
 
77
- await user.clear(titleInput())
78
- await user.type(titleInput(), "Saved title")
79
- await user.click(screen.getByRole("button", { name: SAVE }))
81
+ await user.clear(titleInput())
82
+ await user.type(titleInput(), "Saved title")
83
+ await user.click(screen.getByRole("button", { name: SAVE }))
80
84
 
81
- await waitFor(() => expect(state?.isTouched).toBe(false))
82
- for (const tick of [1, 2, 3]) {
83
- rerender(<EditTitle capture={capture} record={record} tick={tick} />)
84
- }
85
- await act(() => Promise.resolve())
85
+ await waitFor(() => expect(state?.isTouched).toBe(false))
86
+ for (const tick of [1, 2, 3]) {
87
+ rerender(<EditTitle capture={capture} record={record} tick={tick} />)
88
+ }
89
+ await act(() => Promise.resolve())
86
90
 
87
- expect(titleInput().value).toBe("Saved title")
88
- expect(state?.values).toEqual({ title: "Saved title" })
89
- expect(state?.isDefaultValue).toBe(true)
90
- expect(state?.isDirty).toBe(false)
91
- })
91
+ expect(titleInput().value).toBe("Saved title")
92
+ expect(state?.values).toEqual({ title: "Saved title" })
93
+ expect(state?.isDefaultValue).toBe(true)
94
+ expect(state?.isDirty).toBe(false)
95
+ })
96
+ }
92
97
 
93
98
  test("new caller defaults apply while the form is untouched", async () => {
94
99
  let state: Captured | undefined