@zeno-lib/forms 0.3.0 → 0.5.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 (59) hide show
  1. package/dist/create-zeno-form.mjs +9 -3
  2. package/dist/form-element.mjs +7 -5
  3. package/dist/index.d.mts +2 -2
  4. package/dist/index.mjs +3 -3
  5. package/dist/lib/empty-value.d.mts +12 -0
  6. package/dist/lib/empty-value.mjs +68 -0
  7. package/dist/lib/focus-first-invalid.d.mts +23 -0
  8. package/dist/lib/focus-first-invalid.mjs +37 -0
  9. package/dist/lib/schema-defaults.d.mts +9 -1
  10. package/dist/lib/schema-defaults.mjs +33 -1
  11. package/dist/lib/schema-required.d.mts +2 -1
  12. package/dist/lib/schema-required.mjs +49 -21
  13. package/dist/lib/submit-action.mjs +1 -1
  14. package/dist/lib/submit-error.d.mts +36 -0
  15. package/dist/lib/submit-error.mjs +66 -0
  16. package/dist/lib/use-form-dialog.d.mts +15 -1
  17. package/dist/lib/use-form-dialog.mjs +20 -2
  18. package/dist/lib/use-is-invalid.d.mts +2 -1
  19. package/dist/lib/use-is-invalid.mjs +5 -2
  20. package/dist/lib/validation-modes.d.mts +5 -1
  21. package/dist/lib/validation-modes.mjs +9 -4
  22. package/package.json +1 -1
  23. package/src/create-form.tsx +2 -0
  24. package/src/create-zeno-form.tsx +25 -5
  25. package/src/fields/checkbox-field.tsx +8 -1
  26. package/src/fields/combobox-field.tsx +29 -12
  27. package/src/fields/date-picker-field.tsx +37 -9
  28. package/src/fields/email-field.tsx +8 -0
  29. package/src/fields/form-error.tsx +61 -0
  30. package/src/fields/index.ts +1 -0
  31. package/src/fields/input-field.tsx +6 -2
  32. package/src/fields/money-field.tsx +3 -1
  33. package/src/fields/multi-select-field.tsx +8 -1
  34. package/src/fields/number-field.tsx +9 -7
  35. package/src/fields/otp-field.tsx +8 -1
  36. package/src/fields/percentage-field.tsx +3 -1
  37. package/src/fields/radio-group-field.tsx +11 -4
  38. package/src/fields/select-field.tsx +26 -3
  39. package/src/fields/switch-field.tsx +8 -1
  40. package/src/fields/textarea-field.tsx +8 -1
  41. package/src/fields/year-field.tsx +3 -1
  42. package/src/form-dialog.test.tsx +493 -2
  43. package/src/form-dialog.tsx +217 -33
  44. package/src/form-element.tsx +13 -11
  45. package/src/form-error.test.tsx +255 -0
  46. package/src/index.ts +1 -0
  47. package/src/lib/empty-value.test.ts +72 -0
  48. package/src/lib/empty-value.ts +130 -0
  49. package/src/lib/focus-first-invalid.test.ts +22 -0
  50. package/src/lib/focus-first-invalid.ts +52 -0
  51. package/src/lib/schema-defaults.ts +50 -1
  52. package/src/lib/schema-required.test.ts +24 -2
  53. package/src/lib/schema-required.ts +81 -43
  54. package/src/lib/submit-error.ts +133 -0
  55. package/src/lib/use-form-dialog.ts +49 -1
  56. package/src/lib/use-is-invalid.ts +14 -1
  57. package/src/lib/validation-modes.ts +18 -6
  58. package/src/use-app-fields.test.tsx +312 -2
  59. package/src/use-form.focus-invalid.test.tsx +34 -0
@@ -1,23 +1,42 @@
1
1
  import {
2
+ act,
2
3
  cleanup,
3
4
  render,
4
5
  screen,
5
6
  waitFor,
7
+ within,
6
8
  } from "@zeno-lib/test/testing-library"
7
9
  import userEvent from "@zeno-lib/test/user-event"
8
- import { afterEach, describe, expect, test, vi } from "vitest"
10
+ import { type ReactNode, useState } from "react"
11
+ import { afterEach, describe, expect, onTestFinished, test, vi } from "vitest"
9
12
  import { z } from "zod"
10
13
  import { useForm } from "./create-form"
11
14
  import { FormDialog } from "./form-dialog"
15
+ import type { ActionResult } from "./lib/action-result"
16
+ import { submitAction } from "./lib/submit-action"
12
17
  import { useFormDialog } from "./lib/use-form-dialog"
13
18
 
14
19
  const NAME = /Name/
15
20
  const EMAIL = /Email/
21
+ const INNER_NAME = /Inner name/
22
+
23
+ const animatable = HTMLElement.prototype as {
24
+ getAnimations?: () => unknown[]
25
+ }
16
26
 
17
27
  afterEach(() => {
28
+ animatable.getAnimations = undefined
18
29
  cleanup()
30
+ vi.unstubAllGlobals()
19
31
  })
20
32
 
33
+ // jsdom runs no CSS animations, so Base UI unmounts a closing popup at once.
34
+ // Report an animation that never ends, as a browser does while the exit
35
+ // animation plays, so the popup stays mounted after it closes.
36
+ function holdExitAnimation() {
37
+ animatable.getAnimations = () => [{ finished: new Promise(() => undefined) }]
38
+ }
39
+
21
40
  const schema = z.object({
22
41
  email: z.string(),
23
42
  name: z.string().min(1, "Required"),
@@ -27,16 +46,28 @@ const BLANK: Values = { email: "", name: "" }
27
46
  const ROW: Values = { email: "ada@example.com", name: "Ada" }
28
47
 
29
48
  function Harness({
49
+ action,
50
+ children,
30
51
  onSubmit = vi.fn(),
31
52
  closeOnSubmit,
53
+ disabled,
54
+ saveFromPrompt,
55
+ submitErrorMessage,
32
56
  }: {
57
+ /** Submit through `submitAction` with this server action instead. */
58
+ action?: (values: Values) => Promise<ActionResult<unknown>>
59
+ children?: ReactNode
33
60
  onSubmit?: (values: Values) => unknown
34
61
  closeOnSubmit?: boolean
62
+ disabled?: boolean
63
+ saveFromPrompt?: boolean
64
+ submitErrorMessage?: (error: unknown) => ReactNode
35
65
  }) {
36
66
  const dialog = useFormDialog({ defaultValues: BLANK })
37
67
  const form = useForm({
38
68
  defaultValues: dialog.defaultValues,
39
- onSubmit: ({ value }) => onSubmit(value),
69
+ onSubmit: (submit) =>
70
+ action ? submitAction(submit, action) : onSubmit(submit.value),
40
71
  schema,
41
72
  })
42
73
  const { InputField } = form
@@ -54,16 +85,55 @@ function Harness({
54
85
  <FormDialog
55
86
  closeOnSubmit={closeOnSubmit}
56
87
  dialog={dialog}
88
+ disabled={disabled}
57
89
  form={form}
90
+ saveFromPrompt={saveFromPrompt}
91
+ submitErrorMessage={submitErrorMessage}
58
92
  title="Person"
59
93
  >
60
94
  <InputField label="Name" name="name" />
61
95
  <InputField label="Email" name="email" />
96
+ {children}
62
97
  </FormDialog>
63
98
  </>
64
99
  )
65
100
  }
66
101
 
102
+ // Browsers drop focus to the body when the focused Save button is disabled
103
+ // for the submit; jsdom keeps it there (and can't blur a disabled button).
104
+ // Drop it as a browser would: through an element that is then removed.
105
+ function dropFocus() {
106
+ const sink = document.createElement("input")
107
+ document.body.append(sink)
108
+ sink.focus()
109
+ sink.remove()
110
+ }
111
+
112
+ // The alerts added where assistive tech could see them. One added under an
113
+ // `aria-hidden` ancestor (the dialog, while the leave prompt is open) is never
114
+ // announced, not even once that ancestor is shown again.
115
+ function watchAnnouncedAlerts(): string[] {
116
+ const announced: string[] = []
117
+ const observer = new MutationObserver((records) => {
118
+ for (const node of records.flatMap((record) => [...record.addedNodes])) {
119
+ if (!(node instanceof Element)) {
120
+ continue
121
+ }
122
+ for (const alert of [node, ...node.querySelectorAll("*")]) {
123
+ if (
124
+ alert.getAttribute("role") === "alert" &&
125
+ !alert.closest('[aria-hidden="true"]')
126
+ ) {
127
+ announced.push(alert.textContent ?? "")
128
+ }
129
+ }
130
+ }
131
+ })
132
+ observer.observe(document.body, { childList: true, subtree: true })
133
+ onTestFinished(() => observer.disconnect())
134
+ return announced
135
+ }
136
+
67
137
  // Open the "Edit" session and wait for its initial focus (on Email) to land,
68
138
  // so later typing isn't raced by the dialog's focus management.
69
139
  async function openEdit(user: ReturnType<typeof userEvent.setup>) {
@@ -158,4 +228,425 @@ describe("FormDialog", () => {
158
228
  await waitFor(() => expect(onSubmit).toHaveBeenCalled())
159
229
  expect(screen.getByRole("dialog")).toBeTruthy()
160
230
  })
231
+
232
+ test("a failed Save focuses the first invalid field", async () => {
233
+ const user = userEvent.setup()
234
+ render(<Harness />)
235
+ await user.click(screen.getByRole("button", { name: "New" }))
236
+ const name = await screen.findByLabelText(NAME)
237
+ // The dialog's initial focus also lands on Name; let it settle so only
238
+ // the failed Save can bring focus back from the Save button.
239
+ await waitFor(() => expect(document.activeElement).toBe(name))
240
+ await user.click(screen.getByRole("button", { name: "Save" }))
241
+ expect(await screen.findByText("Required")).toBeTruthy()
242
+ await waitFor(() => expect(document.activeElement).toBe(name))
243
+ })
244
+
245
+ test("a save that ends after the dialog reopened leaves the new session open", async () => {
246
+ const user = userEvent.setup()
247
+ let finishSave: () => void = () => undefined
248
+ const onSubmit = vi.fn(
249
+ () =>
250
+ new Promise<void>((resolve) => {
251
+ finishSave = resolve
252
+ })
253
+ )
254
+ render(<Harness onSubmit={onSubmit} />)
255
+ await openEdit(user)
256
+ await user.click(screen.getByRole("button", { name: "Save" }))
257
+ await waitFor(() => expect(onSubmit).toHaveBeenCalled())
258
+ await user.click(screen.getByRole("button", { name: "Cancel" }))
259
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull())
260
+
261
+ await user.click(screen.getByRole("button", { name: "New" }))
262
+ await screen.findByRole("dialog")
263
+ await act(async () => {
264
+ finishSave()
265
+ await Promise.resolve()
266
+ })
267
+ expect(screen.getByRole("dialog")).toBeTruthy()
268
+ })
269
+
270
+ test("a field error from the server focuses that field", async () => {
271
+ const user = userEvent.setup()
272
+ const action = vi.fn(() =>
273
+ Promise.resolve<ActionResult<never>>({
274
+ error: { fieldErrors: { email: ["Already taken"] }, formErrors: [] },
275
+ ok: false,
276
+ })
277
+ )
278
+ render(<Harness action={action} />)
279
+ await user.click(screen.getByRole("button", { name: "New" }))
280
+ await user.type(await screen.findByLabelText(NAME), "Bob")
281
+ await user.click(screen.getByRole("button", { name: "Save" }))
282
+ expect(await screen.findByText("Already taken")).toBeTruthy()
283
+ await waitFor(() =>
284
+ expect(document.activeElement).toBe(screen.getByLabelText(EMAIL))
285
+ )
286
+ expect(screen.getByRole("dialog")).toBeTruthy()
287
+ })
288
+
289
+ test("an action's form-level error shows in the dialog, focus back on Save", async () => {
290
+ const user = userEvent.setup()
291
+ const action = vi.fn(() => {
292
+ dropFocus()
293
+ return Promise.resolve<ActionResult<never>>({
294
+ error: { fieldErrors: {}, formErrors: ["Record is locked"] },
295
+ ok: false,
296
+ })
297
+ })
298
+ render(<Harness action={action} />)
299
+ const name = await openEdit(user)
300
+ const save = screen.getByRole("button", { name: "Save" })
301
+ await user.click(save)
302
+
303
+ const alert = await screen.findByRole("alert")
304
+ expect(alert.textContent).toBe("Record is locked")
305
+ expect(screen.getByRole("dialog")).toBeTruthy()
306
+ await waitFor(() => expect(document.activeElement).toBe(save))
307
+
308
+ await user.type(name, "!")
309
+ await waitFor(() => expect(screen.queryByRole("alert")).toBeNull())
310
+ })
311
+
312
+ test("a thrown submit shows a generic message until an edit or a resubmit", async () => {
313
+ const user = userEvent.setup()
314
+ let rejectRetry: (error: Error) => void = () => undefined
315
+ const onSubmit = vi
316
+ .fn<(values: Values) => unknown>()
317
+ .mockImplementationOnce(() => {
318
+ dropFocus()
319
+ throw new Error("connect ECONNREFUSED 10.0.0.1:5432")
320
+ })
321
+ .mockImplementationOnce(() => {
322
+ throw new Error("offline")
323
+ })
324
+ .mockImplementationOnce(
325
+ () =>
326
+ new Promise((_, reject) => {
327
+ rejectRetry = reject
328
+ })
329
+ )
330
+ render(<Harness onSubmit={onSubmit} />)
331
+ const name = await openEdit(user)
332
+ const save = screen.getByRole("button", { name: "Save" })
333
+ await user.click(save)
334
+
335
+ const alert = await screen.findByRole("alert")
336
+ expect(alert.textContent).toBe("Something went wrong. Try again.")
337
+ expect(screen.getByRole("dialog")).toBeTruthy()
338
+ await waitFor(() => expect(document.activeElement).toBe(save))
339
+
340
+ await user.type(name, "!")
341
+ expect(screen.queryByRole("alert")).toBeNull()
342
+
343
+ await user.click(save)
344
+ expect(await screen.findByRole("alert")).toBeTruthy()
345
+
346
+ // The retry clears the message while it runs; it comes back if it fails.
347
+ await user.click(save)
348
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(3))
349
+ expect(screen.queryByRole("alert")).toBeNull()
350
+ rejectRetry(new Error("offline"))
351
+ expect(await screen.findByRole("alert")).toBeTruthy()
352
+ })
353
+
354
+ test("submitErrorMessage words the thrown-submit message from the error", async () => {
355
+ const user = userEvent.setup()
356
+ render(
357
+ <Harness
358
+ onSubmit={() => {
359
+ throw new Error("Quota reached")
360
+ }}
361
+ submitErrorMessage={(error) => `Not saved: ${(error as Error).message}`}
362
+ />
363
+ )
364
+ await openEdit(user)
365
+ await user.click(screen.getByRole("button", { name: "Save" }))
366
+ expect((await screen.findByRole("alert")).textContent).toBe(
367
+ "Not saved: Quota reached"
368
+ )
369
+ })
370
+
371
+ test("reopening during the close animation mounts fresh fields", async () => {
372
+ const user = userEvent.setup()
373
+ function Details() {
374
+ const [shown, setShown] = useState(false)
375
+ return (
376
+ <button
377
+ aria-pressed={shown}
378
+ onClick={() => setShown(true)}
379
+ type="button"
380
+ >
381
+ Details
382
+ </button>
383
+ )
384
+ }
385
+ render(
386
+ <Harness>
387
+ <Details />
388
+ </Harness>
389
+ )
390
+ await openEdit(user)
391
+ await user.click(screen.getByRole("button", { name: "Details" }))
392
+ expect(
393
+ screen
394
+ .getByRole("button", { name: "Details" })
395
+ .getAttribute("aria-pressed")
396
+ ).toBe("true")
397
+
398
+ holdExitAnimation()
399
+ await user.click(screen.getByRole("button", { name: "Cancel" }))
400
+ // Still mounted: the exit animation is running.
401
+ expect(
402
+ screen.queryByRole("button", { hidden: true, name: "Details" })
403
+ ).not.toBeNull()
404
+ const name = await openEdit(user)
405
+ expect(name.value).toBe("Ada")
406
+ expect(
407
+ screen
408
+ .getByRole("button", { name: "Details" })
409
+ .getAttribute("aria-pressed")
410
+ ).toBe("false")
411
+ })
412
+
413
+ test("Save in the leave prompt saves, then closes", async () => {
414
+ const user = userEvent.setup()
415
+ const onSubmit = vi.fn()
416
+ render(<Harness onSubmit={onSubmit} saveFromPrompt />)
417
+ const name = await openEdit(user)
418
+ await user.type(name, "!")
419
+ await user.keyboard("{Escape}")
420
+ const prompt = await screen.findByRole("alertdialog")
421
+
422
+ // The submit shortcut is off while the prompt asks.
423
+ await user.keyboard("{Control>}{Enter}{/Control}")
424
+ expect(onSubmit).not.toHaveBeenCalled()
425
+
426
+ await user.click(within(prompt).getByRole("button", { name: "Save" }))
427
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull())
428
+ expect(onSubmit).toHaveBeenCalledWith({
429
+ email: "ada@example.com",
430
+ name: "Ada!",
431
+ })
432
+ expect(screen.queryByRole("alertdialog")).toBeNull()
433
+ })
434
+
435
+ test("a failed Save in the leave prompt stays open and focuses the invalid field", async () => {
436
+ const user = userEvent.setup()
437
+ const onSubmit = vi.fn()
438
+ render(<Harness onSubmit={onSubmit} saveFromPrompt />)
439
+ const name = await openEdit(user)
440
+ await user.clear(name)
441
+ // Leave from another field, so focus returning there isn't a pass.
442
+ await user.click(screen.getByLabelText(EMAIL))
443
+ await user.keyboard("{Escape}")
444
+ const prompt = await screen.findByRole("alertdialog")
445
+
446
+ await user.click(within(prompt).getByRole("button", { name: "Save" }))
447
+ await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull())
448
+ expect(onSubmit).not.toHaveBeenCalled()
449
+ expect(screen.getByRole("dialog")).toBeTruthy()
450
+ await waitFor(() => expect(document.activeElement).toBe(name))
451
+ })
452
+
453
+ test("a Save in the leave prompt that throws is announced once the prompt closes", async () => {
454
+ const user = userEvent.setup()
455
+ const onSubmit = vi.fn(() => {
456
+ throw new Error("offline")
457
+ })
458
+ render(<Harness onSubmit={onSubmit} saveFromPrompt />)
459
+ const name = await openEdit(user)
460
+ await user.type(name, "!")
461
+ await user.keyboard("{Escape}")
462
+ const prompt = await screen.findByRole("alertdialog")
463
+ const announced = watchAnnouncedAlerts()
464
+
465
+ await user.click(within(prompt).getByRole("button", { name: "Save" }))
466
+ await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull())
467
+ expect(screen.getByRole("dialog")).toBeTruthy()
468
+ await waitFor(() =>
469
+ expect(announced).toContain("Something went wrong. Try again.")
470
+ )
471
+ })
472
+
473
+ test("a Save still running when the leave prompt opens is announced once it closes", async () => {
474
+ const user = userEvent.setup()
475
+ let rejectSave: (error: Error) => void = () => undefined
476
+ const onSubmit = vi.fn(
477
+ () =>
478
+ new Promise((_, reject) => {
479
+ rejectSave = reject
480
+ })
481
+ )
482
+ render(<Harness onSubmit={onSubmit} />)
483
+ const name = await openEdit(user)
484
+ await user.type(name, "!")
485
+ await user.click(screen.getByRole("button", { name: "Save" }))
486
+ await waitFor(() => expect(onSubmit).toHaveBeenCalled())
487
+ await user.keyboard("{Escape}")
488
+ const prompt = await screen.findByRole("alertdialog")
489
+ const announced = watchAnnouncedAlerts()
490
+
491
+ rejectSave(new Error("offline"))
492
+ await waitFor(() =>
493
+ expect(document.querySelector('[data-slot="form-error"]')).not.toBeNull()
494
+ )
495
+ await user.click(
496
+ within(prompt).getByRole("button", { name: "Keep editing" })
497
+ )
498
+ await waitFor(() =>
499
+ expect(announced).toContain("Something went wrong. Try again.")
500
+ )
501
+ })
502
+
503
+ test("a Save that fails after a discard doesn't show in the next session", async () => {
504
+ const user = userEvent.setup()
505
+ const report = vi.fn()
506
+ vi.stubGlobal("reportError", report)
507
+ let rejectSave: (error: Error) => void = () => undefined
508
+ const onSubmit = vi.fn(
509
+ () =>
510
+ new Promise((_, reject) => {
511
+ rejectSave = reject
512
+ })
513
+ )
514
+ render(<Harness onSubmit={onSubmit} />)
515
+ await user.click(screen.getByRole("button", { name: "New" }))
516
+ await user.type(await screen.findByLabelText(NAME), "Ada")
517
+ await user.click(screen.getByRole("button", { name: "Save" }))
518
+ await waitFor(() => expect(onSubmit).toHaveBeenCalled())
519
+ await user.keyboard("{Escape}")
520
+ const prompt = await screen.findByRole("alertdialog")
521
+ await user.click(within(prompt).getByRole("button", { name: "Discard" }))
522
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull())
523
+ await user.click(screen.getByRole("button", { name: "New" }))
524
+ await screen.findByRole("dialog")
525
+
526
+ rejectSave(new Error("offline"))
527
+ await waitFor(() => expect(report).toHaveBeenCalled())
528
+ expect(screen.queryByRole("alert")).toBeNull()
529
+ })
530
+
531
+ for (const key of ["Control", "Meta"]) {
532
+ test(`${key}+Enter submits, from a textarea too; plain Enter there doesn't`, async () => {
533
+ const user = userEvent.setup()
534
+ const onSubmit = vi.fn()
535
+ render(
536
+ <Harness onSubmit={onSubmit}>
537
+ <textarea aria-label="Notes" />
538
+ </Harness>
539
+ )
540
+ const name = await openEdit(user)
541
+ await user.type(name, "!")
542
+ await user.type(screen.getByLabelText("Notes"), "line{Enter}")
543
+ expect(onSubmit).not.toHaveBeenCalled()
544
+
545
+ await user.keyboard(`{${key}>}{Enter}{/${key}}`)
546
+ await waitFor(() =>
547
+ expect(onSubmit).toHaveBeenCalledWith({
548
+ email: "ada@example.com",
549
+ name: "Ada!",
550
+ })
551
+ )
552
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull())
553
+ })
554
+ }
555
+
556
+ test("disabled turns off Save, the shortcut and the native fields", async () => {
557
+ const user = userEvent.setup()
558
+ const onSubmit = vi.fn()
559
+ render(<Harness disabled onSubmit={onSubmit} />)
560
+ await user.click(screen.getByRole("button", { name: "Edit" }))
561
+ const dialog = await screen.findByRole("dialog")
562
+ await waitFor(() =>
563
+ expect(dialog.contains(document.activeElement)).toBe(true)
564
+ )
565
+ expect(screen.getByLabelText(NAME).matches(":disabled")).toBe(true)
566
+ expect(
567
+ screen.getByRole("button", { name: "Save" }).matches(":disabled")
568
+ ).toBe(true)
569
+
570
+ await user.keyboard("{Control>}{Enter}{/Control}")
571
+ await user.click(screen.getByRole("button", { name: "Save" }))
572
+ expect(onSubmit).not.toHaveBeenCalled()
573
+ expect(screen.getByRole("dialog")).toBeTruthy()
574
+ })
575
+
576
+ test("disabled also turns off Save in the leave prompt", async () => {
577
+ const user = userEvent.setup()
578
+ const onSubmit = vi.fn()
579
+ const { rerender } = render(<Harness onSubmit={onSubmit} saveFromPrompt />)
580
+ const name = await openEdit(user)
581
+ await user.type(name, "!")
582
+ rerender(<Harness disabled onSubmit={onSubmit} saveFromPrompt />)
583
+ await user.click(screen.getByRole("button", { name: "Cancel" }))
584
+ const prompt = await screen.findByRole("alertdialog")
585
+ await user.click(within(prompt).getByRole("button", { name: "Save" }))
586
+ expect(onSubmit).not.toHaveBeenCalled()
587
+ })
588
+
589
+ test("Save in the leave prompt doesn't submit again while a submit runs", async () => {
590
+ const user = userEvent.setup()
591
+ const onSubmit = vi.fn(() => new Promise(() => undefined))
592
+ render(<Harness onSubmit={onSubmit} saveFromPrompt />)
593
+ const name = await openEdit(user)
594
+ await user.type(name, "!")
595
+ await user.click(screen.getByRole("button", { name: "Save" }))
596
+ await waitFor(() => expect(onSubmit).toHaveBeenCalled())
597
+ await user.keyboard("{Escape}")
598
+ const prompt = await screen.findByRole("alertdialog")
599
+ await user.click(within(prompt).getByRole("button", { name: "Save" }))
600
+ expect(onSubmit).toHaveBeenCalledTimes(1)
601
+ })
602
+
603
+ test("Ctrl+Enter during the close animation doesn't submit again", async () => {
604
+ const user = userEvent.setup()
605
+ const onSubmit = vi.fn()
606
+ render(<Harness onSubmit={onSubmit} />)
607
+ const name = await openEdit(user)
608
+ await user.type(name, "!")
609
+ holdExitAnimation()
610
+ await user.keyboard("{Control>}{Enter}{/Control}")
611
+ const dialog = screen.getByRole("dialog")
612
+ await waitFor(() => expect(dialog.hasAttribute("data-closed")).toBe(true))
613
+ await user.keyboard("{Control>}{Enter}{/Control}")
614
+ expect(onSubmit).toHaveBeenCalledTimes(1)
615
+ })
616
+
617
+ test("Ctrl+Enter in a nested dialog submits only that dialog", async () => {
618
+ const user = userEvent.setup()
619
+ const outer = vi.fn()
620
+ const inner = vi.fn()
621
+ function Inner() {
622
+ const dialog = useFormDialog({ defaultValues: ROW })
623
+ const form = useForm({
624
+ defaultValues: dialog.defaultValues,
625
+ onSubmit: ({ value }) => inner(value),
626
+ schema,
627
+ })
628
+ return (
629
+ <FormDialog
630
+ dialog={dialog}
631
+ form={form}
632
+ title="Inner"
633
+ trigger={<button type="button">Open inner</button>}
634
+ >
635
+ <form.InputField label="Inner name" name="name" />
636
+ </FormDialog>
637
+ )
638
+ }
639
+ render(
640
+ <Harness onSubmit={outer}>
641
+ <Inner />
642
+ </Harness>
643
+ )
644
+ await openEdit(user)
645
+ await user.click(screen.getByRole("button", { name: "Open inner" }))
646
+ const innerName = await screen.findByLabelText(INNER_NAME)
647
+ await user.type(innerName, "!")
648
+ await user.keyboard("{Control>}{Enter}{/Control}")
649
+ await waitFor(() => expect(inner).toHaveBeenCalled())
650
+ expect(outer).not.toHaveBeenCalled()
651
+ })
161
652
  })