@zeno-lib/forms 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/create-zeno-form.d.mts +19 -4
- package/dist/create-zeno-form.mjs +29 -6
- package/dist/form-element.d.mts +16 -3
- package/dist/form-element.mjs +38 -18
- package/dist/index.d.mts +2 -1
- package/dist/index.mjs +2 -1
- package/dist/lib/date-value.d.mts +24 -0
- package/dist/lib/date-value.mjs +37 -0
- package/dist/lib/default-props.d.mts +7 -0
- package/dist/lib/default-props.mjs +13 -0
- package/dist/lib/empty-value.mjs +25 -1
- package/dist/lib/form-disabled.d.mts +28 -0
- package/dist/lib/form-disabled.mjs +54 -0
- package/dist/lib/item-options.d.mts +45 -0
- package/dist/lib/item-options.mjs +70 -0
- package/dist/lib/schema-required.mjs +17 -0
- package/dist/lib/use-field-control.d.mts +7 -0
- package/dist/lib/use-field-control.mjs +4 -1
- package/package.json +1 -1
- package/src/create-form.tsx +34 -29
- package/src/create-zeno-form.tsx +124 -51
- package/src/date-picker-field.test.tsx +108 -0
- package/src/default-props.test-d.ts +66 -0
- package/src/default-props.test.tsx +165 -0
- package/src/fields/checkbox-field.tsx +5 -0
- package/src/fields/checkbox-group-field.tsx +6 -2
- package/src/fields/combobox-field.tsx +107 -40
- package/src/fields/date-picker-field.tsx +34 -11
- package/src/fields/field-shell.tsx +8 -1
- package/src/fields/input-field.tsx +2 -0
- package/src/fields/multi-select-field.tsx +100 -42
- package/src/fields/otp-field.tsx +8 -2
- package/src/fields/radio-group-field.tsx +9 -1
- package/src/fields/reset-button.tsx +4 -0
- package/src/fields/select-field.tsx +9 -1
- package/src/fields/slider-field.tsx +9 -1
- package/src/fields/submit-button.tsx +3 -1
- package/src/fields/switch-field.tsx +5 -0
- package/src/fields/textarea-field.tsx +2 -0
- package/src/form-dialog.test.tsx +102 -1
- package/src/form-dialog.tsx +143 -42
- package/src/form-disabled.test.tsx +329 -0
- package/src/form-element.tsx +64 -29
- package/src/grouped-options.test-d.ts +59 -0
- package/src/grouped-options.test.tsx +251 -0
- package/src/index.ts +1 -0
- package/src/lib/date-value.test.ts +14 -0
- package/src/lib/date-value.ts +70 -0
- package/src/lib/default-props.ts +19 -0
- package/src/lib/empty-value.test.ts +19 -0
- package/src/lib/empty-value.ts +45 -1
- package/src/lib/form-disabled.ts +95 -0
- package/src/lib/item-options.test.ts +11 -0
- package/src/lib/item-options.ts +165 -0
- package/src/lib/schema-required.test.ts +9 -0
- package/src/lib/schema-required.ts +33 -0
- package/src/lib/use-field-control.ts +11 -1
- package/src/with-form.test-d.ts +76 -0
- package/src/with-form.test.tsx +60 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import type { AnyFormApi } from "@tanstack/react-form"
|
|
4
|
+
import {
|
|
5
|
+
createContext,
|
|
6
|
+
useCallback,
|
|
7
|
+
useContext,
|
|
8
|
+
useLayoutEffect,
|
|
9
|
+
useSyncExternalStore,
|
|
10
|
+
} from "react"
|
|
11
|
+
|
|
12
|
+
import { formContext } from "./contexts"
|
|
13
|
+
|
|
14
|
+
// `useForm({ disabled })`, per form. Keyed by `form.store`, as the rest of the
|
|
15
|
+
// per-form Zeno state is (`validation-modes.ts`), and read through
|
|
16
|
+
// `useSyncExternalStore`, so a toggle re-renders every field, a memoized one
|
|
17
|
+
// or one with no provider above it included.
|
|
18
|
+
const disabledOption = new WeakMap<object, boolean>()
|
|
19
|
+
const listeners = new WeakMap<object, Set<() => void>>()
|
|
20
|
+
|
|
21
|
+
function subscribe(key: object, listener: () => void): () => void {
|
|
22
|
+
let set = listeners.get(key)
|
|
23
|
+
if (!set) {
|
|
24
|
+
set = new Set()
|
|
25
|
+
listeners.set(key, set)
|
|
26
|
+
}
|
|
27
|
+
set.add(listener)
|
|
28
|
+
return () => {
|
|
29
|
+
set.delete(listener)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Apply `useForm({ disabled })` to `form`. */
|
|
34
|
+
function useDisabledOption(form: AnyFormApi, disabled: boolean): void {
|
|
35
|
+
const key = form.store
|
|
36
|
+
// Seeded while rendering, so the first render, the server's included,
|
|
37
|
+
// already has it. A change lands on commit, so a render React drops (a
|
|
38
|
+
// suspended transition) never reaches the fields.
|
|
39
|
+
if (!disabledOption.has(key)) {
|
|
40
|
+
disabledOption.set(key, disabled)
|
|
41
|
+
}
|
|
42
|
+
useLayoutEffect(() => {
|
|
43
|
+
if (disabledOption.get(key) === disabled) {
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
disabledOption.set(key, disabled)
|
|
47
|
+
for (const listener of listeners.get(key) ?? []) {
|
|
48
|
+
listener()
|
|
49
|
+
}
|
|
50
|
+
}, [key, disabled])
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type FormDisabledScope = {
|
|
54
|
+
readonly disabled: boolean
|
|
55
|
+
/** The `store` of the form it disables. */
|
|
56
|
+
readonly key: object
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The disabled state `FormProvider` and `<Form>` give what they wrap, for
|
|
61
|
+
* their own form only: a field of another form rendered inside (a quick-add
|
|
62
|
+
* popover) keeps that form's state.
|
|
63
|
+
*/
|
|
64
|
+
const FormDisabledContext = createContext<FormDisabledScope | null>(null)
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Whether the form is disabled, by `useForm({ disabled })`,
|
|
68
|
+
* `<FormProvider disabled>`, `<Form disabled>` or `FormDialog`'s `disabled`.
|
|
69
|
+
* Every kit field reads it, so a control the fieldset of a disabled dialog
|
|
70
|
+
* can't reach (a checkbox, a switch, a radio, a slider) turns off too.
|
|
71
|
+
*
|
|
72
|
+
* Reads the form of the nearest provider; inside a field, pass `field.form`.
|
|
73
|
+
* `useForm({ disabled })` applies even where no provider of the form is
|
|
74
|
+
* mounted.
|
|
75
|
+
*/
|
|
76
|
+
function useIsFormDisabled(form?: AnyFormApi): boolean {
|
|
77
|
+
const scope = useContext(FormDisabledContext)
|
|
78
|
+
const contextForm = useContext(formContext) as AnyFormApi | null
|
|
79
|
+
const key = (form ?? contextForm)?.store
|
|
80
|
+
const subscribeToKey = useCallback(
|
|
81
|
+
(listener: () => void) => (key ? subscribe(key, listener) : () => null),
|
|
82
|
+
[key]
|
|
83
|
+
)
|
|
84
|
+
const getSnapshot = () => (key ? (disabledOption.get(key) ?? false) : false)
|
|
85
|
+
const byOption = useSyncExternalStore(
|
|
86
|
+
subscribeToKey,
|
|
87
|
+
getSnapshot,
|
|
88
|
+
getSnapshot
|
|
89
|
+
)
|
|
90
|
+
const byScope =
|
|
91
|
+
scope?.disabled === true && (key === undefined || scope.key === key)
|
|
92
|
+
return byScope || byOption
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export { FormDisabledContext, useDisabledOption, useIsFormDisabled }
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest"
|
|
2
|
+
|
|
3
|
+
import { valueKey } from "./item-options"
|
|
4
|
+
|
|
5
|
+
describe("valueKey", () => {
|
|
6
|
+
test("keeps values that print alike apart, so React keys don't collide", () => {
|
|
7
|
+
expect(valueKey(1)).not.toBe(valueKey("1"))
|
|
8
|
+
expect(valueKey(null)).not.toBe(valueKey("null"))
|
|
9
|
+
expect(valueKey({ id: 1 })).not.toBe(valueKey('{"id":1}'))
|
|
10
|
+
})
|
|
11
|
+
})
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// The items of `ComboboxField` and `MultiSelectField`: plain values, which
|
|
2
|
+
// are their own label, `{ value, label }` objects, or `{ label, items }`
|
|
3
|
+
// groups of either.
|
|
4
|
+
|
|
5
|
+
/** An item whose value isn't its label. */
|
|
6
|
+
type ItemObject<V> = { value: V; label: string }
|
|
7
|
+
|
|
8
|
+
/** Items under a heading. */
|
|
9
|
+
type ItemGroup<T> = { label: string; items: readonly T[] }
|
|
10
|
+
|
|
11
|
+
/** An item of `items`, or of one of its groups. */
|
|
12
|
+
type ItemOption<T> = T extends ItemGroup<infer I> ? I : T
|
|
13
|
+
|
|
14
|
+
/** What picking an item stores: `item.value` for an object, else the item. */
|
|
15
|
+
type ItemValue<T> =
|
|
16
|
+
T extends ItemGroup<infer I>
|
|
17
|
+
? ItemValue<I>
|
|
18
|
+
: T extends ItemObject<infer V>
|
|
19
|
+
? V
|
|
20
|
+
: T
|
|
21
|
+
|
|
22
|
+
/** Whether an item's value is a stored one, as `isItemEqualToValue` says. */
|
|
23
|
+
type ValueComparator = (itemValue: unknown, value: unknown) => boolean
|
|
24
|
+
|
|
25
|
+
function isItemObject(item: unknown): item is ItemObject<unknown> {
|
|
26
|
+
return (
|
|
27
|
+
typeof item === "object" &&
|
|
28
|
+
item !== null &&
|
|
29
|
+
"value" in item &&
|
|
30
|
+
"label" in item
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** What Base UI takes for a group: an object with an `items` array. */
|
|
35
|
+
function isItemGroup(item: unknown): item is ItemGroup<unknown> {
|
|
36
|
+
return (
|
|
37
|
+
typeof item === "object" &&
|
|
38
|
+
item !== null &&
|
|
39
|
+
Array.isArray((item as { items?: unknown }).items)
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function itemValue(item: unknown): unknown {
|
|
44
|
+
return isItemObject(item) ? item.value : item
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function itemLabel(item: unknown): string {
|
|
48
|
+
return isItemObject(item) ? item.label : String(item)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A React key per value: `1` and `"1"` get two, an object gets its JSON. */
|
|
52
|
+
function valueKey(value: unknown): string {
|
|
53
|
+
if (typeof value !== "object" || value === null) {
|
|
54
|
+
return `${typeof value}:${String(value)}`
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
return `object:${JSON.stringify(value)}`
|
|
58
|
+
} catch {
|
|
59
|
+
return `object:${String(value)}`
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
type ItemList<T> = {
|
|
64
|
+
/**
|
|
65
|
+
* `items` as Base UI takes them. It tells a grouped list by its first entry
|
|
66
|
+
* alone, so once there is a group, each run of plain items beside one goes
|
|
67
|
+
* under a group with no heading (`label: ""`).
|
|
68
|
+
*/
|
|
69
|
+
readonly items: readonly T[]
|
|
70
|
+
/** Every option, the grouped ones included, in order. */
|
|
71
|
+
readonly options: readonly ItemOption<T>[]
|
|
72
|
+
/** The first option of each `valueKey`, so finding a value is no scan. */
|
|
73
|
+
readonly byKey: ReadonlyMap<string, ItemOption<T>>
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function toItemList<T>(items: readonly T[]): ItemList<T> {
|
|
77
|
+
const hasGroup = items.some(isItemGroup)
|
|
78
|
+
const options: ItemOption<T>[] = []
|
|
79
|
+
const groups: ItemGroup<unknown>[] = []
|
|
80
|
+
let run: unknown[] | undefined
|
|
81
|
+
for (const item of items) {
|
|
82
|
+
if (isItemGroup(item)) {
|
|
83
|
+
for (const option of item.items) {
|
|
84
|
+
options.push(option as ItemOption<T>)
|
|
85
|
+
}
|
|
86
|
+
groups.push(item)
|
|
87
|
+
run = undefined
|
|
88
|
+
} else {
|
|
89
|
+
options.push(item as ItemOption<T>)
|
|
90
|
+
if (hasGroup && !run) {
|
|
91
|
+
run = []
|
|
92
|
+
groups.push({ items: run, label: "" })
|
|
93
|
+
}
|
|
94
|
+
run?.push(item)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const byKey = new Map<string, ItemOption<T>>()
|
|
98
|
+
for (const option of options) {
|
|
99
|
+
const key = valueKey(itemValue(option))
|
|
100
|
+
if (!byKey.has(key)) {
|
|
101
|
+
byKey.set(key, option)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
byKey,
|
|
106
|
+
items: hasGroup ? (groups as unknown as readonly T[]) : items,
|
|
107
|
+
options,
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* `Object.is`, then `isItemEqualToValue`, which never sees an empty value.
|
|
113
|
+
*/
|
|
114
|
+
function isSameValue(
|
|
115
|
+
a: unknown,
|
|
116
|
+
b: unknown,
|
|
117
|
+
isItemEqualToValue: ValueComparator | undefined
|
|
118
|
+
): boolean {
|
|
119
|
+
if (Object.is(a, b)) {
|
|
120
|
+
return true
|
|
121
|
+
}
|
|
122
|
+
if (a === null || a === undefined || b === null || b === undefined) {
|
|
123
|
+
return false
|
|
124
|
+
}
|
|
125
|
+
return isItemEqualToValue?.(a, b) ?? false
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The option whose value is `value`, if `items` has it. */
|
|
129
|
+
function findOption<T>(
|
|
130
|
+
list: ItemList<T>,
|
|
131
|
+
value: unknown,
|
|
132
|
+
isItemEqualToValue: ValueComparator | undefined
|
|
133
|
+
): ItemOption<T> | undefined {
|
|
134
|
+
const hit = list.byKey.get(valueKey(value))
|
|
135
|
+
if (
|
|
136
|
+
hit !== undefined &&
|
|
137
|
+
isSameValue(itemValue(hit), value, isItemEqualToValue)
|
|
138
|
+
) {
|
|
139
|
+
return hit
|
|
140
|
+
}
|
|
141
|
+
// A comparator can match values whose keys differ (`{ id }` against
|
|
142
|
+
// `{ id, name }`).
|
|
143
|
+
return list.options.find((option) =>
|
|
144
|
+
isSameValue(itemValue(option), value, isItemEqualToValue)
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export type {
|
|
149
|
+
ItemGroup,
|
|
150
|
+
ItemList,
|
|
151
|
+
ItemObject,
|
|
152
|
+
ItemOption,
|
|
153
|
+
ItemValue,
|
|
154
|
+
ValueComparator,
|
|
155
|
+
}
|
|
156
|
+
export {
|
|
157
|
+
findOption,
|
|
158
|
+
isItemGroup,
|
|
159
|
+
isItemObject,
|
|
160
|
+
isSameValue,
|
|
161
|
+
itemLabel,
|
|
162
|
+
itemValue,
|
|
163
|
+
toItemList,
|
|
164
|
+
valueKey,
|
|
165
|
+
}
|
|
@@ -87,6 +87,15 @@ describe("getRequiredPaths", () => {
|
|
|
87
87
|
expect(required.has("profile.bio")).toBe(false)
|
|
88
88
|
})
|
|
89
89
|
|
|
90
|
+
test("a nullable object is not required, though its own fields are", () => {
|
|
91
|
+
const schema = z.object({
|
|
92
|
+
payer: z.object({ id: z.number(), kind: z.string().min(1) }).nullable(),
|
|
93
|
+
})
|
|
94
|
+
const required = getRequiredPaths(schema)
|
|
95
|
+
expect(required.has("payer")).toBe(false)
|
|
96
|
+
expect(required.has("payer.kind")).toBe(true)
|
|
97
|
+
})
|
|
98
|
+
|
|
90
99
|
test("optional nested object is not descended into", () => {
|
|
91
100
|
const schema = z.object({
|
|
92
101
|
profile: z.object({ email: z.email() }).optional(),
|
|
@@ -221,6 +221,38 @@ function settleEmptied(
|
|
|
221
221
|
return { descended, next }
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
+
// The probe descends into a missing object with `{}`, so it never tries
|
|
225
|
+
// `null` there. Try it on the side: a `.nullable()` object accepts it, so it
|
|
226
|
+
// isn't required, though its own fields still are once it is set.
|
|
227
|
+
function clearNullableObjects(
|
|
228
|
+
schema: StandardSchemaLike,
|
|
229
|
+
filled: readonly ProbeFill[],
|
|
230
|
+
probe: Record<PropertyKey, unknown>,
|
|
231
|
+
required: Set<string>,
|
|
232
|
+
cleared: Set<string>
|
|
233
|
+
): void {
|
|
234
|
+
for (const fill of filled) {
|
|
235
|
+
if (isEmptyProbe(fill.value) || cleared.has(fill.path)) {
|
|
236
|
+
continue
|
|
237
|
+
}
|
|
238
|
+
fillAt(probe, fill.keys, null, true)
|
|
239
|
+
const issues = validateSync(schema, probe)
|
|
240
|
+
fillAt(probe, fill.keys, fill.value, true)
|
|
241
|
+
const rejected = issues?.some((issue) => {
|
|
242
|
+
const path = joinPath(issue.path?.map(pathKey) ?? [])
|
|
243
|
+
return (
|
|
244
|
+
path === fill.path ||
|
|
245
|
+
path.startsWith(`${fill.path}.`) ||
|
|
246
|
+
path.startsWith(`${fill.path}[`)
|
|
247
|
+
)
|
|
248
|
+
})
|
|
249
|
+
if (issues && !rejected) {
|
|
250
|
+
required.delete(fill.path)
|
|
251
|
+
cleared.add(fill.path)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
224
256
|
function getRequiredPaths(schema: StandardSchemaLike): Set<string> {
|
|
225
257
|
const required = new Set<string>()
|
|
226
258
|
const cleared = new Set<string>()
|
|
@@ -234,6 +266,7 @@ function getRequiredPaths(schema: StandardSchemaLike): Set<string> {
|
|
|
234
266
|
return required
|
|
235
267
|
}
|
|
236
268
|
const { failing, filled } = recordPass(issues, probe, required, cleared)
|
|
269
|
+
clearNullableObjects(schema, filled, probe, required, cleared)
|
|
237
270
|
const settled = settleEmptied(emptied, failing, probe, required, cleared)
|
|
238
271
|
emptied = [
|
|
239
272
|
...filled.filter(({ value }) => isEmptyProbe(value)),
|
|
@@ -6,6 +6,7 @@ import { type ReactNode, useCallback, useId } from "react"
|
|
|
6
6
|
import { describedBy } from "./aria"
|
|
7
7
|
import { useFieldContext } from "./contexts"
|
|
8
8
|
import { emptyValueFor } from "./empty-value"
|
|
9
|
+
import { useIsFormDisabled } from "./form-disabled"
|
|
9
10
|
import {
|
|
10
11
|
useHideFieldErrors,
|
|
11
12
|
useIsFieldRequired,
|
|
@@ -20,6 +21,10 @@ type UseFieldControlOptions = {
|
|
|
20
21
|
* it, and by the error while one shows.
|
|
21
22
|
*/
|
|
22
23
|
description?: ReactNode
|
|
24
|
+
/**
|
|
25
|
+
* Disable the control. A disabled form (`useForm({ disabled })`,
|
|
26
|
+
* `<Form disabled>`, `FormDialog`'s `disabled`) disables it either way.
|
|
27
|
+
*/
|
|
23
28
|
disabled?: boolean
|
|
24
29
|
/**
|
|
25
30
|
* The label the field renders, if any. The control gets `aria-labelledby`,
|
|
@@ -40,6 +45,7 @@ type FieldControlProps<TValue = unknown> = {
|
|
|
40
45
|
"aria-invalid": true | undefined
|
|
41
46
|
"aria-labelledby": string | undefined
|
|
42
47
|
"aria-required": true | undefined
|
|
48
|
+
/** The field's own `disabled`, or the form's. */
|
|
43
49
|
disabled: boolean | undefined
|
|
44
50
|
id: string
|
|
45
51
|
name: string
|
|
@@ -71,6 +77,8 @@ type FieldControl<TValue = unknown> = {
|
|
|
71
77
|
errors: AnyFieldMeta["errors"]
|
|
72
78
|
/** The control's id, for the label's `htmlFor`. */
|
|
73
79
|
id: string
|
|
80
|
+
/** The field is disabled, by its own `disabled` or the form's. */
|
|
81
|
+
isDisabled: boolean
|
|
74
82
|
isInvalid: boolean
|
|
75
83
|
/** The field requires a value, by the schema or `required`. */
|
|
76
84
|
isRequired: boolean
|
|
@@ -125,6 +133,7 @@ function useFieldControl<TValue = unknown>({
|
|
|
125
133
|
const schemaRequired = useIsFieldRequired(field)
|
|
126
134
|
const schemaRequiresValue = useIsFieldRequiredBySchema(field)
|
|
127
135
|
const isRequired = required ?? schemaRequiresValue
|
|
136
|
+
const isDisabled = useIsFormDisabled(field.form) || Boolean(disabled)
|
|
128
137
|
// `field` is rebuilt on every value change; these members aren't, so a
|
|
129
138
|
// control that memoizes on `onChange` doesn't see a new one each render.
|
|
130
139
|
const { form, handleBlur, handleChange, name } = field
|
|
@@ -158,7 +167,7 @@ function useFieldControl<TValue = unknown>({
|
|
|
158
167
|
"aria-invalid": isInvalid || undefined,
|
|
159
168
|
"aria-labelledby": labelId,
|
|
160
169
|
"aria-required": isRequired || undefined,
|
|
161
|
-
disabled,
|
|
170
|
+
disabled: isDisabled,
|
|
162
171
|
id,
|
|
163
172
|
name,
|
|
164
173
|
onBlur: handleBlur,
|
|
@@ -169,6 +178,7 @@ function useFieldControl<TValue = unknown>({
|
|
|
169
178
|
errorId,
|
|
170
179
|
errors: showError ? field.state.meta.errors : [],
|
|
171
180
|
id,
|
|
181
|
+
isDisabled,
|
|
172
182
|
isInvalid,
|
|
173
183
|
isRequired,
|
|
174
184
|
labelId,
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { expectTypeOf, test } from "vitest"
|
|
2
|
+
import { z } from "zod"
|
|
3
|
+
import {
|
|
4
|
+
useAppFields,
|
|
5
|
+
useAppForm,
|
|
6
|
+
useForm,
|
|
7
|
+
withFieldGroup,
|
|
8
|
+
withForm,
|
|
9
|
+
} from "./create-form"
|
|
10
|
+
|
|
11
|
+
const schema = z.object({
|
|
12
|
+
address: z.object({ city: z.string(), street: z.string() }),
|
|
13
|
+
email: z.string(),
|
|
14
|
+
})
|
|
15
|
+
type Values = z.infer<typeof schema>
|
|
16
|
+
const VALUES: Values = { address: { city: "", street: "" }, email: "" }
|
|
17
|
+
|
|
18
|
+
// Typed by `defaultValues`, as in TanStack; `render` gets the kit's fields
|
|
19
|
+
// through `useAppFields`.
|
|
20
|
+
const EmailSection = withForm({
|
|
21
|
+
defaultValues: VALUES,
|
|
22
|
+
props: { title: "" },
|
|
23
|
+
render({ form, title }) {
|
|
24
|
+
expectTypeOf(title).toEqualTypeOf<string>()
|
|
25
|
+
expectTypeOf(form.state.values).toEqualTypeOf<Values>()
|
|
26
|
+
// biome-ignore lint/correctness/useHookAtTopLevel: `withForm` runs `render` inside a component; a .ts file can't return JSX to show it.
|
|
27
|
+
const { InputField } = useAppFields(form)
|
|
28
|
+
InputField({ name: "address.city" })
|
|
29
|
+
// @ts-expect-error — not a path of the form
|
|
30
|
+
InputField({ name: "phone" })
|
|
31
|
+
return null
|
|
32
|
+
},
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
const AddressGroup = withFieldGroup({
|
|
36
|
+
defaultValues: VALUES.address,
|
|
37
|
+
render: () => null,
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test("withForm takes the form useForm returns, with a schema", () => {
|
|
41
|
+
const form = useForm({ onSubmit: () => undefined, schema })
|
|
42
|
+
EmailSection({ form, title: "Email" })
|
|
43
|
+
AddressGroup({ fields: "address", form })
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test("withForm takes the form useForm returns, with a validation mode", () => {
|
|
47
|
+
const form = useForm({
|
|
48
|
+
onSubmit: () => undefined,
|
|
49
|
+
schema,
|
|
50
|
+
validators: "change",
|
|
51
|
+
})
|
|
52
|
+
EmailSection({ form, title: "Email" })
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test("withForm takes the form useForm returns, without a schema", () => {
|
|
56
|
+
const form = useForm<Values>({
|
|
57
|
+
defaultValues: VALUES,
|
|
58
|
+
onSubmit: () => undefined,
|
|
59
|
+
})
|
|
60
|
+
EmailSection({ form, title: "Email" })
|
|
61
|
+
AddressGroup({ fields: "address", form })
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test("withForm still takes the form useAppForm returns", () => {
|
|
65
|
+
const form = useAppForm({ defaultValues: VALUES, onSubmit: () => undefined })
|
|
66
|
+
EmailSection({ form, title: "Email" })
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test("withForm rejects a form with other values", () => {
|
|
70
|
+
const form = useForm({
|
|
71
|
+
onSubmit: () => undefined,
|
|
72
|
+
schema: z.object({ name: z.string() }),
|
|
73
|
+
})
|
|
74
|
+
// @ts-expect-error — the section needs `email` and `address`
|
|
75
|
+
EmailSection({ form, title: "Email" })
|
|
76
|
+
})
|
|
@@ -0,0 +1,60 @@
|
|
|
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 {
|
|
6
|
+
Form,
|
|
7
|
+
FormProvider,
|
|
8
|
+
useAppFields,
|
|
9
|
+
useForm,
|
|
10
|
+
withForm,
|
|
11
|
+
} from "./create-form"
|
|
12
|
+
|
|
13
|
+
const CITY = /City/
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
cleanup()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const schema = z.object({
|
|
20
|
+
address: z.object({ city: z.string().min(1, "Enter a city") }),
|
|
21
|
+
name: z.string(),
|
|
22
|
+
})
|
|
23
|
+
type Profile = z.infer<typeof schema>
|
|
24
|
+
|
|
25
|
+
const AddressSection = withForm({
|
|
26
|
+
defaultValues: {} as Profile,
|
|
27
|
+
render: ({ form }) => {
|
|
28
|
+
const { InputField } = useAppFields(form)
|
|
29
|
+
return <InputField label="City" name="address.city" />
|
|
30
|
+
},
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test("a withForm section edits and validates the parent's form", async () => {
|
|
34
|
+
const user = userEvent.setup()
|
|
35
|
+
const onSubmit = vi.fn()
|
|
36
|
+
function Profile() {
|
|
37
|
+
const form = useForm({ onSubmit: ({ value }) => onSubmit(value), schema })
|
|
38
|
+
const { SubmitButton } = form
|
|
39
|
+
return (
|
|
40
|
+
<FormProvider form={form}>
|
|
41
|
+
<Form>
|
|
42
|
+
<AddressSection form={form} />
|
|
43
|
+
<SubmitButton>Save</SubmitButton>
|
|
44
|
+
</Form>
|
|
45
|
+
</FormProvider>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
render(<Profile />)
|
|
49
|
+
|
|
50
|
+
await user.click(screen.getByRole("button", { name: "Save" }))
|
|
51
|
+
expect(await screen.findByText("Enter a city")).toBeTruthy()
|
|
52
|
+
expect(document.activeElement).toBe(screen.getByLabelText(CITY))
|
|
53
|
+
|
|
54
|
+
await user.type(screen.getByLabelText(CITY), "Basel")
|
|
55
|
+
await user.click(screen.getByRole("button", { name: "Save" }))
|
|
56
|
+
expect(onSubmit).toHaveBeenCalledWith({
|
|
57
|
+
address: { city: "Basel" },
|
|
58
|
+
name: "",
|
|
59
|
+
})
|
|
60
|
+
})
|