@zeno-lib/forms 0.3.0 → 0.4.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/create-zeno-form.mjs +5 -3
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +3 -3
- package/dist/lib/empty-value.d.mts +12 -0
- package/dist/lib/empty-value.mjs +68 -0
- package/dist/lib/schema-defaults.d.mts +9 -1
- package/dist/lib/schema-defaults.mjs +33 -1
- package/dist/lib/schema-required.d.mts +2 -1
- package/dist/lib/schema-required.mjs +49 -21
- package/dist/lib/submit-action.mjs +1 -1
- package/dist/lib/use-is-invalid.d.mts +2 -1
- package/dist/lib/use-is-invalid.mjs +5 -2
- package/dist/lib/validation-modes.d.mts +5 -1
- package/dist/lib/validation-modes.mjs +9 -4
- package/package.json +1 -1
- package/src/create-zeno-form.tsx +13 -5
- package/src/fields/checkbox-field.tsx +8 -1
- package/src/fields/combobox-field.tsx +29 -12
- package/src/fields/date-picker-field.tsx +37 -9
- package/src/fields/email-field.tsx +8 -0
- package/src/fields/input-field.tsx +6 -2
- package/src/fields/money-field.tsx +3 -1
- package/src/fields/multi-select-field.tsx +8 -1
- package/src/fields/number-field.tsx +9 -7
- package/src/fields/otp-field.tsx +8 -1
- package/src/fields/percentage-field.tsx +3 -1
- package/src/fields/radio-group-field.tsx +11 -4
- package/src/fields/select-field.tsx +26 -3
- package/src/fields/switch-field.tsx +8 -1
- package/src/fields/textarea-field.tsx +8 -1
- package/src/fields/year-field.tsx +3 -1
- package/src/index.ts +1 -0
- package/src/lib/empty-value.test.ts +72 -0
- package/src/lib/empty-value.ts +130 -0
- package/src/lib/schema-defaults.ts +50 -1
- package/src/lib/schema-required.test.ts +24 -2
- package/src/lib/schema-required.ts +81 -43
- package/src/lib/use-is-invalid.ts +14 -1
- package/src/lib/validation-modes.ts +18 -6
- package/src/use-app-fields.test.tsx +312 -2
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { AnyFieldApi } from "@tanstack/react-form"
|
|
2
|
+
|
|
3
|
+
import { toFieldName } from "./action-result"
|
|
4
|
+
import { extractZodDefaultAt } from "./schema-defaults"
|
|
5
|
+
import { type StandardSchemaLike, validateSync } from "./schema-required"
|
|
6
|
+
import { getFormSchema } from "./validation-modes"
|
|
7
|
+
|
|
8
|
+
// A field's empty value is the first one its schema accepts of `null`,
|
|
9
|
+
// `undefined` and the type's own empty value (`""` for a string, `[]` for an
|
|
10
|
+
// array, through `extractZodDefaults`). So `.nullable()` empties to `null`,
|
|
11
|
+
// `.optional()` to `undefined`, and a bare `z.string()` to `""`. A field that
|
|
12
|
+
// accepts none of them is required. A cleared field holds its empty value,
|
|
13
|
+
// and so does an untouched one whose schema rejects `""` or `[]`.
|
|
14
|
+
|
|
15
|
+
const NAME_SEGMENT = /[^.[\]]+/g
|
|
16
|
+
const INDEX = /^\d+$/
|
|
17
|
+
|
|
18
|
+
// `members[2].name` (or `members.2.name`) → `["members", 2, "name"]`.
|
|
19
|
+
function toPathKeys(name: string): PropertyKey[] {
|
|
20
|
+
return (name.match(NAME_SEGMENT) ?? []).map((part) =>
|
|
21
|
+
INDEX.test(part) ? Number(part) : part
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function valueAt(root: unknown, keys: readonly PropertyKey[]): unknown {
|
|
26
|
+
let node = root
|
|
27
|
+
for (const key of keys) {
|
|
28
|
+
if (typeof node !== "object" || node === null) {
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
node = (node as Record<PropertyKey, unknown>)[key]
|
|
32
|
+
}
|
|
33
|
+
return node
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// A copy of `root` with `value` at `keys`, copying only the nodes on the way.
|
|
37
|
+
function withValueAt(
|
|
38
|
+
root: unknown,
|
|
39
|
+
keys: readonly PropertyKey[],
|
|
40
|
+
value: unknown
|
|
41
|
+
): unknown {
|
|
42
|
+
const [key, ...rest] = keys
|
|
43
|
+
if (key === undefined) {
|
|
44
|
+
return value
|
|
45
|
+
}
|
|
46
|
+
const node = (root ?? (typeof key === "number" ? [] : {})) as Record<
|
|
47
|
+
PropertyKey,
|
|
48
|
+
unknown
|
|
49
|
+
>
|
|
50
|
+
const copy = (Array.isArray(node) ? [...node] : { ...node }) as Record<
|
|
51
|
+
PropertyKey,
|
|
52
|
+
unknown
|
|
53
|
+
>
|
|
54
|
+
copy[key] = withValueAt(node[key], rest, value)
|
|
55
|
+
return copy
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function emptyValueFor(
|
|
59
|
+
schema: StandardSchemaLike | undefined,
|
|
60
|
+
values: unknown,
|
|
61
|
+
name: string,
|
|
62
|
+
fallback: unknown
|
|
63
|
+
): unknown {
|
|
64
|
+
if (!schema) {
|
|
65
|
+
return fallback
|
|
66
|
+
}
|
|
67
|
+
const keys = toPathKeys(name)
|
|
68
|
+
const fieldName = toFieldName(keys)
|
|
69
|
+
const typeEmpty = extractZodDefaultAt(
|
|
70
|
+
schema as Parameters<typeof extractZodDefaultAt>[0],
|
|
71
|
+
keys
|
|
72
|
+
)
|
|
73
|
+
const candidates = typeEmpty.ok
|
|
74
|
+
? [null, undefined, typeEmpty.value]
|
|
75
|
+
: [null, undefined]
|
|
76
|
+
for (const candidate of candidates) {
|
|
77
|
+
const issues = validateSync(schema, withValueAt(values, keys, candidate))
|
|
78
|
+
if (!issues) {
|
|
79
|
+
return fallback
|
|
80
|
+
}
|
|
81
|
+
if (!issues.some((issue) => toFieldName(issue.path) === fieldName)) {
|
|
82
|
+
return candidate
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return fallback
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isTypeEmpty(value: unknown): boolean {
|
|
89
|
+
return value === "" || (Array.isArray(value) && value.length === 0)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// `extractZodDefaults` starts every string at `""` and every array at `[]`.
|
|
93
|
+
// Where the schema rejects that (`z.uuid().nullable()`,
|
|
94
|
+
// `z.email().optional()`), start the field at its empty value instead, so an
|
|
95
|
+
// untouched optional field passes. A required one keeps `""` or `[]`.
|
|
96
|
+
function withEmptyStartValues(
|
|
97
|
+
schema: StandardSchemaLike,
|
|
98
|
+
defaults: Record<string, unknown>
|
|
99
|
+
): Record<string, unknown> {
|
|
100
|
+
const issues = validateSync(schema, defaults)
|
|
101
|
+
let result: unknown = defaults
|
|
102
|
+
for (const issue of issues ?? []) {
|
|
103
|
+
const name = toFieldName(issue.path)
|
|
104
|
+
const keys = toPathKeys(name)
|
|
105
|
+
const start = valueAt(defaults, keys)
|
|
106
|
+
if (keys.length === 0 || !isTypeEmpty(start)) {
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
const empty = emptyValueFor(schema, result, name, start)
|
|
110
|
+
if (empty !== start) {
|
|
111
|
+
result = withValueAt(result, keys, empty)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return result as Record<string, unknown>
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The value `field` takes when the user clears it, by the rule above. Typed as
|
|
119
|
+
* the field's value, since the same schema decides both.
|
|
120
|
+
*/
|
|
121
|
+
function getEmptyValue<T>(field: AnyFieldApi, fallback: T): T {
|
|
122
|
+
return emptyValueFor(
|
|
123
|
+
getFormSchema(field.form),
|
|
124
|
+
field.form.state.values,
|
|
125
|
+
field.name,
|
|
126
|
+
fallback
|
|
127
|
+
) as T
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export { emptyValueFor, getEmptyValue, withEmptyStartValues }
|
|
@@ -24,6 +24,7 @@ type SchemaDef = {
|
|
|
24
24
|
readonly innerType?: SchemaNode
|
|
25
25
|
readonly shape?: Record<string, SchemaNode>
|
|
26
26
|
readonly in?: SchemaNode
|
|
27
|
+
readonly element?: SchemaNode
|
|
27
28
|
readonly defaultValue?: unknown
|
|
28
29
|
}
|
|
29
30
|
|
|
@@ -91,6 +92,54 @@ function extractZodDefaults(schema: SchemaNode): Record<string, unknown> {
|
|
|
91
92
|
}
|
|
92
93
|
}
|
|
93
94
|
|
|
95
|
+
// Step from an object or array schema into its `key` child, through any
|
|
96
|
+
// wrapper around it. `null` when there is none.
|
|
97
|
+
function childAt(node: SchemaNode, key: PropertyKey): SchemaNode | null {
|
|
98
|
+
let current: SchemaNode | undefined = node
|
|
99
|
+
for (let depth = 0; depth < MAX_WRAPPER_DEPTH; depth += 1) {
|
|
100
|
+
const def: SchemaDef | undefined = getDef(current)
|
|
101
|
+
switch (def?.type) {
|
|
102
|
+
case "object":
|
|
103
|
+
return (typeof key === "string" && def.shape?.[key]) || null
|
|
104
|
+
case "array":
|
|
105
|
+
return (typeof key === "number" && def.element) || null
|
|
106
|
+
case "default":
|
|
107
|
+
case "nonoptional":
|
|
108
|
+
case "nullable":
|
|
109
|
+
case "optional":
|
|
110
|
+
case "prefault":
|
|
111
|
+
case "readonly":
|
|
112
|
+
current = def.innerType
|
|
113
|
+
continue
|
|
114
|
+
case "pipe":
|
|
115
|
+
current = def.in
|
|
116
|
+
continue
|
|
117
|
+
default:
|
|
118
|
+
return null
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// The value `extractZodDefaults` starts the field at `keys` at, array rows
|
|
125
|
+
// included (`["members", 2, "name"]`). `{ ok: false }` when it gives none.
|
|
126
|
+
function extractZodDefaultAt(
|
|
127
|
+
schema: SchemaNode,
|
|
128
|
+
keys: readonly PropertyKey[]
|
|
129
|
+
): ExtractResult {
|
|
130
|
+
try {
|
|
131
|
+
let node: SchemaNode | null = schema
|
|
132
|
+
for (const key of keys) {
|
|
133
|
+
node = node && childAt(node, key)
|
|
134
|
+
}
|
|
135
|
+
return node && keys.length > 0
|
|
136
|
+
? extractDefaultForField(node)
|
|
137
|
+
: { ok: false }
|
|
138
|
+
} catch {
|
|
139
|
+
return { ok: false }
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
94
143
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
95
144
|
return (
|
|
96
145
|
typeof value === "object" &&
|
|
@@ -124,4 +173,4 @@ function deepMergeDefaults(
|
|
|
124
173
|
return result
|
|
125
174
|
}
|
|
126
175
|
|
|
127
|
-
export { deepMergeDefaults, extractZodDefaults }
|
|
176
|
+
export { deepMergeDefaults, extractZodDefaultAt, extractZodDefaults }
|
|
@@ -58,6 +58,22 @@ describe("getRequiredPaths", () => {
|
|
|
58
58
|
expect(required.has("members[0].name")).toBe(true)
|
|
59
59
|
})
|
|
60
60
|
|
|
61
|
+
test("a field that accepts null is not required", () => {
|
|
62
|
+
const schema = z.object({
|
|
63
|
+
count: z.number(),
|
|
64
|
+
day: z.date().nullable(),
|
|
65
|
+
kind: z.enum(["a", "b"]).nullable(),
|
|
66
|
+
ownerId: z.uuid().nullable(),
|
|
67
|
+
total: z.number().nullable(),
|
|
68
|
+
})
|
|
69
|
+
const required = getRequiredPaths(schema)
|
|
70
|
+
expect(required.has("count")).toBe(true)
|
|
71
|
+
expect(required.has("day")).toBe(false)
|
|
72
|
+
expect(required.has("kind")).toBe(false)
|
|
73
|
+
expect(required.has("ownerId")).toBe(false)
|
|
74
|
+
expect(required.has("total")).toBe(false)
|
|
75
|
+
})
|
|
76
|
+
|
|
61
77
|
test("nested z.object reports the parent and its required children", () => {
|
|
62
78
|
const schema = z.object({
|
|
63
79
|
profile: z.object({
|
|
@@ -80,20 +96,26 @@ describe("getRequiredPaths", () => {
|
|
|
80
96
|
|
|
81
97
|
test("array rows are reported with TanStack bracket syntax", () => {
|
|
82
98
|
const schema = z.object({
|
|
99
|
+
emails: z.array(z.email()),
|
|
83
100
|
members: z.array(
|
|
84
101
|
z.object({
|
|
85
102
|
name: z.string().min(1),
|
|
86
103
|
note: z.string().optional(),
|
|
87
104
|
})
|
|
88
105
|
),
|
|
106
|
+
picked: z.array(z.string()).min(1),
|
|
89
107
|
tags: z.array(z.string()),
|
|
90
108
|
})
|
|
91
109
|
const required = getRequiredPaths(schema)
|
|
92
|
-
expect(required.has("members")).toBe(true)
|
|
93
110
|
expect(required.has("members[0].name")).toBe(true)
|
|
94
111
|
expect(required.has("members.0.name")).toBe(false)
|
|
95
112
|
expect(required.has("members[0].note")).toBe(false)
|
|
96
|
-
expect(required.has("
|
|
113
|
+
expect(required.has("emails[0]")).toBe(true)
|
|
114
|
+
expect(required.has("picked")).toBe(true)
|
|
115
|
+
// `[]` and `""` are what the form starts them at, and both pass.
|
|
116
|
+
expect(required.has("members")).toBe(false)
|
|
117
|
+
expect(required.has("tags")).toBe(false)
|
|
118
|
+
expect(required.has("tags[0]")).toBe(false)
|
|
97
119
|
})
|
|
98
120
|
|
|
99
121
|
test("nested arrays descend through every level", () => {
|
|
@@ -9,14 +9,17 @@
|
|
|
9
9
|
// A missing parent hides its children (the schema stops at the missing key), so
|
|
10
10
|
// the probe descends: when an issue says it expected an object or an array
|
|
11
11
|
// (Zod and Valibot both put that on `issue.expected`), the next pass fills that
|
|
12
|
-
// path
|
|
13
|
-
//
|
|
14
|
-
//
|
|
12
|
+
// path and validates again. An object gets `{}`. An array gets one missing row
|
|
13
|
+
// (`[undefined]`), so the following pass probes the row's own schema. Rows are
|
|
14
|
+
// probed through index `0` and recorded index-agnostically, so
|
|
15
|
+
// `members[0].name` in the set matches every row's `members[3].name` via
|
|
16
|
+
// `toRequiredPathKey`.
|
|
15
17
|
//
|
|
16
|
-
// A missing
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
// (`.min(1)`,
|
|
18
|
+
// A missing field is then probed with an empty value: `""` for a string, then
|
|
19
|
+
// `null`; `[]` for an array; `null` for anything else. A field that accepts one
|
|
20
|
+
// (a bare `z.string()` or `z.array()`, anything `.nullable()`) leaves the set,
|
|
21
|
+
// matching `lib/empty-value.ts`; one that rejects them all (`.min(1)`,
|
|
22
|
+
// `z.email()`, a bare `z.number()`) stays.
|
|
20
23
|
//
|
|
21
24
|
// Paths use TanStack Form's field-name syntax: dots for object keys, brackets
|
|
22
25
|
// for array indices (`members[0].name`, not `members.0.name`).
|
|
@@ -47,7 +50,8 @@ type StandardSchemaLike = {
|
|
|
47
50
|
}
|
|
48
51
|
}
|
|
49
52
|
|
|
50
|
-
|
|
53
|
+
// Each level can take two passes: the empty value, then the descent.
|
|
54
|
+
const MAX_PROBE_DEPTH = 16
|
|
51
55
|
|
|
52
56
|
const INDEX_SEGMENT = /^\d+$/
|
|
53
57
|
|
|
@@ -92,26 +96,25 @@ function toRequiredPathKey(name: string): string {
|
|
|
92
96
|
}
|
|
93
97
|
|
|
94
98
|
function probeValue(expected: unknown): unknown {
|
|
95
|
-
|
|
96
|
-
return
|
|
97
|
-
}
|
|
98
|
-
switch (expected.toLowerCase()) {
|
|
99
|
+
switch (typeof expected === "string" ? expected.toLowerCase() : undefined) {
|
|
99
100
|
case "object":
|
|
100
101
|
return {}
|
|
101
102
|
case "array":
|
|
102
|
-
return [
|
|
103
|
+
return []
|
|
103
104
|
case "string":
|
|
104
105
|
return ""
|
|
105
106
|
default:
|
|
106
|
-
return
|
|
107
|
+
return null
|
|
107
108
|
}
|
|
108
109
|
}
|
|
109
110
|
|
|
110
|
-
// Write `value` at `keys` inside `root`, only where nothing is set yet
|
|
111
|
+
// Write `value` at `keys` inside `root`, only where nothing is set yet unless
|
|
112
|
+
// `replace` is set.
|
|
111
113
|
function fillAt(
|
|
112
114
|
root: Record<PropertyKey, unknown>,
|
|
113
115
|
keys: readonly PropertyKey[],
|
|
114
|
-
value: unknown
|
|
116
|
+
value: unknown,
|
|
117
|
+
replace = false
|
|
115
118
|
): boolean {
|
|
116
119
|
let node: Record<PropertyKey, unknown> = root
|
|
117
120
|
for (const key of keys.slice(0, -1)) {
|
|
@@ -122,7 +125,7 @@ function fillAt(
|
|
|
122
125
|
node = next as Record<PropertyKey, unknown>
|
|
123
126
|
}
|
|
124
127
|
const last = keys.at(-1) as PropertyKey
|
|
125
|
-
if (node[last] !== undefined) {
|
|
128
|
+
if (!replace && node[last] !== undefined) {
|
|
126
129
|
return false
|
|
127
130
|
}
|
|
128
131
|
node[last] = value
|
|
@@ -145,22 +148,27 @@ function validateSync(
|
|
|
145
148
|
return "issues" in result && result.issues ? result.issues : []
|
|
146
149
|
}
|
|
147
150
|
|
|
151
|
+
type ProbeFill = {
|
|
152
|
+
readonly keys: readonly PropertyKey[]
|
|
153
|
+
readonly path: string
|
|
154
|
+
readonly value: unknown
|
|
155
|
+
}
|
|
156
|
+
|
|
148
157
|
type ProbePass = {
|
|
149
|
-
readonly descended: boolean
|
|
150
158
|
readonly failing: ReadonlySet<string>
|
|
151
|
-
readonly
|
|
159
|
+
readonly filled: readonly ProbeFill[]
|
|
152
160
|
}
|
|
153
161
|
|
|
154
162
|
// Record one pass's failing paths, and fill the probe at each of them for the
|
|
155
|
-
// next pass.
|
|
163
|
+
// next pass. A path whose empty value already passed stays out of the set.
|
|
156
164
|
function recordPass(
|
|
157
165
|
issues: readonly StandardIssue[],
|
|
158
166
|
probe: Record<PropertyKey, unknown>,
|
|
159
|
-
required: Set<string
|
|
167
|
+
required: Set<string>,
|
|
168
|
+
cleared: ReadonlySet<string>
|
|
160
169
|
): ProbePass {
|
|
161
170
|
const failing = new Set<string>()
|
|
162
|
-
const
|
|
163
|
-
let descended = false
|
|
171
|
+
const filled: ProbeFill[] = []
|
|
164
172
|
for (const issue of issues) {
|
|
165
173
|
const keys = issue.path?.map(pathKey) ?? []
|
|
166
174
|
if (keys.length === 0) {
|
|
@@ -168,40 +176,70 @@ function recordPass(
|
|
|
168
176
|
}
|
|
169
177
|
const path = joinPath(keys)
|
|
170
178
|
failing.add(path)
|
|
171
|
-
|
|
179
|
+
if (!cleared.has(path)) {
|
|
180
|
+
required.add(path)
|
|
181
|
+
}
|
|
172
182
|
const value = probeValue(issue.expected)
|
|
173
183
|
if (value !== undefined && fillAt(probe, keys, value)) {
|
|
184
|
+
filled.push({ keys, path, value })
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return { failing, filled }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function isEmptyProbe(value: unknown): boolean {
|
|
191
|
+
return value === "" || value === null || Array.isArray(value)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Settle what the previous pass filled with an empty value: a path that now
|
|
195
|
+
// passes leaves the required set. A string that rejects `""` gets `null` next,
|
|
196
|
+
// and an array checked empty gets one missing row, so the row is probed.
|
|
197
|
+
function settleEmptied(
|
|
198
|
+
emptied: readonly ProbeFill[],
|
|
199
|
+
failing: ReadonlySet<string>,
|
|
200
|
+
probe: Record<PropertyKey, unknown>,
|
|
201
|
+
required: Set<string>,
|
|
202
|
+
cleared: Set<string>
|
|
203
|
+
): { readonly next: ProbeFill[]; readonly descended: boolean } {
|
|
204
|
+
const next: ProbeFill[] = []
|
|
205
|
+
let descended = false
|
|
206
|
+
for (const fill of emptied) {
|
|
207
|
+
const passed = !failing.has(fill.path)
|
|
208
|
+
if (passed) {
|
|
209
|
+
required.delete(fill.path)
|
|
210
|
+
cleared.add(fill.path)
|
|
211
|
+
}
|
|
212
|
+
if (Array.isArray(fill.value)) {
|
|
213
|
+
fill.value.push(undefined)
|
|
214
|
+
descended = true
|
|
215
|
+
} else if (!passed && fill.value === "") {
|
|
216
|
+
fillAt(probe, fill.keys, null, true)
|
|
217
|
+
next.push({ ...fill, value: null })
|
|
174
218
|
descended = true
|
|
175
|
-
if (value === "") {
|
|
176
|
-
filledStrings.push(path)
|
|
177
|
-
}
|
|
178
219
|
}
|
|
179
220
|
}
|
|
180
|
-
return { descended,
|
|
221
|
+
return { descended, next }
|
|
181
222
|
}
|
|
182
223
|
|
|
183
224
|
function getRequiredPaths(schema: StandardSchemaLike): Set<string> {
|
|
184
225
|
const required = new Set<string>()
|
|
226
|
+
const cleared = new Set<string>()
|
|
185
227
|
const probe: Record<PropertyKey, unknown> = {}
|
|
186
|
-
//
|
|
187
|
-
|
|
228
|
+
// What the previous pass filled with an empty value: each stays required
|
|
229
|
+
// only if it still fails.
|
|
230
|
+
let emptied: readonly ProbeFill[] = []
|
|
188
231
|
for (let depth = 0; depth < MAX_PROBE_DEPTH; depth += 1) {
|
|
189
232
|
const issues = validateSync(schema, probe)
|
|
190
233
|
if (!issues) {
|
|
191
234
|
return required
|
|
192
235
|
}
|
|
193
|
-
const {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
required.delete(path)
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
filledStrings = filled
|
|
204
|
-
if (!descended) {
|
|
236
|
+
const { failing, filled } = recordPass(issues, probe, required, cleared)
|
|
237
|
+
const settled = settleEmptied(emptied, failing, probe, required, cleared)
|
|
238
|
+
emptied = [
|
|
239
|
+
...filled.filter(({ value }) => isEmptyProbe(value)),
|
|
240
|
+
...settled.next,
|
|
241
|
+
]
|
|
242
|
+
if (filled.length === 0 && !settled.descended) {
|
|
205
243
|
break
|
|
206
244
|
}
|
|
207
245
|
}
|
|
@@ -209,4 +247,4 @@ function getRequiredPaths(schema: StandardSchemaLike): Set<string> {
|
|
|
209
247
|
}
|
|
210
248
|
|
|
211
249
|
export type { StandardSchemaLike }
|
|
212
|
-
export { getRequiredPaths, toRequiredPathKey }
|
|
250
|
+
export { getRequiredPaths, toRequiredPathKey, validateSync }
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
getFormHideFieldErrors,
|
|
7
7
|
getFormValidationMode,
|
|
8
8
|
isFieldRequired,
|
|
9
|
+
isFieldRequiredBySchema,
|
|
9
10
|
type ValidationMode,
|
|
10
11
|
} from "./validation-modes"
|
|
11
12
|
|
|
@@ -106,4 +107,16 @@ function useIsFieldRequired(field: AnyFieldApi): boolean {
|
|
|
106
107
|
return isFieldRequired(field.form, field.name)
|
|
107
108
|
}
|
|
108
109
|
|
|
109
|
-
|
|
110
|
+
// Returns whether the form's schema requires this field, even with
|
|
111
|
+
// `requiredIndicator: false`. Drives what a required field does (no clear
|
|
112
|
+
// button, `aria-required`), where `useIsFieldRequired` drives the `*`.
|
|
113
|
+
function useIsFieldRequiredBySchema(field: AnyFieldApi): boolean {
|
|
114
|
+
return isFieldRequiredBySchema(field.form, field.name)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export {
|
|
118
|
+
useHideFieldErrors,
|
|
119
|
+
useIsFieldRequired,
|
|
120
|
+
useIsFieldRequiredBySchema,
|
|
121
|
+
useIsInvalid,
|
|
122
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import type { AnyFormApi } from "@tanstack/react-form"
|
|
4
4
|
|
|
5
|
-
import { toRequiredPathKey } from "./schema-required"
|
|
5
|
+
import { type StandardSchemaLike, toRequiredPathKey } from "./schema-required"
|
|
6
6
|
|
|
7
7
|
type ValidationMode = "change" | "blur" | "submit" | "blur-then-change"
|
|
8
8
|
|
|
@@ -13,6 +13,7 @@ type ZenoFormState = {
|
|
|
13
13
|
hideFieldErrors: boolean
|
|
14
14
|
requiredIndicator: boolean
|
|
15
15
|
requiredFields: ReadonlySet<string>
|
|
16
|
+
schema: StandardSchemaLike | undefined
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
const EMPTY_REQUIRED_FIELDS: ReadonlySet<string> = new Set<string>()
|
|
@@ -21,6 +22,7 @@ const DEFAULT_FORM_STATE: ZenoFormState = {
|
|
|
21
22
|
hideFieldErrors: false,
|
|
22
23
|
requiredFields: EMPTY_REQUIRED_FIELDS,
|
|
23
24
|
requiredIndicator: true,
|
|
25
|
+
schema: undefined,
|
|
24
26
|
validation: DEFAULT_VALIDATION_MODE,
|
|
25
27
|
}
|
|
26
28
|
|
|
@@ -57,19 +59,29 @@ function getFormHideFieldErrors(form: AnyFormApi): boolean {
|
|
|
57
59
|
return getFormZenoState(form).hideFieldErrors
|
|
58
60
|
}
|
|
59
61
|
|
|
62
|
+
// Whether the schema requires the field, whatever `requiredIndicator` says.
|
|
63
|
+
function getFormSchema(form: AnyFormApi): StandardSchemaLike | undefined {
|
|
64
|
+
return getFormZenoState(form).schema
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isFieldRequiredBySchema(form: AnyFormApi, name: string): boolean {
|
|
68
|
+
return getFormZenoState(form).requiredFields.has(toRequiredPathKey(name))
|
|
69
|
+
}
|
|
70
|
+
|
|
60
71
|
function isFieldRequired(form: AnyFormApi, name: string): boolean {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
return state.requiredFields.has(toRequiredPathKey(name))
|
|
72
|
+
return (
|
|
73
|
+
getFormZenoState(form).requiredIndicator &&
|
|
74
|
+
isFieldRequiredBySchema(form, name)
|
|
75
|
+
)
|
|
66
76
|
}
|
|
67
77
|
|
|
68
78
|
export {
|
|
69
79
|
DEFAULT_VALIDATION_MODE,
|
|
70
80
|
getFormHideFieldErrors,
|
|
81
|
+
getFormSchema,
|
|
71
82
|
getFormValidationMode,
|
|
72
83
|
isFieldRequired,
|
|
84
|
+
isFieldRequiredBySchema,
|
|
73
85
|
setFormZenoState,
|
|
74
86
|
type ValidationMode,
|
|
75
87
|
}
|