@pyreon/form 0.11.3 → 0.11.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/form",
3
- "version": "0.11.3",
3
+ "version": "0.11.4",
4
4
  "description": "Signal-based form management for Pyreon",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -46,13 +46,13 @@
46
46
  "lint": "biome check ."
47
47
  },
48
48
  "peerDependencies": {
49
- "@pyreon/core": "^0.11.3",
50
- "@pyreon/reactivity": "^0.11.3"
49
+ "@pyreon/core": "^0.11.4",
50
+ "@pyreon/reactivity": "^0.11.4"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@happy-dom/global-registrator": "^20.8.3",
54
- "@pyreon/core": "^0.11.3",
55
- "@pyreon/reactivity": "^0.11.3",
56
- "@pyreon/runtime-dom": "^0.11.3"
54
+ "@pyreon/core": "^0.11.4",
55
+ "@pyreon/reactivity": "^0.11.4",
56
+ "@pyreon/runtime-dom": "^0.11.4"
57
57
  }
58
58
  }
@@ -0,0 +1,567 @@
1
+ import { effect } from "@pyreon/reactivity"
2
+ import { mount } from "@pyreon/runtime-dom"
3
+ import type { FormState } from "../index"
4
+ import {
5
+ FormProvider,
6
+ useFieldArray,
7
+ useForm,
8
+ useFormContext,
9
+ useFormState,
10
+ useWatch,
11
+ } from "../index"
12
+
13
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
14
+
15
+ function Capture<T>({ fn }: { fn: () => T }) {
16
+ fn()
17
+ return null
18
+ }
19
+
20
+ function mountWith<T>(fn: () => T): { result: T; unmount: () => void } {
21
+ let result: T | undefined
22
+ const el = document.createElement("div")
23
+ document.body.appendChild(el)
24
+ const unmount = mount(
25
+ <Capture
26
+ fn={() => {
27
+ result = fn()
28
+ }}
29
+ />,
30
+ el,
31
+ )
32
+ return {
33
+ result: result!,
34
+ unmount: () => {
35
+ unmount()
36
+ el.remove()
37
+ },
38
+ }
39
+ }
40
+
41
+ // ─── useFieldArray — additional operations ───────────────────────────────────
42
+
43
+ describe("useFieldArray — additional operations", () => {
44
+ it("append multiple items sequentially", () => {
45
+ const { result: arr, unmount } = mountWith(() => useFieldArray<string>([]))
46
+
47
+ arr.append("a")
48
+ arr.append("b")
49
+ arr.append("c")
50
+ expect(arr.values()).toEqual(["a", "b", "c"])
51
+ expect(arr.length()).toBe(3)
52
+ unmount()
53
+ })
54
+
55
+ it("remove first item shifts remaining", () => {
56
+ const { result: arr, unmount } = mountWith(() => useFieldArray(["x", "y", "z"]))
57
+
58
+ arr.remove(0)
59
+ expect(arr.values()).toEqual(["y", "z"])
60
+ expect(arr.length()).toBe(2)
61
+ unmount()
62
+ })
63
+
64
+ it("remove last item", () => {
65
+ const { result: arr, unmount } = mountWith(() => useFieldArray(["a", "b", "c"]))
66
+
67
+ arr.remove(2)
68
+ expect(arr.values()).toEqual(["a", "b"])
69
+ unmount()
70
+ })
71
+
72
+ it("move item forward (lower to higher index)", () => {
73
+ const { result: arr, unmount } = mountWith(() => useFieldArray(["a", "b", "c", "d"]))
74
+
75
+ arr.move(1, 3) // move "b" from index 1 to index 3
76
+ expect(arr.values()).toEqual(["a", "c", "d", "b"])
77
+ unmount()
78
+ })
79
+
80
+ it("move item backward (higher to lower index)", () => {
81
+ const { result: arr, unmount } = mountWith(() => useFieldArray(["a", "b", "c", "d"]))
82
+
83
+ arr.move(3, 0) // move "d" from index 3 to index 0
84
+ expect(arr.values()).toEqual(["d", "a", "b", "c"])
85
+ unmount()
86
+ })
87
+
88
+ it("swap preserves all other items", () => {
89
+ const { result: arr, unmount } = mountWith(() => useFieldArray(["a", "b", "c", "d", "e"]))
90
+
91
+ arr.swap(1, 3) // swap "b" and "d"
92
+ expect(arr.values()).toEqual(["a", "d", "c", "b", "e"])
93
+ unmount()
94
+ })
95
+
96
+ it("prepend then remove first restores original", () => {
97
+ const { result: arr, unmount } = mountWith(() => useFieldArray(["x", "y"]))
98
+
99
+ arr.prepend("new")
100
+ expect(arr.values()).toEqual(["new", "x", "y"])
101
+ arr.remove(0)
102
+ expect(arr.values()).toEqual(["x", "y"])
103
+ unmount()
104
+ })
105
+
106
+ it("insert at beginning is equivalent to prepend", () => {
107
+ const { result: arr, unmount } = mountWith(() => useFieldArray(["b", "c"]))
108
+
109
+ arr.insert(0, "a")
110
+ expect(arr.values()).toEqual(["a", "b", "c"])
111
+ unmount()
112
+ })
113
+
114
+ it("insert at end is equivalent to append", () => {
115
+ const { result: arr, unmount } = mountWith(() => useFieldArray(["a", "b"]))
116
+
117
+ arr.insert(2, "c")
118
+ expect(arr.values()).toEqual(["a", "b", "c"])
119
+ unmount()
120
+ })
121
+
122
+ it("replace with empty array clears all items", () => {
123
+ const { result: arr, unmount } = mountWith(() => useFieldArray(["a", "b", "c"]))
124
+
125
+ arr.replace([])
126
+ expect(arr.values()).toEqual([])
127
+ expect(arr.length()).toBe(0)
128
+ unmount()
129
+ })
130
+
131
+ it("replace generates new keys for all items", () => {
132
+ const { result: arr, unmount } = mountWith(() => useFieldArray(["a", "b"]))
133
+
134
+ const keysBefore = arr.items().map((i: any) => i.key)
135
+ arr.replace(["x", "y"])
136
+ const keysAfter = arr.items().map((i: any) => i.key)
137
+
138
+ // All keys should be different since replace creates new items
139
+ expect(keysAfter[0]).not.toBe(keysBefore[0])
140
+ expect(keysAfter[1]).not.toBe(keysBefore[1])
141
+ unmount()
142
+ })
143
+
144
+ it("items signal is reactive — effect fires on append", () => {
145
+ const { result: arr, unmount } = mountWith(() => useFieldArray<string>([]))
146
+
147
+ const lengths: number[] = []
148
+ const cleanup = effect(() => {
149
+ lengths.push(arr.items().length)
150
+ })
151
+
152
+ expect(lengths).toEqual([0])
153
+
154
+ arr.append("a")
155
+ expect(lengths).toEqual([0, 1])
156
+
157
+ arr.append("b")
158
+ expect(lengths).toEqual([0, 1, 2])
159
+
160
+ cleanup.dispose()
161
+ unmount()
162
+ })
163
+
164
+ it("update preserves key identity", () => {
165
+ const { result: arr, unmount } = mountWith(() => useFieldArray(["a", "b", "c"]))
166
+
167
+ const keyBefore = arr.items()[1]!.key
168
+ arr.update(1, "updated")
169
+ const keyAfter = arr.items()[1]!.key
170
+
171
+ // Same item, just value changed — key should be identical
172
+ expect(keyAfter).toBe(keyBefore)
173
+ expect(arr.items()[1]!.value()).toBe("updated")
174
+ unmount()
175
+ })
176
+ })
177
+
178
+ // ─── useWatch — reactivity ───────────────────────────────────────────────────
179
+
180
+ describe("useWatch — reactivity", () => {
181
+ it("single field watch is reactive to value changes", () => {
182
+ const { result, unmount } = mountWith(() => {
183
+ const form = useForm({
184
+ initialValues: { count: 0 },
185
+ onSubmit: () => {
186
+ /* noop */
187
+ },
188
+ })
189
+ const count = useWatch(form, "count")
190
+ return { form, count }
191
+ })
192
+
193
+ const values: number[] = []
194
+ const cleanup = effect(() => {
195
+ values.push(result.count() as number)
196
+ })
197
+
198
+ expect(values).toEqual([0])
199
+
200
+ result.form.fields.count.setValue(1)
201
+ expect(values).toEqual([0, 1])
202
+
203
+ result.form.fields.count.setValue(42)
204
+ expect(values).toEqual([0, 1, 42])
205
+
206
+ cleanup.dispose()
207
+ unmount()
208
+ })
209
+
210
+ it("watch all fields is reactive to any field change", () => {
211
+ const { result, unmount } = mountWith(() => {
212
+ const form = useForm({
213
+ initialValues: { first: "A", second: "B" },
214
+ onSubmit: () => {
215
+ /* noop */
216
+ },
217
+ })
218
+ const all = useWatch(form)
219
+ return { form, all }
220
+ })
221
+
222
+ expect(result.all()).toEqual({ first: "A", second: "B" })
223
+
224
+ result.form.fields.first.setValue("X")
225
+ expect(result.all()).toEqual({ first: "X", second: "B" })
226
+
227
+ result.form.fields.second.setValue("Y")
228
+ expect(result.all()).toEqual({ first: "X", second: "Y" })
229
+ unmount()
230
+ })
231
+
232
+ it("multiple field watch returns signal array with correct types", () => {
233
+ const { result, unmount } = mountWith(() => {
234
+ const form = useForm({
235
+ initialValues: { name: "Alice", age: 30 },
236
+ onSubmit: () => {
237
+ /* noop */
238
+ },
239
+ })
240
+ const [name, age] = useWatch(form, ["name", "age"])
241
+ return { form, name, age }
242
+ })
243
+
244
+ expect(result.name!()).toBe("Alice")
245
+ expect(result.age!()).toBe(30)
246
+
247
+ result.form.fields.name.setValue("Bob")
248
+ expect(result.name!()).toBe("Bob")
249
+ expect(result.age!()).toBe(30) // unchanged
250
+ unmount()
251
+ })
252
+ })
253
+
254
+ // ─── useFormState — additional scenarios ─────────────────────────────────────
255
+
256
+ describe("useFormState — additional scenarios", () => {
257
+ it("tracks dirty fields correctly after multiple changes", () => {
258
+ const { result, unmount } = mountWith(() => {
259
+ const form = useForm({
260
+ initialValues: { email: "", name: "", age: 0 },
261
+ onSubmit: () => {
262
+ /* noop */
263
+ },
264
+ })
265
+ const state = useFormState(form)
266
+ return { form, state }
267
+ })
268
+
269
+ result.form.fields.email.setValue("test@test.com")
270
+ result.form.fields.name.setValue("Alice")
271
+
272
+ const s = result.state()
273
+ expect(s.dirtyFields).toEqual({ email: true, name: true })
274
+ expect(s.isDirty).toBe(true)
275
+
276
+ // Revert email
277
+ result.form.fields.email.setValue("")
278
+ const s2 = result.state()
279
+ expect(s2.dirtyFields).toEqual({ name: true })
280
+ expect(s2.isDirty).toBe(true)
281
+
282
+ // Revert name too
283
+ result.form.fields.name.setValue("")
284
+ const s3 = result.state()
285
+ expect(s3.dirtyFields).toEqual({})
286
+ expect(s3.isDirty).toBe(false)
287
+ unmount()
288
+ })
289
+
290
+ it("tracks touched fields after blur events", () => {
291
+ const { result, unmount } = mountWith(() => {
292
+ const form = useForm({
293
+ initialValues: { email: "", password: "" },
294
+ onSubmit: () => {
295
+ /* noop */
296
+ },
297
+ })
298
+ const state = useFormState(form)
299
+ return { form, state }
300
+ })
301
+
302
+ expect(result.state().touchedFields).toEqual({})
303
+
304
+ result.form.fields.email.setTouched()
305
+ expect(result.state().touchedFields).toEqual({ email: true })
306
+
307
+ result.form.fields.password.setTouched()
308
+ expect(result.state().touchedFields).toEqual({ email: true, password: true })
309
+ unmount()
310
+ })
311
+
312
+ it("submitCount and submitError are tracked", async () => {
313
+ const { result, unmount } = mountWith(() => {
314
+ const form = useForm({
315
+ initialValues: { name: "valid" },
316
+ onSubmit: async () => {
317
+ throw new Error("Submit failed")
318
+ },
319
+ })
320
+ const state = useFormState(form)
321
+ return { form, state }
322
+ })
323
+
324
+ expect(result.state().submitCount).toBe(0)
325
+ expect(result.state().submitError).toBeUndefined()
326
+
327
+ await result.form.handleSubmit().catch(() => {
328
+ /* expected */
329
+ })
330
+
331
+ expect(result.state().submitCount).toBe(1)
332
+ expect(result.state().submitError).toBeInstanceOf(Error)
333
+ unmount()
334
+ })
335
+
336
+ it("isValidating is tracked during async validation", async () => {
337
+ const { result, unmount } = mountWith(() => {
338
+ const form = useForm({
339
+ initialValues: { name: "" },
340
+ validators: {
341
+ name: async (v) => {
342
+ await new Promise((r) => setTimeout(r, 20))
343
+ return !v ? "Required" : undefined
344
+ },
345
+ },
346
+ onSubmit: () => {
347
+ /* noop */
348
+ },
349
+ })
350
+ const state = useFormState(form)
351
+ return { form, state }
352
+ })
353
+
354
+ expect(result.state().isValidating).toBe(false)
355
+
356
+ const validatePromise = result.form.validate()
357
+ expect(result.state().isValidating).toBe(true)
358
+
359
+ await validatePromise
360
+ expect(result.state().isValidating).toBe(false)
361
+ unmount()
362
+ })
363
+ })
364
+
365
+ // ─── Validation integration — schema-based ───────────────────────────────────
366
+
367
+ describe("validation integration — schema-based", () => {
368
+ it("schema errors are set only on fields without field-level errors", async () => {
369
+ const { result: form, unmount } = mountWith(() =>
370
+ useForm({
371
+ initialValues: { email: "", password: "" },
372
+ validators: {
373
+ email: (v) => (!v ? "Email required" : undefined),
374
+ },
375
+ schema: (_values) => ({
376
+ email: "Schema email error", // Should be overridden by field-level
377
+ password: "Schema password error",
378
+ }),
379
+ onSubmit: () => {
380
+ /* noop */
381
+ },
382
+ }),
383
+ )
384
+
385
+ await form.validate()
386
+
387
+ // email has a field-level error ("Email required") — schema error ignored
388
+ expect(form.fields.email.error()).toBe("Email required")
389
+ // password has no field-level validator — schema error applied
390
+ expect(form.fields.password.error()).toBe("Schema password error")
391
+ unmount()
392
+ })
393
+
394
+ it("async schema validation works", async () => {
395
+ const { result: form, unmount } = mountWith(() =>
396
+ useForm({
397
+ initialValues: { code: "" },
398
+ schema: async (values) => {
399
+ await new Promise((r) => setTimeout(r, 5))
400
+ if (!values.code) return { code: "Code is required" }
401
+ return {}
402
+ },
403
+ onSubmit: () => {
404
+ /* noop */
405
+ },
406
+ }),
407
+ )
408
+
409
+ const valid = await form.validate()
410
+ expect(valid).toBe(false)
411
+ expect(form.fields.code.error()).toBe("Code is required")
412
+ unmount()
413
+ })
414
+ })
415
+
416
+ // ─── form.reset() clears all field state ─────────────────────────────────────
417
+
418
+ describe("form.reset() comprehensive", () => {
419
+ it("clears all field state including errors, touched, dirty, submitCount, submitError", async () => {
420
+ const { result: form, unmount } = mountWith(() =>
421
+ useForm({
422
+ initialValues: { email: "", password: "" },
423
+ validators: {
424
+ email: (v) => (!v ? "Required" : undefined),
425
+ password: (v) => (!v ? "Required" : undefined),
426
+ },
427
+ onSubmit: () => {
428
+ /* noop */
429
+ },
430
+ }),
431
+ )
432
+
433
+ // Dirty up the form
434
+ form.fields.email.setValue("changed")
435
+ form.fields.password.setValue("changed")
436
+ form.fields.email.setTouched()
437
+ form.fields.password.setTouched()
438
+ await form.handleSubmit() // triggers validation
439
+
440
+ // Verify state is dirty
441
+ expect(form.fields.email.dirty()).toBe(true)
442
+ expect(form.fields.password.dirty()).toBe(true)
443
+ expect(form.fields.email.touched()).toBe(true)
444
+ expect(form.fields.password.touched()).toBe(true)
445
+ expect(form.submitCount()).toBe(1)
446
+
447
+ // Reset everything
448
+ form.reset()
449
+
450
+ // All state should be cleared
451
+ expect(form.fields.email.value()).toBe("")
452
+ expect(form.fields.password.value()).toBe("")
453
+ expect(form.fields.email.error()).toBeUndefined()
454
+ expect(form.fields.password.error()).toBeUndefined()
455
+ expect(form.fields.email.touched()).toBe(false)
456
+ expect(form.fields.password.touched()).toBe(false)
457
+ expect(form.fields.email.dirty()).toBe(false)
458
+ expect(form.fields.password.dirty()).toBe(false)
459
+ expect(form.submitCount()).toBe(0)
460
+ expect(form.submitError()).toBeUndefined()
461
+ expect(form.isDirty()).toBe(false)
462
+ expect(form.isValid()).toBe(true)
463
+ unmount()
464
+ })
465
+ })
466
+
467
+ // ─── Debounced validation — additional scenarios ─────────────────────────────
468
+
469
+ describe("debounced validation — additional", () => {
470
+ it("debounced validation on blur only fires after delay", async () => {
471
+ const calls: string[] = []
472
+ const { result: form, unmount } = mountWith(() =>
473
+ useForm({
474
+ initialValues: { query: "" },
475
+ validators: {
476
+ query: (v) => {
477
+ calls.push(v as string)
478
+ return undefined
479
+ },
480
+ },
481
+ validateOn: "blur",
482
+ debounceMs: 40,
483
+ onSubmit: () => {
484
+ /* noop */
485
+ },
486
+ }),
487
+ )
488
+
489
+ form.fields.query.setValue("a")
490
+ form.fields.query.setTouched()
491
+ form.fields.query.setValue("ab")
492
+ form.fields.query.setTouched()
493
+
494
+ // Nothing fired yet
495
+ await new Promise((r) => setTimeout(r, 10))
496
+ expect(calls).toHaveLength(0)
497
+
498
+ // After debounce, only the last blur validation fires
499
+ await new Promise((r) => setTimeout(r, 60))
500
+ expect(calls).toHaveLength(1)
501
+ unmount()
502
+ })
503
+
504
+ it("field-level reset clears debounce timer for that field", async () => {
505
+ let callCount = 0
506
+ const { result: form, unmount } = mountWith(() =>
507
+ useForm({
508
+ initialValues: { name: "" },
509
+ validators: {
510
+ name: () => {
511
+ callCount++
512
+ return undefined
513
+ },
514
+ },
515
+ validateOn: "blur",
516
+ debounceMs: 50,
517
+ onSubmit: () => {
518
+ /* noop */
519
+ },
520
+ }),
521
+ )
522
+
523
+ form.fields.name.setTouched()
524
+ // Reset the individual field before debounce fires
525
+ form.fields.name.reset()
526
+
527
+ await new Promise((r) => setTimeout(r, 80))
528
+ // Timer was cleared by field reset
529
+ expect(callCount).toBe(0)
530
+ unmount()
531
+ })
532
+ })
533
+
534
+ // ─── FormProvider with direct VNode children ─────────────────────────────────
535
+
536
+ describe("FormProvider — VNode children branch", () => {
537
+ it("renders when children is a direct VNode (not a function)", () => {
538
+ let contextForm: FormState<{ name: string }> | undefined
539
+ const el = document.createElement("div")
540
+ document.body.appendChild(el)
541
+
542
+ function Child() {
543
+ contextForm = useFormContext() as FormState<{ name: string }>
544
+ return null
545
+ }
546
+
547
+ function TestComponent() {
548
+ const form = useForm({
549
+ initialValues: { name: "Direct" },
550
+ onSubmit: () => {
551
+ /* noop */
552
+ },
553
+ })
554
+ return (
555
+ <FormProvider form={form as any}>
556
+ <Child />
557
+ </FormProvider>
558
+ )
559
+ }
560
+
561
+ const unmount = mount(<TestComponent />, el)
562
+ expect(contextForm).toBeDefined()
563
+ expect(contextForm!.fields.name.value()).toBe("Direct")
564
+ unmount()
565
+ el.remove()
566
+ })
567
+ })