@zeno-lib/forms 0.0.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 (48) hide show
  1. package/package.json +47 -0
  2. package/src/addons/index.ts +2 -0
  3. package/src/addons/validation-spinner.tsx +25 -0
  4. package/src/fields/checkbox-field.tsx +80 -0
  5. package/src/fields/combobox-field.tsx +189 -0
  6. package/src/fields/date-picker-field.tsx +141 -0
  7. package/src/fields/email-field.tsx +21 -0
  8. package/src/fields/index.ts +16 -0
  9. package/src/fields/input-field.tsx +95 -0
  10. package/src/fields/number-field.tsx +33 -0
  11. package/src/fields/otp-field.tsx +111 -0
  12. package/src/fields/password-field.tsx +20 -0
  13. package/src/fields/radio-group-field.tsx +105 -0
  14. package/src/fields/reset-button.tsx +29 -0
  15. package/src/fields/select-field.tsx +113 -0
  16. package/src/fields/slider-field.tsx +103 -0
  17. package/src/fields/submit-button.tsx +31 -0
  18. package/src/fields/switch-field.tsx +80 -0
  19. package/src/fields/textarea-field.tsx +91 -0
  20. package/src/form.tsx +105 -0
  21. package/src/lib/apply-validation-error.test.ts +97 -0
  22. package/src/lib/apply-validation-error.ts +31 -0
  23. package/src/lib/aria.ts +7 -0
  24. package/src/lib/contexts.ts +9 -0
  25. package/src/lib/required-indicator.tsx +22 -0
  26. package/src/lib/schema-defaults.test.ts +195 -0
  27. package/src/lib/schema-defaults.ts +127 -0
  28. package/src/lib/schema-required.test.ts +132 -0
  29. package/src/lib/schema-required.ts +64 -0
  30. package/src/lib/use-is-invalid.ts +109 -0
  31. package/src/lib/use-unsaved-changes-warning.ts +57 -0
  32. package/src/lib/validation-error.test.ts +41 -0
  33. package/src/lib/validation-error.ts +30 -0
  34. package/src/lib/validation-logic.test.ts +261 -0
  35. package/src/lib/validation-logic.ts +192 -0
  36. package/src/lib/validation-modes.test-d.ts +9 -0
  37. package/src/lib/validation-modes.test.ts +72 -0
  38. package/src/lib/validation-modes.ts +73 -0
  39. package/src/tanstack.ts +2 -0
  40. package/src/use-app-fields.test-d.ts +112 -0
  41. package/src/use-app-fields.test.tsx +163 -0
  42. package/src/use-app-fields.tsx +272 -0
  43. package/src/use-form.cascade.test.tsx +83 -0
  44. package/src/use-form.test-d.ts +153 -0
  45. package/src/use-form.test.tsx +348 -0
  46. package/src/use-form.tsx +450 -0
  47. package/src/use-form.unsaved-warning.test.tsx +192 -0
  48. package/src/use-form.validation-modes.test.tsx +149 -0
@@ -0,0 +1,132 @@
1
+ import { describe, expect, test } from "@zeno-lib/vitest"
2
+ import { z } from "zod"
3
+
4
+ import { getRequiredPaths, type StandardSchemaLike } from "./schema-required"
5
+
6
+ describe("getRequiredPaths", () => {
7
+ test("plain required fields appear in the set", () => {
8
+ const schema = z.object({
9
+ email: z.email(),
10
+ name: z.string().min(1),
11
+ })
12
+ const required = getRequiredPaths(schema)
13
+ expect(required.has("email")).toBe(true)
14
+ expect(required.has("name")).toBe(true)
15
+ })
16
+
17
+ test(".optional() fields do not appear", () => {
18
+ const schema = z.object({
19
+ email: z.email(),
20
+ nickname: z.string().optional(),
21
+ })
22
+ const required = getRequiredPaths(schema)
23
+ expect(required.has("email")).toBe(true)
24
+ expect(required.has("nickname")).toBe(false)
25
+ })
26
+
27
+ test(".default(...) fields do not appear", () => {
28
+ const schema = z.object({
29
+ email: z.email(),
30
+ role: z.string().default("member"),
31
+ })
32
+ const required = getRequiredPaths(schema)
33
+ expect(required.has("email")).toBe(true)
34
+ expect(required.has("role")).toBe(false)
35
+ })
36
+
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.
40
+ const schema = z.object({
41
+ profile: z.object({
42
+ email: z.email(),
43
+ }),
44
+ })
45
+ const required = getRequiredPaths(schema)
46
+ expect(required.has("profile")).toBe(true)
47
+ expect(required.has("profile.email")).toBe(false)
48
+ })
49
+
50
+ test("dot-joined path is built when issues carry multi-segment paths", () => {
51
+ // Whether Zod surfaces nested paths depends on schema shape (defaults,
52
+ // refine targets, etc.). We exercise the path-joining itself via a
53
+ // stand-in schema that emits a multi-segment issue path.
54
+ const schema = {
55
+ "~standard": {
56
+ validate: () => ({
57
+ issues: [{ path: ["profile", "email"] }],
58
+ }),
59
+ },
60
+ }
61
+ const required = getRequiredPaths(schema)
62
+ expect(required.has("profile.email")).toBe(true)
63
+ })
64
+
65
+ test(".refine() cross-field issues are tolerated (do not crash)", () => {
66
+ const schema = z
67
+ .object({
68
+ confirm: z.string().min(8),
69
+ password: z.string().min(8),
70
+ })
71
+ .refine((value) => value.password === value.confirm, {
72
+ message: "Passwords must match",
73
+ path: ["confirm"],
74
+ })
75
+ expect(() => getRequiredPaths(schema)).not.toThrow()
76
+ })
77
+
78
+ test("async schema returns empty set", () => {
79
+ const asyncSchema: StandardSchemaLike = {
80
+ "~standard": {
81
+ validate: (_: unknown) =>
82
+ Promise.resolve({ value: undefined } as { value: unknown }),
83
+ },
84
+ }
85
+ expect(getRequiredPaths(asyncSchema).size).toBe(0)
86
+ })
87
+
88
+ test("throwing schema returns empty set", () => {
89
+ const throwing: StandardSchemaLike = {
90
+ "~standard": {
91
+ validate: () => {
92
+ throw new Error("boom")
93
+ },
94
+ },
95
+ }
96
+ expect(getRequiredPaths(throwing).size).toBe(0)
97
+ })
98
+
99
+ test("schema returning no issues returns empty set", () => {
100
+ const empty: StandardSchemaLike = {
101
+ "~standard": {
102
+ validate: () => ({ value: undefined }),
103
+ },
104
+ }
105
+ expect(getRequiredPaths(empty).size).toBe(0)
106
+ })
107
+
108
+ test("path entries shaped like { key } (Valibot) are normalised", () => {
109
+ const valibotStyle: StandardSchemaLike = {
110
+ "~standard": {
111
+ validate: () => ({
112
+ issues: [
113
+ { path: [{ key: "profile" }, { key: "email" }] },
114
+ { path: ["plain"] },
115
+ ],
116
+ }),
117
+ },
118
+ }
119
+ const required = getRequiredPaths(valibotStyle)
120
+ expect(required.has("profile.email")).toBe(true)
121
+ expect(required.has("plain")).toBe(true)
122
+ })
123
+
124
+ test("issues with empty path are skipped (form-level only)", () => {
125
+ const formLevelOnly: StandardSchemaLike = {
126
+ "~standard": {
127
+ validate: () => ({ issues: [{ path: [] }] }),
128
+ },
129
+ }
130
+ expect(getRequiredPaths(formLevelOnly).size).toBe(0)
131
+ })
132
+ })
@@ -0,0 +1,64 @@
1
+ // Inspect a Standard Schema to discover which field paths it treats as required.
2
+ //
3
+ // Standard Schema (Zod, Valibot, ArkType, …) doesn't expose introspection — only
4
+ // a `validate(value)` function. We probe by validating an empty object: every
5
+ // field that is *required* (i.e. not optional, nullable, or defaulted) emits an
6
+ // issue with a `path` we can record. Fields with defaults or `.optional()` stay
7
+ // silent because the schema considers them satisfied.
8
+ //
9
+ // Limitations:
10
+ // - Async schemas are skipped (returns an empty set). Required-ness is a
11
+ // 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.
15
+
16
+ type StandardIssue = {
17
+ readonly path?: ReadonlyArray<PropertyKey | { readonly key: PropertyKey }>
18
+ }
19
+
20
+ type StandardSchemaLike = {
21
+ readonly "~standard": {
22
+ readonly validate: (
23
+ value: unknown
24
+ ) =>
25
+ | { readonly value: unknown }
26
+ | { readonly issues: readonly StandardIssue[] }
27
+ | Promise<unknown>
28
+ }
29
+ }
30
+
31
+ function pathSegment(
32
+ part: PropertyKey | { readonly key: PropertyKey }
33
+ ): string {
34
+ if (typeof part === "object" && part !== null && "key" in part) {
35
+ return String(part.key)
36
+ }
37
+ return String(part)
38
+ }
39
+
40
+ function getRequiredPaths(schema: StandardSchemaLike): Set<string> {
41
+ const required = new Set<string>()
42
+ let result: ReturnType<StandardSchemaLike["~standard"]["validate"]>
43
+ try {
44
+ result = schema["~standard"].validate({})
45
+ } catch {
46
+ return required
47
+ }
48
+ if (result instanceof Promise) {
49
+ return required
50
+ }
51
+ if (!("issues" in result && result.issues)) {
52
+ return required
53
+ }
54
+ for (const issue of result.issues) {
55
+ const segments = issue.path?.map(pathSegment) ?? []
56
+ if (segments.length > 0) {
57
+ required.add(segments.join("."))
58
+ }
59
+ }
60
+ return required
61
+ }
62
+
63
+ export type { StandardSchemaLike }
64
+ export { getRequiredPaths }
@@ -0,0 +1,109 @@
1
+ "use client"
2
+
3
+ import { type AnyFieldApi, useStore } from "@tanstack/react-form"
4
+
5
+ import {
6
+ getFormHideFieldErrors,
7
+ getFormValidationMode,
8
+ isFieldRequired,
9
+ type ValidationMode,
10
+ } from "./validation-modes"
11
+
12
+ type ErrorSourceMap = Record<string, "field" | "form" | undefined>
13
+
14
+ const FIELD_LEVEL_CAUSES = ["onChange", "onBlur", "onSubmit"] as const
15
+
16
+ // Has the user explicitly opted into a `validators={{ onChange | onBlur |
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
19
+ // who wrote `onChange` expects live feedback, even if the form is in
20
+ // `blur-then-change` mode).
21
+ function hasFieldLevelError(
22
+ errorMap: Record<string, unknown> | undefined,
23
+ errorSourceMap: ErrorSourceMap | undefined,
24
+ cause: (typeof FIELD_LEVEL_CAUSES)[number]
25
+ ): boolean {
26
+ if (!(errorMap && errorSourceMap)) {
27
+ return false
28
+ }
29
+ return errorSourceMap[cause] === "field" && errorMap[cause] !== undefined
30
+ }
31
+
32
+ function modeAllowsDisplay(
33
+ mode: ValidationMode,
34
+ isDirty: boolean,
35
+ isBlurred: boolean,
36
+ wasSubmitted: boolean
37
+ ): boolean {
38
+ if (mode === "change") {
39
+ return isDirty || wasSubmitted
40
+ }
41
+ if (mode === "submit") {
42
+ return wasSubmitted
43
+ }
44
+ // 'blur' and 'blur-then-change'
45
+ return isBlurred || wasSubmitted
46
+ }
47
+
48
+ // Returns the "should we display errors?" gate for a field. The gate matches
49
+ // the form's `validation` mode (set via `useForm({ validation })`):
50
+ //
51
+ // - `change` → show as soon as the user has typed (live)
52
+ // - `blur` → show after first blur
53
+ // - `submit` → show after first submit attempt
54
+ // - `blur-then-change` → show after first blur (default)
55
+ //
56
+ // All modes also show errors after a submit attempt, so a user who clicks
57
+ // submit without ever interacting with required fields still sees them.
58
+ //
59
+ // Per-field validators bypass the form's mode entirely: an error written by
60
+ // `validators={{ onChange }}` shows live (after first keystroke) regardless
61
+ // of the form's gating, because the user explicitly opted into that cause.
62
+ function useIsInvalid(field: AnyFieldApi): boolean {
63
+ const wasSubmitted = useStore(
64
+ field.form.store,
65
+ (state) => state.submissionAttempts > 0
66
+ )
67
+
68
+ if (field.state.meta.isValid) {
69
+ return false
70
+ }
71
+
72
+ const { isDirty, isBlurred, errorMap, errorSourceMap } = field.state.meta
73
+ const sourceMap = errorSourceMap as ErrorSourceMap | undefined
74
+
75
+ if (hasFieldLevelError(errorMap, sourceMap, "onChange")) {
76
+ return isDirty || wasSubmitted
77
+ }
78
+ if (hasFieldLevelError(errorMap, sourceMap, "onBlur")) {
79
+ return isBlurred || wasSubmitted
80
+ }
81
+ if (hasFieldLevelError(errorMap, sourceMap, "onSubmit")) {
82
+ return wasSubmitted
83
+ }
84
+
85
+ return modeAllowsDisplay(
86
+ getFormValidationMode(field.form),
87
+ isDirty,
88
+ isBlurred,
89
+ wasSubmitted
90
+ )
91
+ }
92
+
93
+ // Returns whether the form was configured to hide inline field errors via
94
+ // `useForm({ hideFieldErrors: true })`. The shipped fields read this to
95
+ // skip rendering their inline `<FieldError>` while still flipping
96
+ // `data-invalid` / `aria-invalid` for invalid styling.
97
+ function useHideFieldErrors(field: AnyFieldApi): boolean {
98
+ return getFormHideFieldErrors(field.form)
99
+ }
100
+
101
+ // Returns whether the form's schema marks this field as required. Drives the
102
+ // inline `*` indicator next to field labels. Returns `false` when the form
103
+ // has no schema, was constructed with `requiredIndicator: false`, or the
104
+ // schema doesn't mark this field's path as required.
105
+ function useIsFieldRequired(field: AnyFieldApi): boolean {
106
+ return isFieldRequired(field.form, field.name)
107
+ }
108
+
109
+ export { useHideFieldErrors, useIsFieldRequired, useIsInvalid }
@@ -0,0 +1,57 @@
1
+ "use client"
2
+
3
+ import { type AnyFormApi, useStore } from "@tanstack/react-form"
4
+ import { useEffect } from "react"
5
+
6
+ type UnsavedChangesMode = "if-changed" | "if-touched"
7
+
8
+ // Warn the user before they leave the page with unsaved changes.
9
+ //
10
+ // Two trigger modes, selected by the caller:
11
+ // - "if-changed" (default) — fires while current values differ from
12
+ // defaults (`!state.isDefaultValue`). Clears when the user restores
13
+ // the originals, or after `formApi.reset(value)` rebases defaults.
14
+ // - "if-touched" — fires after the user has edited any field, even if
15
+ // they reverted (`state.isDirty`, sticky once edited until reset).
16
+ //
17
+ // `isSubmitting` is always read so the listener detaches during the
18
+ // submit round-trip — a successful submit transitions through
19
+ // `isSubmitting=true` before the parent typically resets/navigates, and
20
+ // we don't want a stale warning to fire in that window.
21
+ //
22
+ // Modern browsers ignore custom strings on `beforeunload` and show their
23
+ // own generic prompt; we still set `returnValue` because some older
24
+ // browsers and embedded webviews honour it.
25
+ function useUnsavedChangesWarning(
26
+ form: AnyFormApi,
27
+ enabled: boolean | UnsavedChangesMode
28
+ ): void {
29
+ let mode: UnsavedChangesMode | null
30
+ if (enabled === false) {
31
+ mode = null
32
+ } else if (enabled === true) {
33
+ mode = "if-changed"
34
+ } else {
35
+ mode = enabled
36
+ }
37
+ const dirty = useStore(form.store, (state) =>
38
+ mode === "if-touched" ? state.isDirty : !state.isDefaultValue
39
+ )
40
+ const isSubmitting = useStore(form.store, (state) => state.isSubmitting)
41
+ const active = mode !== null && dirty && !isSubmitting
42
+
43
+ useEffect(() => {
44
+ if (!active) {
45
+ return
46
+ }
47
+ const handler = (event: BeforeUnloadEvent) => {
48
+ event.preventDefault()
49
+ event.returnValue = ""
50
+ }
51
+ window.addEventListener("beforeunload", handler)
52
+ return () => window.removeEventListener("beforeunload", handler)
53
+ }, [active])
54
+ }
55
+
56
+ export type { UnsavedChangesMode }
57
+ export { useUnsavedChangesWarning }
@@ -0,0 +1,41 @@
1
+ import { describe, expect, test } from "@zeno-lib/vitest"
2
+
3
+ import { ValidationError } from "./validation-error"
4
+
5
+ describe("ValidationError", () => {
6
+ test("extends Error and sets name", () => {
7
+ const err = new ValidationError({ email: "Taken" })
8
+ expect(err).toBeInstanceOf(Error)
9
+ expect(err.name).toBe("ValidationError")
10
+ })
11
+
12
+ test("default message is 'Validation failed'", () => {
13
+ const err = new ValidationError({ email: "Taken" })
14
+ expect(err.message).toBe("Validation failed")
15
+ })
16
+
17
+ test("custom message via options.message", () => {
18
+ const err = new ValidationError({ email: "Taken" }, { message: "Boom" })
19
+ expect(err.message).toBe("Boom")
20
+ })
21
+
22
+ test("exposes fields and formError", () => {
23
+ const err = new ValidationError(
24
+ { email: "Taken", name: ["A", "B"] },
25
+ { formError: "Server down" }
26
+ )
27
+ expect(err.fields).toEqual({ email: "Taken", name: ["A", "B"] })
28
+ expect(err.formError).toBe("Server down")
29
+ })
30
+
31
+ test("formError is undefined when not provided", () => {
32
+ const err = new ValidationError({ email: "Taken" })
33
+ expect(err.formError).toBeUndefined()
34
+ })
35
+
36
+ test("accepts readonly array of messages for a field", () => {
37
+ const messages = ["too short", "missing digit"] as const
38
+ const err = new ValidationError({ password: messages })
39
+ expect(err.fields.password).toEqual(messages)
40
+ })
41
+ })
@@ -0,0 +1,30 @@
1
+ // Throwable error that carries per-field validation messages. `useForm`
2
+ // catches this inside `onSubmit` and writes each entry onto the matching
3
+ // field's meta, so callers can `throw new ValidationError({ email: '…' })`
4
+ // from anywhere (server response handler, custom async check, etc.) and have
5
+ // the messages land in the right place.
6
+ //
7
+ // Errors are written to `errorMap.onChange`, which means the form-level
8
+ // schema overwrites them as soon as the user edits the field — the message
9
+ // clears automatically once the value changes. Pass an array of messages to
10
+ // surface multiple issues for the same field.
11
+
12
+ type FieldMessage = string | readonly string[]
13
+
14
+ class ValidationError extends Error {
15
+ readonly fields: Readonly<Record<string, FieldMessage>>
16
+ readonly formError?: string
17
+
18
+ constructor(
19
+ fields: Readonly<Record<string, FieldMessage>>,
20
+ options: { formError?: string; message?: string } = {}
21
+ ) {
22
+ super(options.message ?? "Validation failed")
23
+ this.name = "ValidationError"
24
+ this.fields = fields
25
+ this.formError = options.formError
26
+ }
27
+ }
28
+
29
+ export type { FieldMessage }
30
+ export { ValidationError }
@@ -0,0 +1,261 @@
1
+ import type { AnyFormApi, ValidationLogicFn } from "@tanstack/react-form"
2
+ import { describe, expect, test, vi } from "@zeno-lib/vitest"
3
+
4
+ import { blurThenChangeLogic } from "./validation-logic"
5
+
6
+ type LogicArgs = Parameters<ValidationLogicFn>[0]
7
+ type Validators = LogicArgs["validators"]
8
+
9
+ type ObservedValidator = {
10
+ cause: string
11
+ fn: unknown
12
+ }
13
+
14
+ function makeForm(overrides?: {
15
+ submissionAttempts?: number
16
+ blurredFieldNames?: string[]
17
+ }) {
18
+ const fieldMeta: Record<string, { isBlurred: boolean }> = {}
19
+ for (const name of overrides?.blurredFieldNames ?? []) {
20
+ fieldMeta[name] = { isBlurred: true }
21
+ }
22
+ return {
23
+ state: {
24
+ fieldMeta,
25
+ submissionAttempts: overrides?.submissionAttempts ?? 0,
26
+ },
27
+ } as unknown as AnyFormApi
28
+ }
29
+
30
+ function makeRun() {
31
+ const observed: ObservedValidator[][] = []
32
+ const runValidation = vi.fn(
33
+ (props: { form: AnyFormApi; validators: ObservedValidator[] }) => {
34
+ observed.push(props.validators)
35
+ return
36
+ }
37
+ )
38
+ return { observed, runValidation }
39
+ }
40
+
41
+ const SCHEMA_FN = () => undefined
42
+ const ASYNC_SCHEMA_FN = async () => undefined
43
+
44
+ const SCHEMA_VALIDATORS: Validators = {
45
+ onBlur: SCHEMA_FN,
46
+ onChange: SCHEMA_FN,
47
+ onSubmit: SCHEMA_FN,
48
+ } as unknown as Validators
49
+
50
+ const SCHEMA_VALIDATORS_BLUR_ONLY: Validators = {
51
+ onBlur: SCHEMA_FN,
52
+ onSubmit: SCHEMA_FN,
53
+ } as unknown as Validators
54
+
55
+ const SCHEMA_VALIDATORS_ASYNC: Validators = {
56
+ onBlurAsync: ASYNC_SCHEMA_FN,
57
+ onChangeAsync: ASYNC_SCHEMA_FN,
58
+ onSubmitAsync: ASYNC_SCHEMA_FN,
59
+ } as unknown as Validators
60
+
61
+ describe("blurThenChangeLogic — form-level path", () => {
62
+ test("missing validators yields []", () => {
63
+ const { observed, runValidation } = makeRun()
64
+ blurThenChangeLogic({
65
+ event: { async: false, type: "change" } as LogicArgs["event"],
66
+ form: makeForm(),
67
+ runValidation,
68
+ validators: undefined,
69
+ } as LogicArgs)
70
+ expect(observed[0]).toEqual([])
71
+ })
72
+
73
+ test("mount fires onMount only (sync)", () => {
74
+ const onMount = vi.fn()
75
+ const { observed, runValidation } = makeRun()
76
+ blurThenChangeLogic({
77
+ event: { async: false, type: "mount" } as LogicArgs["event"],
78
+ form: makeForm(),
79
+ runValidation,
80
+ validators: { onMount } as unknown as Validators,
81
+ } as LogicArgs)
82
+ expect(observed[0]).toHaveLength(1)
83
+ expect(observed[0]?.[0]).toEqual({ cause: "mount", fn: onMount })
84
+ })
85
+
86
+ test("mount with async event yields [] (no async mount)", () => {
87
+ const { observed, runValidation } = makeRun()
88
+ blurThenChangeLogic({
89
+ event: { async: true, type: "mount" } as LogicArgs["event"],
90
+ form: makeForm(),
91
+ runValidation,
92
+ validators: SCHEMA_VALIDATORS,
93
+ } as LogicArgs)
94
+ expect(observed[0]).toEqual([])
95
+ })
96
+
97
+ test("blur fires the live validator regardless of state", () => {
98
+ const { observed, runValidation } = makeRun()
99
+ blurThenChangeLogic({
100
+ event: { async: false, type: "blur" } as LogicArgs["event"],
101
+ form: makeForm(),
102
+ runValidation,
103
+ validators: SCHEMA_VALIDATORS,
104
+ } as LogicArgs)
105
+ expect(observed[0]).toEqual([{ cause: "change", fn: SCHEMA_FN }])
106
+ })
107
+
108
+ test("blur falls back to onBlur when onChange is absent", () => {
109
+ const { observed, runValidation } = makeRun()
110
+ blurThenChangeLogic({
111
+ event: { async: false, type: "blur" } as LogicArgs["event"],
112
+ form: makeForm(),
113
+ runValidation,
114
+ validators: SCHEMA_VALIDATORS_BLUR_ONLY,
115
+ } as LogicArgs)
116
+ expect(observed[0]).toEqual([{ cause: "change", fn: SCHEMA_FN }])
117
+ })
118
+
119
+ test("change while pristine yields [] (no field blurred, no submit)", () => {
120
+ const { observed, runValidation } = makeRun()
121
+ blurThenChangeLogic({
122
+ event: { async: false, type: "change" } as LogicArgs["event"],
123
+ form: makeForm(),
124
+ runValidation,
125
+ validators: SCHEMA_VALIDATORS,
126
+ } as LogicArgs)
127
+ expect(observed[0]).toEqual([])
128
+ })
129
+
130
+ test("change after a field was blurred runs the live validator", () => {
131
+ const { observed, runValidation } = makeRun()
132
+ blurThenChangeLogic({
133
+ event: { async: false, type: "change" } as LogicArgs["event"],
134
+ form: makeForm({ blurredFieldNames: ["email"] }),
135
+ runValidation,
136
+ validators: SCHEMA_VALIDATORS,
137
+ } as LogicArgs)
138
+ expect(observed[0]).toEqual([{ cause: "change", fn: SCHEMA_FN }])
139
+ })
140
+
141
+ test("change after a submit attempt runs the live validator", () => {
142
+ const { observed, runValidation } = makeRun()
143
+ blurThenChangeLogic({
144
+ event: { async: false, type: "change" } as LogicArgs["event"],
145
+ form: makeForm({ submissionAttempts: 1 }),
146
+ runValidation,
147
+ validators: SCHEMA_VALIDATORS,
148
+ } as LogicArgs)
149
+ expect(observed[0]).toEqual([{ cause: "change", fn: SCHEMA_FN }])
150
+ })
151
+
152
+ test("sync submit yields live + submit + server placeholder", () => {
153
+ const { observed, runValidation } = makeRun()
154
+ blurThenChangeLogic({
155
+ event: { async: false, type: "submit" } as LogicArgs["event"],
156
+ form: makeForm(),
157
+ runValidation,
158
+ validators: SCHEMA_VALIDATORS,
159
+ } as LogicArgs)
160
+ expect(observed[0]?.map((v) => v.cause)).toEqual([
161
+ "change",
162
+ "submit",
163
+ "server",
164
+ ])
165
+ })
166
+
167
+ test("async submit yields live + submitAsync only (no server placeholder)", () => {
168
+ const { observed, runValidation } = makeRun()
169
+ blurThenChangeLogic({
170
+ event: { async: true, type: "submit" } as LogicArgs["event"],
171
+ form: makeForm(),
172
+ runValidation,
173
+ validators: SCHEMA_VALIDATORS_ASYNC,
174
+ } as LogicArgs)
175
+ expect(observed[0]?.map((v) => v.cause)).toEqual(["change", "submit"])
176
+ })
177
+ })
178
+
179
+ describe("blurThenChangeLogic — field-level path", () => {
180
+ function fieldEvent(
181
+ type: "change" | "blur" | "submit" | "mount",
182
+ async = false
183
+ ): LogicArgs["event"] {
184
+ return { async, fieldName: "email", type } as LogicArgs["event"]
185
+ }
186
+
187
+ test("field-level change uses validators.onChange directly (no gating)", () => {
188
+ const onChange = vi.fn()
189
+ const { observed, runValidation } = makeRun()
190
+ blurThenChangeLogic({
191
+ event: fieldEvent("change"),
192
+ form: makeForm(),
193
+ runValidation,
194
+ validators: { onChange } as unknown as Validators,
195
+ } as LogicArgs)
196
+ expect(observed[0]).toEqual([{ cause: "change", fn: onChange }])
197
+ })
198
+
199
+ test("field-level blur uses validators.onBlur directly", () => {
200
+ const onBlur = vi.fn()
201
+ const { observed, runValidation } = makeRun()
202
+ blurThenChangeLogic({
203
+ event: fieldEvent("blur"),
204
+ form: makeForm(),
205
+ runValidation,
206
+ validators: { onBlur } as unknown as Validators,
207
+ } as LogicArgs)
208
+ expect(observed[0]).toEqual([{ cause: "blur", fn: onBlur }])
209
+ })
210
+
211
+ test("field-level submit runs change + blur + submit", () => {
212
+ const onChange = vi.fn()
213
+ const onBlur = vi.fn()
214
+ const onSubmit = vi.fn()
215
+ const { observed, runValidation } = makeRun()
216
+ blurThenChangeLogic({
217
+ event: fieldEvent("submit"),
218
+ form: makeForm(),
219
+ runValidation,
220
+ validators: { onBlur, onChange, onSubmit } as unknown as Validators,
221
+ } as LogicArgs)
222
+ expect(observed[0]?.map((v) => v.cause)).toEqual([
223
+ "change",
224
+ "blur",
225
+ "submit",
226
+ ])
227
+ })
228
+
229
+ test("field-level no validators yields []", () => {
230
+ const { observed, runValidation } = makeRun()
231
+ blurThenChangeLogic({
232
+ event: fieldEvent("change"),
233
+ form: makeForm(),
234
+ runValidation,
235
+ validators: undefined,
236
+ } as LogicArgs)
237
+ expect(observed[0]).toEqual([])
238
+ })
239
+
240
+ test("field-level async submit uses async slots", () => {
241
+ const onChangeAsync = vi.fn()
242
+ const onBlurAsync = vi.fn()
243
+ const onSubmitAsync = vi.fn()
244
+ const { observed, runValidation } = makeRun()
245
+ blurThenChangeLogic({
246
+ event: fieldEvent("submit", true),
247
+ form: makeForm(),
248
+ runValidation,
249
+ validators: {
250
+ onBlurAsync,
251
+ onChangeAsync,
252
+ onSubmitAsync,
253
+ } as unknown as Validators,
254
+ } as LogicArgs)
255
+ expect(observed[0]?.map((v) => v.fn)).toEqual([
256
+ onChangeAsync,
257
+ onBlurAsync,
258
+ onSubmitAsync,
259
+ ])
260
+ })
261
+ })