@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,450 @@
1
+ "use client"
2
+
3
+ import type {
4
+ AnyFormApi,
5
+ FormAsyncValidateOrFn,
6
+ FormOptions,
7
+ FormValidateOrFn,
8
+ } from "@tanstack/react-form"
9
+ import { useMemo } from "react"
10
+
11
+ import { ResetButton } from "./fields/reset-button"
12
+ import { SubmitButton } from "./fields/submit-button"
13
+ import { useAppForm } from "./form"
14
+ import { applyValidationError } from "./lib/apply-validation-error"
15
+ import { deepMergeDefaults, extractZodDefaults } from "./lib/schema-defaults"
16
+ import { getRequiredPaths } from "./lib/schema-required"
17
+ import { useUnsavedChangesWarning } from "./lib/use-unsaved-changes-warning"
18
+ import { ValidationError } from "./lib/validation-error"
19
+ import { blurThenChangeLogic } from "./lib/validation-logic"
20
+ import {
21
+ DEFAULT_VALIDATION_MODE,
22
+ setFormZenoState,
23
+ type ValidationMode,
24
+ } from "./lib/validation-modes"
25
+ import { useAppFields } from "./use-app-fields"
26
+
27
+ // Structural shape of a Standard Schema (Zod, Valibot, ArkType, …). Avoids a
28
+ // direct dependency on `@standard-schema/spec`; runtime validation goes
29
+ // through TanStack Form's standard-schema integration unchanged.
30
+ type StandardSchema<T> = {
31
+ readonly "~standard": {
32
+ readonly validate: (value: unknown) => unknown
33
+ readonly types?: { readonly input: T; readonly output: T }
34
+ }
35
+ }
36
+
37
+ // Recursive partial that preserves arrays, dates, and other structural types
38
+ // as atomic — only plain object keys become optional. Used to relax
39
+ // `defaultValues` so users can omit fields covered by the schema's
40
+ // `.default(...)` values or by the string/array auto-fill (see
41
+ // `lib/schema-defaults.ts`). `value` on submit is still typed as the full
42
+ // `TFormData` because TanStack's runtime state assertion is unchanged.
43
+ type PartialFormData<T> = T extends readonly unknown[]
44
+ ? T
45
+ : T extends Date | RegExp | ((...args: never[]) => unknown)
46
+ ? T
47
+ : T extends object
48
+ ? { [K in keyof T]?: PartialFormData<T[K]> }
49
+ : T
50
+
51
+ type ZenoFormExtras<TFormData> = {
52
+ /**
53
+ * If `true`, shipped fields skip rendering their inline `<FieldError>`
54
+ * message. Use it when you collect errors in a single summary somewhere
55
+ * else (e.g. above the submit button). The fields still flip
56
+ * `data-invalid` and `aria-invalid`, so invalid styling is preserved.
57
+ */
58
+ hideFieldErrors?: boolean
59
+ /**
60
+ * Show a `*` next to the label of every field the schema treats as
61
+ * required. Defaults to `true`. Set to `false` to opt out form-wide.
62
+ *
63
+ * Required-ness is detected by probing the schema with an empty object;
64
+ * fields wrapped in `.optional()`, `.nullable()`, or `.default(…)` are
65
+ * treated as not required. Async schemas can't be probed and produce no
66
+ * indicators. Pass `required` directly on a field to override the
67
+ * schema-derived value.
68
+ */
69
+ requiredIndicator?: boolean
70
+ /**
71
+ * Warn the user before they navigate away with unsaved changes. Two
72
+ * trigger modes:
73
+ *
74
+ * - `"if-changed"` (recommended) — warn while current values differ
75
+ * from defaults (`!state.isDefaultValue`). Clears if the user
76
+ * restores the original values, or after `formApi.reset(value)` is
77
+ * called post-submit to rebase defaults.
78
+ * - `"if-touched"` — warn after the user has edited any field, even
79
+ * if they reverted (`state.isDirty`, sticky once edited).
80
+ *
81
+ * Pass `true` as shorthand for `"if-changed"`. The warning never fires
82
+ * while the form is mid-submit. Covers full-page navigation only — for
83
+ * client-side route changes, read the relevant state flag from the
84
+ * form yourself and prompt before pushing the next route.
85
+ */
86
+ unsavedChangesWarning?: boolean | "if-changed" | "if-touched"
87
+ /**
88
+ * Initial values for the form fields. Relaxed to a deep partial so you can
89
+ * omit fields whose defaults come from the schema — `.default(...)` values
90
+ * flow through, and `z.string()` / `z.array(...)` without a default
91
+ * initialise to `""` / `[]` to keep inputs controlled. See `lib/schema-defaults.ts`.
92
+ *
93
+ * Numbers, booleans, dates, and other domain-loaded types still need an
94
+ * explicit value here when the schema doesn't provide one.
95
+ */
96
+ defaultValues?: PartialFormData<TFormData>
97
+ }
98
+
99
+ // Schema path — `schema` drives validation. `validators` (if present) is a
100
+ // `ValidationMode` string that selects *when* the schema fires; the matching
101
+ // `validationLogic` is wired internally, so it must not be supplied by the
102
+ // caller. To take manual control of `validators` / `validationLogic`, omit
103
+ // `schema` and use the manual path instead.
104
+ type SchemaPathExtras<TFormData> = {
105
+ /**
106
+ * A standard schema (Zod, Valibot, ArkType, …) describing the form data.
107
+ * Drives validation, default values, the `*` required indicator, and the
108
+ * `TFormData` type. Pair with `validators` (a `ValidationMode` string)
109
+ * to control timing.
110
+ */
111
+ schema: StandardSchema<TFormData>
112
+ /**
113
+ * When the schema runs and when errors are displayed.
114
+ *
115
+ * - `'blur-then-change'` (default) — schema runs on blur, then on every
116
+ * change for fields that have already been blurred. Errors appear after
117
+ * first blur. Calm typing, live corrections.
118
+ * - `'change'` — schema runs on every keystroke. Errors appear as soon as
119
+ * the user starts typing.
120
+ * - `'blur'` — schema runs only on blur. Errors appear after blur, but
121
+ * do not update until the next blur.
122
+ * - `'submit'` — schema runs only on submit. No errors before submit.
123
+ */
124
+ validators?: ValidationMode
125
+ /**
126
+ * Not accepted on the schema path — the mode chosen via `validators`
127
+ * selects the logic internally. Omit `schema` to supply your own.
128
+ */
129
+ validationLogic?: never
130
+ }
131
+
132
+ // Manual path — no schema. `validators` and `validationLogic` keep their
133
+ // native TanStack Form types unchanged.
134
+ type ManualPathExtras = {
135
+ schema?: never
136
+ }
137
+
138
+ type SchemaFormOptions<
139
+ TFormData,
140
+ TOnMount extends undefined | FormValidateOrFn<TFormData>,
141
+ TOnChange extends undefined | FormValidateOrFn<TFormData>,
142
+ TOnChangeAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
143
+ TOnBlur extends undefined | FormValidateOrFn<TFormData>,
144
+ TOnBlurAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
145
+ TOnSubmit extends undefined | FormValidateOrFn<TFormData>,
146
+ TOnSubmitAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
147
+ TOnDynamic extends undefined | FormValidateOrFn<TFormData>,
148
+ TOnDynamicAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
149
+ TOnServer extends undefined | FormAsyncValidateOrFn<TFormData>,
150
+ TSubmitMeta,
151
+ > = Omit<
152
+ FormOptions<
153
+ TFormData,
154
+ TOnMount,
155
+ TOnChange,
156
+ TOnChangeAsync,
157
+ TOnBlur,
158
+ TOnBlurAsync,
159
+ TOnSubmit,
160
+ TOnSubmitAsync,
161
+ TOnDynamic,
162
+ TOnDynamicAsync,
163
+ TOnServer,
164
+ TSubmitMeta
165
+ >,
166
+ "defaultValues" | "validators" | "validationLogic"
167
+ > &
168
+ SchemaPathExtras<TFormData> &
169
+ ZenoFormExtras<TFormData>
170
+
171
+ type ManualFormOptions<
172
+ TFormData,
173
+ TOnMount extends undefined | FormValidateOrFn<TFormData>,
174
+ TOnChange extends undefined | FormValidateOrFn<TFormData>,
175
+ TOnChangeAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
176
+ TOnBlur extends undefined | FormValidateOrFn<TFormData>,
177
+ TOnBlurAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
178
+ TOnSubmit extends undefined | FormValidateOrFn<TFormData>,
179
+ TOnSubmitAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
180
+ TOnDynamic extends undefined | FormValidateOrFn<TFormData>,
181
+ TOnDynamicAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
182
+ TOnServer extends undefined | FormAsyncValidateOrFn<TFormData>,
183
+ TSubmitMeta,
184
+ > = Omit<
185
+ FormOptions<
186
+ TFormData,
187
+ TOnMount,
188
+ TOnChange,
189
+ TOnChangeAsync,
190
+ TOnBlur,
191
+ TOnBlurAsync,
192
+ TOnSubmit,
193
+ TOnSubmitAsync,
194
+ TOnDynamic,
195
+ TOnDynamicAsync,
196
+ TOnServer,
197
+ TSubmitMeta
198
+ >,
199
+ "defaultValues"
200
+ > &
201
+ ManualPathExtras &
202
+ ZenoFormExtras<TFormData>
203
+
204
+ type UseFormOptions<
205
+ TFormData,
206
+ TOnMount extends undefined | FormValidateOrFn<TFormData>,
207
+ TOnChange extends undefined | FormValidateOrFn<TFormData>,
208
+ TOnChangeAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
209
+ TOnBlur extends undefined | FormValidateOrFn<TFormData>,
210
+ TOnBlurAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
211
+ TOnSubmit extends undefined | FormValidateOrFn<TFormData>,
212
+ TOnSubmitAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
213
+ TOnDynamic extends undefined | FormValidateOrFn<TFormData>,
214
+ TOnDynamicAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
215
+ TOnServer extends undefined | FormAsyncValidateOrFn<TFormData>,
216
+ TSubmitMeta,
217
+ > =
218
+ | SchemaFormOptions<
219
+ TFormData,
220
+ TOnMount,
221
+ TOnChange,
222
+ TOnChangeAsync,
223
+ TOnBlur,
224
+ TOnBlurAsync,
225
+ TOnSubmit,
226
+ TOnSubmitAsync,
227
+ TOnDynamic,
228
+ TOnDynamicAsync,
229
+ TOnServer,
230
+ TSubmitMeta
231
+ >
232
+ | ManualFormOptions<
233
+ TFormData,
234
+ TOnMount,
235
+ TOnChange,
236
+ TOnChangeAsync,
237
+ TOnBlur,
238
+ TOnBlurAsync,
239
+ TOnSubmit,
240
+ TOnSubmitAsync,
241
+ TOnDynamic,
242
+ TOnDynamicAsync,
243
+ TOnServer,
244
+ TSubmitMeta
245
+ >
246
+
247
+ function buildValidatorsFromSchema<TFormData>(
248
+ schema: StandardSchema<TFormData>,
249
+ mode: ValidationMode
250
+ ) {
251
+ switch (mode) {
252
+ case "change":
253
+ case "blur-then-change":
254
+ return { onChange: schema }
255
+ case "blur":
256
+ return { onBlur: schema }
257
+ case "submit":
258
+ return { onSubmit: schema }
259
+ default:
260
+ return { onChange: schema }
261
+ }
262
+ }
263
+
264
+ function useForm<
265
+ TFormData,
266
+ TOnMount extends undefined | FormValidateOrFn<TFormData> = undefined,
267
+ TOnChange extends undefined | FormValidateOrFn<TFormData> = undefined,
268
+ TOnChangeAsync extends
269
+ | undefined
270
+ | FormAsyncValidateOrFn<TFormData> = undefined,
271
+ TOnBlur extends undefined | FormValidateOrFn<TFormData> = undefined,
272
+ TOnBlurAsync extends undefined | FormAsyncValidateOrFn<TFormData> = undefined,
273
+ TOnSubmit extends undefined | FormValidateOrFn<TFormData> = undefined,
274
+ TOnSubmitAsync extends
275
+ | undefined
276
+ | FormAsyncValidateOrFn<TFormData> = undefined,
277
+ TOnDynamic extends undefined | FormValidateOrFn<TFormData> = undefined,
278
+ TOnDynamicAsync extends
279
+ | undefined
280
+ | FormAsyncValidateOrFn<TFormData> = undefined,
281
+ TOnServer extends undefined | FormAsyncValidateOrFn<TFormData> = undefined,
282
+ TSubmitMeta = never,
283
+ >(
284
+ options: UseFormOptions<
285
+ TFormData,
286
+ TOnMount,
287
+ TOnChange,
288
+ TOnChangeAsync,
289
+ TOnBlur,
290
+ TOnBlurAsync,
291
+ TOnSubmit,
292
+ TOnSubmitAsync,
293
+ TOnDynamic,
294
+ TOnDynamicAsync,
295
+ TOnServer,
296
+ TSubmitMeta
297
+ >
298
+ ) {
299
+ type NativeFormOptions = FormOptions<
300
+ TFormData,
301
+ TOnMount,
302
+ TOnChange,
303
+ TOnChangeAsync,
304
+ TOnBlur,
305
+ TOnBlurAsync,
306
+ TOnSubmit,
307
+ TOnSubmitAsync,
308
+ TOnDynamic,
309
+ TOnDynamicAsync,
310
+ TOnServer,
311
+ TSubmitMeta
312
+ >
313
+ type NativeValidators = NativeFormOptions["validators"]
314
+ type NativeValidationLogic = NativeFormOptions["validationLogic"]
315
+
316
+ // Loosen the union for a single destructure. We narrow on `schema` below.
317
+ const {
318
+ schema,
319
+ validators: validatorsInput,
320
+ validationLogic: userValidationLogic,
321
+ hideFieldErrors = false,
322
+ requiredIndicator = true,
323
+ unsavedChangesWarning = false,
324
+ defaultValues: userDefaultValues,
325
+ onSubmit: userOnSubmit,
326
+ ...rest
327
+ } = options as Omit<NativeFormOptions, "defaultValues"> & {
328
+ schema?: StandardSchema<TFormData>
329
+ validators?: ValidationMode | NativeValidators
330
+ validationLogic?: NativeValidationLogic
331
+ defaultValues?: PartialFormData<TFormData>
332
+ hideFieldErrors?: boolean
333
+ requiredIndicator?: boolean
334
+ unsavedChangesWarning?: boolean | "if-changed" | "if-touched"
335
+ }
336
+
337
+ let schemaMode: ValidationMode | undefined
338
+ let resolvedValidators: NativeValidators | undefined
339
+ let resolvedValidationLogic: NativeValidationLogic | undefined
340
+
341
+ if (schema === undefined) {
342
+ schemaMode = undefined
343
+ resolvedValidators =
344
+ typeof validatorsInput === "object" && validatorsInput !== null
345
+ ? (validatorsInput as NativeValidators)
346
+ : undefined
347
+ resolvedValidationLogic = userValidationLogic
348
+ } else {
349
+ const mode: ValidationMode =
350
+ typeof validatorsInput === "string"
351
+ ? (validatorsInput as ValidationMode)
352
+ : DEFAULT_VALIDATION_MODE
353
+ schemaMode = mode
354
+ resolvedValidators = buildValidatorsFromSchema(
355
+ schema,
356
+ mode
357
+ ) as unknown as NativeValidators
358
+ resolvedValidationLogic =
359
+ mode === "blur-then-change"
360
+ ? (blurThenChangeLogic as unknown as NativeValidationLogic)
361
+ : undefined
362
+ }
363
+
364
+ const wrappedOnSubmit = userOnSubmit
365
+ ? ((async (props: { formApi: AnyFormApi; value: TFormData }) => {
366
+ try {
367
+ await (
368
+ userOnSubmit as unknown as (
369
+ p: typeof props
370
+ ) => unknown | Promise<unknown>
371
+ )(props)
372
+ } catch (error) {
373
+ if (error instanceof ValidationError) {
374
+ applyValidationError(props.formApi, error)
375
+ return
376
+ }
377
+ throw error
378
+ }
379
+ }) as typeof userOnSubmit)
380
+ : undefined
381
+
382
+ const requiredFields = useMemo(
383
+ () =>
384
+ schema && requiredIndicator
385
+ ? getRequiredPaths(schema as Parameters<typeof getRequiredPaths>[0])
386
+ : new Set<string>(),
387
+ [schema, requiredIndicator]
388
+ )
389
+
390
+ const schemaDefaults = useMemo(
391
+ () =>
392
+ schema
393
+ ? extractZodDefaults(schema as Parameters<typeof extractZodDefaults>[0])
394
+ : undefined,
395
+ [schema]
396
+ )
397
+
398
+ const mergedDefaultValues = useMemo(
399
+ () =>
400
+ schemaDefaults
401
+ ? (deepMergeDefaults(
402
+ schemaDefaults,
403
+ userDefaultValues as Record<string, unknown> | undefined
404
+ ) as typeof userDefaultValues)
405
+ : userDefaultValues,
406
+ [schemaDefaults, userDefaultValues]
407
+ )
408
+
409
+ const form = useAppForm<
410
+ TFormData,
411
+ TOnMount,
412
+ TOnChange,
413
+ TOnChangeAsync,
414
+ TOnBlur,
415
+ TOnBlurAsync,
416
+ TOnSubmit,
417
+ TOnSubmitAsync,
418
+ TOnDynamic,
419
+ TOnDynamicAsync,
420
+ TOnServer,
421
+ TSubmitMeta
422
+ >({
423
+ ...rest,
424
+ ...(mergedDefaultValues === undefined
425
+ ? {}
426
+ : { defaultValues: mergedDefaultValues }),
427
+ ...(wrappedOnSubmit ? { onSubmit: wrappedOnSubmit } : {}),
428
+ ...(resolvedValidators ? { validators: resolvedValidators } : {}),
429
+ ...(resolvedValidationLogic
430
+ ? { validationLogic: resolvedValidationLogic }
431
+ : {}),
432
+ } as NativeFormOptions)
433
+
434
+ setFormZenoState(form, {
435
+ hideFieldErrors,
436
+ requiredFields,
437
+ requiredIndicator,
438
+ ...(schemaMode === undefined ? {} : { validation: schemaMode }),
439
+ })
440
+
441
+ useUnsavedChangesWarning(form, unsavedChangesWarning)
442
+
443
+ const fields = useAppFields(form)
444
+ return useMemo(
445
+ () => Object.assign(form, fields, { ResetButton, SubmitButton }),
446
+ [form, fields]
447
+ )
448
+ }
449
+
450
+ export { useForm }
@@ -0,0 +1,192 @@
1
+ import {
2
+ afterEach,
3
+ beforeEach,
4
+ describe,
5
+ expect,
6
+ test,
7
+ vi,
8
+ } from "@zeno-lib/vitest"
9
+ import { act, cleanup, render, screen } from "@zeno-lib/vitest/testing-library"
10
+ import userEvent from "@zeno-lib/vitest/user-event"
11
+ import { z } from "zod"
12
+
13
+ import { Form, FormProvider } from "./form"
14
+ import { useForm } from "./use-form"
15
+
16
+ const NAME_LABEL = /Name/
17
+
18
+ let addSpy: ReturnType<typeof vi.spyOn>
19
+ let removeSpy: ReturnType<typeof vi.spyOn>
20
+
21
+ beforeEach(() => {
22
+ addSpy = vi.spyOn(window, "addEventListener")
23
+ removeSpy = vi.spyOn(window, "removeEventListener")
24
+ })
25
+
26
+ afterEach(() => {
27
+ cleanup()
28
+ addSpy.mockRestore()
29
+ removeSpy.mockRestore()
30
+ })
31
+
32
+ function countBeforeunloadListeners() {
33
+ const added = addSpy.mock.calls.filter(
34
+ (call: unknown[]) => call[0] === "beforeunload"
35
+ ).length
36
+ const removed = removeSpy.mock.calls.filter(
37
+ (call: unknown[]) => call[0] === "beforeunload"
38
+ ).length
39
+ return added - removed
40
+ }
41
+
42
+ const schema = z.object({
43
+ name: z.string(),
44
+ })
45
+
46
+ describe("useUnsavedChangesWarning — disabled", () => {
47
+ test("no listener attached when option is false (default)", () => {
48
+ function H() {
49
+ const form = useForm({ onSubmit: vi.fn(), schema })
50
+ const { InputField } = form
51
+ return (
52
+ <FormProvider form={form}>
53
+ <Form>
54
+ <InputField label="Name" name="name" />
55
+ </Form>
56
+ </FormProvider>
57
+ )
58
+ }
59
+ render(<H />)
60
+ expect(countBeforeunloadListeners()).toBe(0)
61
+ })
62
+ })
63
+
64
+ describe("useUnsavedChangesWarning — 'if-changed' (and `true`)", () => {
65
+ test("listener attaches once values diverge from defaults", async () => {
66
+ const user = userEvent.setup()
67
+ function H() {
68
+ const form = useForm({
69
+ onSubmit: vi.fn(),
70
+ schema,
71
+ unsavedChangesWarning: "if-changed",
72
+ })
73
+ const { InputField } = form
74
+ return (
75
+ <FormProvider form={form}>
76
+ <Form>
77
+ <InputField label="Name" name="name" />
78
+ </Form>
79
+ </FormProvider>
80
+ )
81
+ }
82
+ render(<H />)
83
+ expect(countBeforeunloadListeners()).toBe(0)
84
+ await user.type(screen.getByLabelText(NAME_LABEL), "Alice")
85
+ expect(countBeforeunloadListeners()).toBe(1)
86
+ })
87
+
88
+ test("`true` is shorthand for 'if-changed'", async () => {
89
+ const user = userEvent.setup()
90
+ function H() {
91
+ const form = useForm({
92
+ onSubmit: vi.fn(),
93
+ schema,
94
+ unsavedChangesWarning: true,
95
+ })
96
+ const { InputField } = form
97
+ return (
98
+ <FormProvider form={form}>
99
+ <Form>
100
+ <InputField label="Name" name="name" />
101
+ </Form>
102
+ </FormProvider>
103
+ )
104
+ }
105
+ render(<H />)
106
+ expect(countBeforeunloadListeners()).toBe(0)
107
+ await user.type(screen.getByLabelText(NAME_LABEL), "Alice")
108
+ expect(countBeforeunloadListeners()).toBe(1)
109
+ })
110
+
111
+ test("listener detaches when value reverts to defaults", async () => {
112
+ const user = userEvent.setup()
113
+ function H() {
114
+ const form = useForm({
115
+ onSubmit: vi.fn(),
116
+ schema,
117
+ unsavedChangesWarning: "if-changed",
118
+ })
119
+ const { InputField } = form
120
+ return (
121
+ <FormProvider form={form}>
122
+ <Form>
123
+ <InputField label="Name" name="name" />
124
+ </Form>
125
+ </FormProvider>
126
+ )
127
+ }
128
+ render(<H />)
129
+ const input = screen.getByLabelText(NAME_LABEL) as HTMLInputElement
130
+ await user.type(input, "Alice")
131
+ expect(countBeforeunloadListeners()).toBe(1)
132
+ await user.clear(input)
133
+ expect(countBeforeunloadListeners()).toBe(0)
134
+ })
135
+ })
136
+
137
+ describe("useUnsavedChangesWarning — 'if-touched'", () => {
138
+ test("listener attaches on first edit and stays attached after revert", async () => {
139
+ const user = userEvent.setup()
140
+ function H() {
141
+ const form = useForm({
142
+ onSubmit: vi.fn(),
143
+ schema,
144
+ unsavedChangesWarning: "if-touched",
145
+ })
146
+ const { InputField } = form
147
+ return (
148
+ <FormProvider form={form}>
149
+ <Form>
150
+ <InputField label="Name" name="name" />
151
+ </Form>
152
+ </FormProvider>
153
+ )
154
+ }
155
+ render(<H />)
156
+ expect(countBeforeunloadListeners()).toBe(0)
157
+ const input = screen.getByLabelText(NAME_LABEL) as HTMLInputElement
158
+ await user.type(input, "Alice")
159
+ expect(countBeforeunloadListeners()).toBe(1)
160
+ // Revert — `if-touched` is sticky once edited.
161
+ await user.clear(input)
162
+ expect(countBeforeunloadListeners()).toBe(1)
163
+ })
164
+ })
165
+
166
+ describe("useUnsavedChangesWarning — beforeunload event behaviour", () => {
167
+ test("preventDefault is called and returnValue is set", async () => {
168
+ const user = userEvent.setup()
169
+ function H() {
170
+ const form = useForm({
171
+ onSubmit: vi.fn(),
172
+ schema,
173
+ unsavedChangesWarning: "if-changed",
174
+ })
175
+ const { InputField } = form
176
+ return (
177
+ <FormProvider form={form}>
178
+ <Form>
179
+ <InputField label="Name" name="name" />
180
+ </Form>
181
+ </FormProvider>
182
+ )
183
+ }
184
+ render(<H />)
185
+ await user.type(screen.getByLabelText(NAME_LABEL), "Alice")
186
+ const event = new Event("beforeunload", { cancelable: true })
187
+ act(() => {
188
+ window.dispatchEvent(event)
189
+ })
190
+ expect(event.defaultPrevented).toBe(true)
191
+ })
192
+ })