@zeno-lib/forms 0.9.1 → 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.
- package/dist/lib/empty-value.d.mts +9 -1
- package/dist/lib/empty-value.mjs +12 -1
- package/dist/lib/field-hints.d.mts +18 -0
- package/dist/lib/field-hints.mjs +24 -0
- package/dist/lib/schema-defaults.d.mts +6 -1
- package/dist/lib/schema-defaults.mjs +36 -3
- package/package.json +1 -1
- package/src/field-hints.test.tsx +118 -0
- package/src/fields/input-field.tsx +6 -4
- package/src/fields/percentage-field.tsx +16 -8
- package/src/fields/textarea-field.tsx +6 -4
- package/src/fields/year-field.tsx +8 -3
- package/src/lib/empty-value.ts +24 -1
- package/src/lib/field-hints.ts +64 -0
- package/src/lib/schema-defaults.test.ts +6 -3
- package/src/lib/schema-defaults.ts +60 -3
- package/src/nullable-text.test.tsx +80 -0
|
@@ -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 };
|
package/dist/lib/empty-value.mjs
CHANGED
|
@@ -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 };
|
package/package.json
CHANGED
|
@@ -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 `
|
|
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
|
|
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
|
|
44
|
+
allowNegative,
|
|
40
45
|
children,
|
|
41
46
|
fractionDigits = 2,
|
|
42
47
|
locale,
|
|
43
|
-
max,
|
|
44
|
-
min,
|
|
45
|
-
scale
|
|
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
|
|
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
|
|
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:
|
|
39
|
+
allowNegative: min !== undefined && min < 0,
|
|
35
40
|
max,
|
|
36
41
|
maxIntegerDigits: 4,
|
|
37
42
|
maximumFractionDigits: 0,
|
package/src/lib/empty-value.ts
CHANGED
|
@@ -171,4 +171,27 @@ function getEmptyValue<T>(field: AnyFieldApi, fallback: T): T {
|
|
|
171
171
|
) as T
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
-
|
|
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()
|
|
69
|
-
const schema = z.object({
|
|
70
|
-
|
|
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
|
}
|
|
@@ -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
|
+
})
|