@zeno-lib/forms 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/package.json +21 -11
  2. package/src/addons/validation-spinner.tsx +3 -4
  3. package/src/create-form.tsx +49 -0
  4. package/src/create-zeno-form.tsx +531 -0
  5. package/src/fields/checkbox-field.tsx +15 -12
  6. package/src/fields/combobox-field.tsx +13 -14
  7. package/src/fields/date-picker-field.tsx +18 -15
  8. package/src/fields/input-field.tsx +12 -13
  9. package/src/fields/number-field.tsx +1 -2
  10. package/src/fields/otp-field.tsx +15 -12
  11. package/src/fields/radio-group-field.tsx +12 -13
  12. package/src/fields/reset-button.tsx +2 -3
  13. package/src/fields/select-field.tsx +12 -13
  14. package/src/fields/slider-field.tsx +11 -12
  15. package/src/fields/submit-button.tsx +3 -4
  16. package/src/fields/switch-field.tsx +15 -12
  17. package/src/fields/textarea-field.tsx +12 -13
  18. package/src/form-element.tsx +51 -0
  19. package/src/index.ts +14 -0
  20. package/src/lib/apply-validation-error.test.ts +1 -2
  21. package/src/lib/required-indicator.tsx +1 -1
  22. package/src/lib/schema-defaults.test.ts +1 -2
  23. package/src/lib/schema-required.test.ts +98 -6
  24. package/src/lib/schema-required.ts +126 -22
  25. package/src/lib/use-is-invalid.ts +4 -4
  26. package/src/lib/use-rebased-default-values.ts +42 -0
  27. package/src/lib/use-unsaved-changes-warning.ts +3 -3
  28. package/src/lib/validation-error.test.ts +1 -1
  29. package/src/lib/validation-logic.test.ts +1 -1
  30. package/src/lib/validation-modes.test-d.ts +1 -1
  31. package/src/lib/validation-modes.test.ts +12 -2
  32. package/src/lib/validation-modes.ts +3 -1
  33. package/src/use-app-fields.test-d.ts +8 -25
  34. package/src/use-app-fields.test.tsx +8 -32
  35. package/src/use-form.cascade.test.tsx +4 -6
  36. package/src/use-form.focus-invalid.test.tsx +198 -0
  37. package/src/use-form.reset-defaults.test.tsx +125 -0
  38. package/src/use-form.test-d.ts +2 -3
  39. package/src/use-form.test.tsx +12 -14
  40. package/src/use-form.unsaved-warning.test.tsx +4 -13
  41. package/src/use-form.validation-modes.test.tsx +10 -8
  42. package/src/form.tsx +0 -105
  43. package/src/use-app-fields.tsx +0 -272
  44. package/src/use-form.tsx +0 -450
@@ -1,7 +1,11 @@
1
- import { describe, expect, test } from "@zeno-lib/vitest"
1
+ import { describe, expect, test } from "vitest"
2
2
  import { z } from "zod"
3
3
 
4
- import { getRequiredPaths, type StandardSchemaLike } from "./schema-required"
4
+ import {
5
+ getRequiredPaths,
6
+ type StandardSchemaLike,
7
+ toRequiredPathKey,
8
+ } from "./schema-required"
5
9
 
6
10
  describe("getRequiredPaths", () => {
7
11
  test("plain required fields appear in the set", () => {
@@ -34,17 +38,52 @@ describe("getRequiredPaths", () => {
34
38
  expect(required.has("role")).toBe(false)
35
39
  })
36
40
 
37
- test("nested z.object reports the top-level key when missing", () => {
38
- // Probing with `{}` only reveals the top-level required key; Zod never
39
- // recurses past a missing parent.
41
+ test("nested z.object reports the parent and its required children", () => {
40
42
  const schema = z.object({
41
43
  profile: z.object({
44
+ bio: z.string().optional(),
42
45
  email: z.email(),
43
46
  }),
44
47
  })
45
48
  const required = getRequiredPaths(schema)
46
49
  expect(required.has("profile")).toBe(true)
47
- expect(required.has("profile.email")).toBe(false)
50
+ expect(required.has("profile.email")).toBe(true)
51
+ expect(required.has("profile.bio")).toBe(false)
52
+ })
53
+
54
+ test("optional nested object is not descended into", () => {
55
+ const schema = z.object({
56
+ profile: z.object({ email: z.email() }).optional(),
57
+ })
58
+ expect(getRequiredPaths(schema).size).toBe(0)
59
+ })
60
+
61
+ test("array rows are reported with TanStack bracket syntax", () => {
62
+ const schema = z.object({
63
+ members: z.array(
64
+ z.object({
65
+ name: z.string().min(1),
66
+ note: z.string().optional(),
67
+ })
68
+ ),
69
+ tags: z.array(z.string()),
70
+ })
71
+ const required = getRequiredPaths(schema)
72
+ expect(required.has("members")).toBe(true)
73
+ expect(required.has("members[0].name")).toBe(true)
74
+ expect(required.has("members.0.name")).toBe(false)
75
+ expect(required.has("members[0].note")).toBe(false)
76
+ expect(required.has("tags[0]")).toBe(true)
77
+ })
78
+
79
+ test("nested arrays descend through every level", () => {
80
+ const schema = z.object({
81
+ groups: z.array(
82
+ z.object({ members: z.array(z.object({ name: z.string() })) })
83
+ ),
84
+ })
85
+ const required = getRequiredPaths(schema)
86
+ expect(required.has("groups[0].members[0].name")).toBe(true)
48
87
  })
49
88
 
50
89
  test("dot-joined path is built when issues carry multi-segment paths", () => {
@@ -105,6 +144,47 @@ describe("getRequiredPaths", () => {
105
144
  expect(getRequiredPaths(empty).size).toBe(0)
106
145
  })
107
146
 
147
+ test("numeric path segments render as bracket indices, normalised to 0", () => {
148
+ const schema: StandardSchemaLike = {
149
+ "~standard": {
150
+ validate: () => ({
151
+ issues: [
152
+ { path: ["members", 2, "name"] },
153
+ { path: ["rows", "1", "value"] },
154
+ { path: [{ key: "items" }, { key: 3 }] },
155
+ ],
156
+ }),
157
+ },
158
+ }
159
+ const required = getRequiredPaths(schema)
160
+ expect(required.has("members[0].name")).toBe(true)
161
+ expect(required.has("rows[0].value")).toBe(true)
162
+ expect(required.has("items[0]")).toBe(true)
163
+ })
164
+
165
+ test("Valibot-style capitalised `expected` hints are descended", () => {
166
+ const schema: StandardSchemaLike = {
167
+ "~standard": {
168
+ validate: (value) => {
169
+ const profile = (value as { profile?: unknown }).profile
170
+ return profile === undefined
171
+ ? { issues: [{ expected: "Object", path: [{ key: "profile" }] }] }
172
+ : {
173
+ issues: [
174
+ {
175
+ expected: "string",
176
+ path: [{ key: "profile" }, { key: "email" }],
177
+ },
178
+ ],
179
+ }
180
+ },
181
+ },
182
+ }
183
+ const required = getRequiredPaths(schema)
184
+ expect(required.has("profile")).toBe(true)
185
+ expect(required.has("profile.email")).toBe(true)
186
+ })
187
+
108
188
  test("path entries shaped like { key } (Valibot) are normalised", () => {
109
189
  const valibotStyle: StandardSchemaLike = {
110
190
  "~standard": {
@@ -130,3 +210,15 @@ describe("getRequiredPaths", () => {
130
210
  expect(getRequiredPaths(formLevelOnly).size).toBe(0)
131
211
  })
132
212
  })
213
+
214
+ describe("toRequiredPathKey", () => {
215
+ test("normalises bracket and dotted indices to [0]", () => {
216
+ expect(toRequiredPathKey("members[3].name")).toBe("members[0].name")
217
+ expect(toRequiredPathKey("members.3.name")).toBe("members[0].name")
218
+ expect(toRequiredPathKey("groups[1].members[12]")).toBe(
219
+ "groups[0].members[0]"
220
+ )
221
+ expect(toRequiredPathKey("email")).toBe("email")
222
+ expect(toRequiredPathKey("line2")).toBe("line2")
223
+ })
224
+ })
@@ -6,15 +6,29 @@
6
6
  // issue with a `path` we can record. Fields with defaults or `.optional()` stay
7
7
  // silent because the schema considers them satisfied.
8
8
  //
9
+ // A missing parent hides its children (the schema stops at the missing key), so
10
+ // the probe descends: when an issue says it expected an object or an array
11
+ // (Zod and Valibot both put that on `issue.expected`), the next pass fills that
12
+ // path with `{}` / `[{}]` and validates again. Array rows are probed through
13
+ // index `0` and recorded index-agnostically, so `members[0].name` in the set
14
+ // matches every row's `members[3].name` via `toRequiredPathKey`.
15
+ //
16
+ // Paths use TanStack Form's field-name syntax: dots for object keys, brackets
17
+ // for array indices (`members[0].name`, not `members.0.name`).
18
+ //
9
19
  // Limitations:
10
20
  // - Async schemas are skipped (returns an empty set). Required-ness is a
11
21
  // visual hint; the validator itself still runs at submit time.
12
- // - Cross-field refinements that fail on `{}` may flag fields that aren't
13
- // intrinsically required. In practice this is the same heuristic users
14
- // would apply by eye.
22
+ // - Cross-field refinements that fail on the probe value may flag fields that
23
+ // aren't intrinsically required. In practice this is the same heuristic
24
+ // users would apply by eye.
25
+ // - Schemas whose issues carry no `expected` hint stop at the top level.
26
+
27
+ type PathPart = PropertyKey | { readonly key: PropertyKey }
15
28
 
16
29
  type StandardIssue = {
17
- readonly path?: ReadonlyArray<PropertyKey | { readonly key: PropertyKey }>
30
+ readonly path?: readonly PathPart[]
31
+ readonly expected?: unknown
18
32
  }
19
33
 
20
34
  type StandardSchemaLike = {
@@ -28,37 +42,127 @@ type StandardSchemaLike = {
28
42
  }
29
43
  }
30
44
 
31
- function pathSegment(
32
- part: PropertyKey | { readonly key: PropertyKey }
33
- ): string {
45
+ const MAX_PROBE_DEPTH = 8
46
+
47
+ const INDEX_SEGMENT = /^\d+$/
48
+
49
+ // Matches `[3]` and `.3` index segments in a field name.
50
+ const INDEX_IN_NAME = /\[\d+\]|\.(\d+)(?=\.|\[|$)/g
51
+
52
+ function pathKey(part: PathPart): PropertyKey {
34
53
  if (typeof part === "object" && part !== null && "key" in part) {
35
- return String(part.key)
54
+ return part.key
36
55
  }
37
- return String(part)
56
+ return part
38
57
  }
39
58
 
40
- function getRequiredPaths(schema: StandardSchemaLike): Set<string> {
41
- const required = new Set<string>()
59
+ function isIndex(key: PropertyKey): boolean {
60
+ return (
61
+ typeof key === "number" ||
62
+ (typeof key === "string" && INDEX_SEGMENT.test(key))
63
+ )
64
+ }
65
+
66
+ // Render issue path segments as a TanStack field name, with every array index
67
+ // normalised to `0` so one entry covers all rows.
68
+ function joinPath(keys: readonly PropertyKey[]): string {
69
+ let out = ""
70
+ for (const key of keys) {
71
+ if (isIndex(key)) {
72
+ out += "[0]"
73
+ } else {
74
+ out += out === "" ? String(key) : `.${String(key)}`
75
+ }
76
+ }
77
+ return out
78
+ }
79
+
80
+ /**
81
+ * Normalise a field name (`members[3].name` or `members.3.name`) to the key
82
+ * `getRequiredPaths` records (`members[0].name`).
83
+ */
84
+ function toRequiredPathKey(name: string): string {
85
+ return name.replace(INDEX_IN_NAME, "[0]")
86
+ }
87
+
88
+ function probeContainer(expected: unknown): unknown {
89
+ if (typeof expected !== "string") {
90
+ return
91
+ }
92
+ const kind = expected.toLowerCase()
93
+ if (kind === "object") {
94
+ return {}
95
+ }
96
+ if (kind === "array") {
97
+ return [{}]
98
+ }
99
+ return
100
+ }
101
+
102
+ // Write `value` at `keys` inside `root`, only where nothing is set yet.
103
+ function fillAt(
104
+ root: Record<PropertyKey, unknown>,
105
+ keys: readonly PropertyKey[],
106
+ value: unknown
107
+ ): boolean {
108
+ let node: Record<PropertyKey, unknown> = root
109
+ for (const key of keys.slice(0, -1)) {
110
+ const next = node[key]
111
+ if (typeof next !== "object" || next === null) {
112
+ return false
113
+ }
114
+ node = next as Record<PropertyKey, unknown>
115
+ }
116
+ const last = keys.at(-1) as PropertyKey
117
+ if (node[last] !== undefined) {
118
+ return false
119
+ }
120
+ node[last] = value
121
+ return true
122
+ }
123
+
124
+ function validateSync(
125
+ schema: StandardSchemaLike,
126
+ value: unknown
127
+ ): readonly StandardIssue[] | undefined {
42
128
  let result: ReturnType<StandardSchemaLike["~standard"]["validate"]>
43
129
  try {
44
- result = schema["~standard"].validate({})
130
+ result = schema["~standard"].validate(value)
45
131
  } catch {
46
- return required
132
+ return
47
133
  }
48
134
  if (result instanceof Promise) {
49
- return required
50
- }
51
- if (!("issues" in result && result.issues)) {
52
- return required
135
+ return
53
136
  }
54
- for (const issue of result.issues) {
55
- const segments = issue.path?.map(pathSegment) ?? []
56
- if (segments.length > 0) {
57
- required.add(segments.join("."))
137
+ return "issues" in result && result.issues ? result.issues : []
138
+ }
139
+
140
+ function getRequiredPaths(schema: StandardSchemaLike): Set<string> {
141
+ const required = new Set<string>()
142
+ const probe: Record<PropertyKey, unknown> = {}
143
+ for (let depth = 0; depth < MAX_PROBE_DEPTH; depth++) {
144
+ const issues = validateSync(schema, probe)
145
+ if (!issues) {
146
+ return required
147
+ }
148
+ let descended = false
149
+ for (const issue of issues) {
150
+ const keys = issue.path?.map(pathKey) ?? []
151
+ if (keys.length === 0) {
152
+ continue
153
+ }
154
+ required.add(joinPath(keys))
155
+ const container = probeContainer(issue.expected)
156
+ if (container !== undefined && fillAt(probe, keys, container)) {
157
+ descended = true
158
+ }
159
+ }
160
+ if (!descended) {
161
+ break
58
162
  }
59
163
  }
60
164
  return required
61
165
  }
62
166
 
63
167
  export type { StandardSchemaLike }
64
- export { getRequiredPaths }
168
+ export { getRequiredPaths, toRequiredPathKey }
@@ -1,6 +1,6 @@
1
1
  "use client"
2
2
 
3
- import { type AnyFieldApi, useStore } from "@tanstack/react-form"
3
+ import { type AnyFieldApi, useSelector } from "@tanstack/react-form"
4
4
 
5
5
  import {
6
6
  getFormHideFieldErrors,
@@ -15,7 +15,7 @@ const FIELD_LEVEL_CAUSES = ["onChange", "onBlur", "onSubmit"] as const
15
15
 
16
16
  // Has the user explicitly opted into a `validators={{ onChange | onBlur |
17
17
  // onSubmit }}` cause on this field? If yes, we honour the cause directly —
18
- // the form's `validation` mode does not gate per-field validators (a user
18
+ // the form's `validators` mode does not gate per-field validators (a user
19
19
  // who wrote `onChange` expects live feedback, even if the form is in
20
20
  // `blur-then-change` mode).
21
21
  function hasFieldLevelError(
@@ -46,7 +46,7 @@ function modeAllowsDisplay(
46
46
  }
47
47
 
48
48
  // Returns the "should we display errors?" gate for a field. The gate matches
49
- // the form's `validation` mode (set via `useForm({ validation })`):
49
+ // the form's `validators` mode (set via `useForm({ validators })`):
50
50
  //
51
51
  // - `change` → show as soon as the user has typed (live)
52
52
  // - `blur` → show after first blur
@@ -60,7 +60,7 @@ function modeAllowsDisplay(
60
60
  // `validators={{ onChange }}` shows live (after first keystroke) regardless
61
61
  // of the form's gating, because the user explicitly opted into that cause.
62
62
  function useIsInvalid(field: AnyFieldApi): boolean {
63
- const wasSubmitted = useStore(
63
+ const wasSubmitted = useSelector(
64
64
  field.form.store,
65
65
  (state) => state.submissionAttempts > 0
66
66
  )
@@ -0,0 +1,42 @@
1
+ "use client"
2
+
3
+ import { type AnyFormApi, evaluate } from "@tanstack/react-form"
4
+ import { useCallback, useRef } from "react"
5
+
6
+ // TanStack's `useForm` calls `formApi.update(options)` after every render. While
7
+ // the form is untouched, `update` re-applies `options.defaultValues` whenever it
8
+ // deep-differs from the form's live defaults. `formApi.reset(values)` rebases
9
+ // those live defaults, so a caller that keeps passing the same `defaultValues`
10
+ // literal would see the reset undone on the next render (the classic "reset to
11
+ // the saved record after submit" flow).
12
+ //
13
+ // This hook forwards the caller's defaults only when *they* change (deep
14
+ // compare, the same `evaluate` TanStack's `update` uses). Otherwise it forwards
15
+ // the form's live defaults back, so `update` sees no change and a reset sticks.
16
+ // The live FormApi instance comes from the form-level `listeners.onMount`
17
+ // callback: the object `useForm` returns is a spread snapshot whose `options`
18
+ // never updates.
19
+ function useRebasedDefaultValues<T>(callerDefaults: T): {
20
+ defaultValues: T
21
+ onMount: (props: { formApi: AnyFormApi }) => void
22
+ } {
23
+ const liveForm = useRef<AnyFormApi | null>(null)
24
+ const lastCallerDefaults = useRef(callerDefaults)
25
+
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
33
+ }
34
+
35
+ const onMount = useCallback((props: { formApi: AnyFormApi }) => {
36
+ liveForm.current = props.formApi
37
+ }, [])
38
+
39
+ return { defaultValues, onMount }
40
+ }
41
+
42
+ export { useRebasedDefaultValues }
@@ -1,6 +1,6 @@
1
1
  "use client"
2
2
 
3
- import { type AnyFormApi, useStore } from "@tanstack/react-form"
3
+ import { type AnyFormApi, useSelector } from "@tanstack/react-form"
4
4
  import { useEffect } from "react"
5
5
 
6
6
  type UnsavedChangesMode = "if-changed" | "if-touched"
@@ -34,10 +34,10 @@ function useUnsavedChangesWarning(
34
34
  } else {
35
35
  mode = enabled
36
36
  }
37
- const dirty = useStore(form.store, (state) =>
37
+ const dirty = useSelector(form.store, (state) =>
38
38
  mode === "if-touched" ? state.isDirty : !state.isDefaultValue
39
39
  )
40
- const isSubmitting = useStore(form.store, (state) => state.isSubmitting)
40
+ const isSubmitting = useSelector(form.store, (state) => state.isSubmitting)
41
41
  const active = mode !== null && dirty && !isSubmitting
42
42
 
43
43
  useEffect(() => {
@@ -1,4 +1,4 @@
1
- import { describe, expect, test } from "@zeno-lib/vitest"
1
+ import { describe, expect, test } from "vitest"
2
2
 
3
3
  import { ValidationError } from "./validation-error"
4
4
 
@@ -1,5 +1,5 @@
1
1
  import type { AnyFormApi, ValidationLogicFn } from "@tanstack/react-form"
2
- import { describe, expect, test, vi } from "@zeno-lib/vitest"
2
+ import { describe, expect, test, vi } from "vitest"
3
3
 
4
4
  import { blurThenChangeLogic } from "./validation-logic"
5
5
 
@@ -1,4 +1,4 @@
1
- import { expectTypeOf, test } from "@zeno-lib/vitest"
1
+ import { expectTypeOf, test } from "vitest"
2
2
 
3
3
  import type { ValidationMode } from "./validation-modes"
4
4
 
@@ -1,6 +1,5 @@
1
1
  import type { AnyFormApi } from "@tanstack/react-form"
2
- import { describe, expect, test } from "@zeno-lib/vitest"
3
-
2
+ import { describe, expect, test } from "vitest"
4
3
  import {
5
4
  DEFAULT_VALIDATION_MODE,
6
5
  getFormHideFieldErrors,
@@ -50,6 +49,17 @@ describe("validation-modes WeakMap state", () => {
50
49
  expect(isFieldRequired(form, "email")).toBe(false)
51
50
  })
52
51
 
52
+ test("isFieldRequired matches array rows by TanStack field name", () => {
53
+ const form = makeFormStub()
54
+ setFormZenoState(form, {
55
+ requiredFields: new Set(["members[0].name"]),
56
+ requiredIndicator: true,
57
+ })
58
+ expect(isFieldRequired(form, "members[0].name")).toBe(true)
59
+ expect(isFieldRequired(form, "members[4].name")).toBe(true)
60
+ expect(isFieldRequired(form, "members[4].note")).toBe(false)
61
+ })
62
+
53
63
  test("two forms keyed on different store references are independent", () => {
54
64
  const a = makeFormStub()
55
65
  const b = makeFormStub()
@@ -2,6 +2,8 @@
2
2
 
3
3
  import type { AnyFormApi } from "@tanstack/react-form"
4
4
 
5
+ import { toRequiredPathKey } from "./schema-required"
6
+
5
7
  type ValidationMode = "change" | "blur" | "submit" | "blur-then-change"
6
8
 
7
9
  const DEFAULT_VALIDATION_MODE: ValidationMode = "blur-then-change"
@@ -60,7 +62,7 @@ function isFieldRequired(form: AnyFormApi, name: string): boolean {
60
62
  if (!state.requiredIndicator) {
61
63
  return false
62
64
  }
63
- return state.requiredFields.has(name)
65
+ return state.requiredFields.has(toRequiredPathKey(name))
64
66
  }
65
67
 
66
68
  export {
@@ -1,7 +1,6 @@
1
- import { test } from "@zeno-lib/vitest"
1
+ import { test } from "vitest"
2
2
  import { z } from "zod"
3
-
4
- import { useForm } from "./use-form"
3
+ import { useForm } from "./create-form"
5
4
 
6
5
  const schema = z.object({
7
6
  accept: z.boolean(),
@@ -14,10 +13,6 @@ const schema = z.object({
14
13
  size: z.string(),
15
14
  })
16
15
 
17
- const schemaNoEmail = z.object({
18
- contact: z.string(),
19
- })
20
-
21
16
  test("InputField.name is constrained to DeepKeys<T>", () => {
22
17
  const form = useForm({ onSubmit: () => undefined, schema })
23
18
  const { InputField } = form
@@ -57,36 +52,24 @@ test("TextAreaField.name is constrained to DeepKeys<T>", () => {
57
52
  form.TextAreaField({ name: "nonexistent" })
58
53
  })
59
54
 
60
- test("EmailField.name is optional when T has an `email` field", () => {
55
+ test("EmailField.name is required and constrained to DeepKeys<T>", () => {
61
56
  const form = useForm({ onSubmit: () => undefined, schema })
62
57
  const { EmailField } = form
63
- // No `name` required.
58
+ // @ts-expect-error — name is required
64
59
  EmailField({})
65
- EmailField({ label: "Email" })
66
60
  EmailField({ name: "email" })
61
+ // @ts-expect-error — not a valid path
62
+ EmailField({ name: "nonexistent" })
67
63
  })
68
64
 
69
- test("EmailField.name is required when T has no `email` field", () => {
70
- const form = useForm({ onSubmit: () => undefined, schema: schemaNoEmail })
71
- // @ts-expect-error — name becomes required without an "email" key on T
72
- form.EmailField({})
73
- form.EmailField({ name: "contact" })
74
- })
75
-
76
- test("PasswordField.name is optional when T has a `password` field", () => {
65
+ test("PasswordField.name is required and constrained to DeepKeys<T>", () => {
77
66
  const form = useForm({ onSubmit: () => undefined, schema })
78
67
  const { PasswordField } = form
68
+ // @ts-expect-error — name is required
79
69
  PasswordField({})
80
70
  PasswordField({ name: "password" })
81
71
  })
82
72
 
83
- test("PasswordField.name is required when T has no `password` field", () => {
84
- const form = useForm({ onSubmit: () => undefined, schema: schemaNoEmail })
85
- // @ts-expect-error — name becomes required without a "password" key on T
86
- form.PasswordField({})
87
- form.PasswordField({ name: "contact" })
88
- })
89
-
90
73
  test("validators prop is accepted on every field wrapper", () => {
91
74
  const form = useForm({ onSubmit: () => undefined, schema })
92
75
  form.InputField({
@@ -1,12 +1,9 @@
1
- import { afterEach, describe, expect, test, vi } from "@zeno-lib/vitest"
2
- import { cleanup, render, screen } from "@zeno-lib/vitest/testing-library"
3
- import userEvent from "@zeno-lib/vitest/user-event"
1
+ import { cleanup, render, screen } from "@zeno-lib/test/testing-library"
2
+ import userEvent from "@zeno-lib/test/user-event"
3
+ import { afterEach, describe, expect, test, vi } from "vitest"
4
4
  import { z } from "zod"
5
+ import { Form, FormProvider, useForm } from "./create-form"
5
6
 
6
- import { Form, FormProvider } from "./form"
7
- import { useForm } from "./use-form"
8
-
9
- const EMAIL_LABEL = /Email/
10
7
  const CONTACT_EMAIL_LABEL = /Contact email/
11
8
  const PASSWORD_LABEL = /Password/
12
9
  const NAME_LABEL = /Name/
@@ -16,27 +13,7 @@ afterEach(() => {
16
13
  cleanup()
17
14
  })
18
15
 
19
- describe("EmailField / PasswordField — default name", () => {
20
- test("EmailField with no `name` renders an input with id='email'", () => {
21
- const schema = z.object({ email: z.email() })
22
- function H() {
23
- const form = useForm({ onSubmit: vi.fn(), schema })
24
- const { EmailField } = form
25
- return (
26
- <FormProvider form={form}>
27
- <Form>
28
- <EmailField label="Email" />
29
- </Form>
30
- </FormProvider>
31
- )
32
- }
33
- render(<H />)
34
- const input = screen.getByLabelText(EMAIL_LABEL) as HTMLInputElement
35
- expect(input.id).toBe("email")
36
- expect(input.name).toBe("email")
37
- expect(input.type).toBe("email")
38
- })
39
-
16
+ describe("EmailField / PasswordField", () => {
40
17
  test("EmailField with an explicit `name` binds to that field", async () => {
41
18
  const user = userEvent.setup()
42
19
  const schema = z.object({ contactEmail: z.email() })
@@ -59,19 +36,18 @@ describe("EmailField / PasswordField — default name", () => {
59
36
  expect(input.value).toBe("u@example.com")
60
37
  })
61
38
 
62
- test("PasswordField with no `name` renders an input with id='password'", () => {
39
+ test("PasswordField with an explicit `name` renders a password input", () => {
63
40
  const schema = z.object({
64
41
  email: z.email(),
65
42
  password: z.string().min(1),
66
43
  })
67
44
  function H() {
68
45
  const form = useForm({ onSubmit: vi.fn(), schema })
69
- const { EmailField, PasswordField } = form
46
+ const { PasswordField } = form
70
47
  return (
71
48
  <FormProvider form={form}>
72
49
  <Form>
73
- <EmailField />
74
- <PasswordField label="Password" />
50
+ <PasswordField label="Password" name="password" />
75
51
  </Form>
76
52
  </FormProvider>
77
53
  )
@@ -1,10 +1,8 @@
1
- import { afterEach, describe, expect, test, vi } from "@zeno-lib/vitest"
2
- import { cleanup, render, screen } from "@zeno-lib/vitest/testing-library"
3
- import userEvent from "@zeno-lib/vitest/user-event"
1
+ import { cleanup, render, screen } from "@zeno-lib/test/testing-library"
2
+ import userEvent from "@zeno-lib/test/user-event"
3
+ import { afterEach, describe, expect, test, vi } from "vitest"
4
4
  import { z } from "zod"
5
-
6
- import { Form, FormProvider } from "./form"
7
- import { useForm } from "./use-form"
5
+ import { Form, FormProvider, useForm } from "./create-form"
8
6
 
9
7
  const DIET_LABEL = /Diet/
10
8
  const SAUSAGE_LABEL = /Sausage/