@zeno-lib/forms 0.7.0 → 0.7.1

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": "@zeno-lib/forms",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "repository": {
5
5
  "url": "https://github.com/zeno-lib/zeno.git",
6
6
  "directory": "packages/forms"
@@ -18,7 +18,7 @@ import {
18
18
  } from "./form-dialog"
19
19
  import type { ActionResult } from "./lib/action-result"
20
20
  import { submitAction } from "./lib/submit-action"
21
- import { useFormDialog } from "./lib/use-form-dialog"
21
+ import { type FormDialogController, useFormDialog } from "./lib/use-form-dialog"
22
22
 
23
23
  const NAME = /Name/
24
24
  const EMAIL = /Email/
@@ -54,8 +54,10 @@ function Harness({
54
54
  children,
55
55
  onSubmit = vi.fn(),
56
56
  closeOnSubmit,
57
+ controller,
57
58
  disabled,
58
59
  discardPrompt,
60
+ formDisabled,
59
61
  saveFromPrompt,
60
62
  submitErrorMessage,
61
63
  submitLabel,
@@ -65,15 +67,23 @@ function Harness({
65
67
  children?: ReactNode
66
68
  onSubmit?: (values: Values) => unknown
67
69
  closeOnSubmit?: boolean
70
+ /** Gets the dialog's controller, to close it from outside. */
71
+ controller?: { current?: FormDialogController<Values> }
68
72
  disabled?: boolean
69
73
  discardPrompt?: DiscardPromptText
74
+ /** Disable the form through `useForm({ disabled })`. */
75
+ formDisabled?: boolean
70
76
  saveFromPrompt?: boolean
71
77
  submitErrorMessage?: (error: unknown) => ReactNode
72
78
  submitLabel?: ReactNode
73
79
  }) {
74
80
  const dialog = useFormDialog({ defaultValues: BLANK })
81
+ if (controller) {
82
+ controller.current = dialog
83
+ }
75
84
  const form = useForm({
76
85
  defaultValues: dialog.defaultValues,
86
+ disabled: formDisabled,
77
87
  onSubmit: (submit) =>
78
88
  action ? submitAction(submit, action) : onSubmit(submit.value),
79
89
  schema,
@@ -119,6 +129,13 @@ function dropFocus() {
119
129
  sink.remove()
120
130
  }
121
131
 
132
+ // Whether leaving the page now would make the browser ask first.
133
+ function warnsOnUnload(): boolean {
134
+ const event = new Event("beforeunload", { cancelable: true })
135
+ window.dispatchEvent(event)
136
+ return event.defaultPrevented
137
+ }
138
+
122
139
  // The alerts added where assistive tech could see them. One added under an
123
140
  // `aria-hidden` ancestor (the dialog, while the leave prompt is open) is never
124
141
  // announced, not even once that ancestor is shown again.
@@ -277,6 +294,110 @@ describe("FormDialog", () => {
277
294
  expect(screen.getByRole("dialog")).toBeTruthy()
278
295
  })
279
296
 
297
+ for (const closeOnSubmit of [true, false]) {
298
+ test(`a leave asked for while a Save runs goes through once it saves (closeOnSubmit: ${closeOnSubmit})`, async () => {
299
+ const user = userEvent.setup()
300
+ let finishSave: () => void = () => undefined
301
+ const onSubmit = vi.fn(
302
+ () =>
303
+ new Promise<void>((resolve) => {
304
+ finishSave = resolve
305
+ })
306
+ )
307
+ render(<Harness closeOnSubmit={closeOnSubmit} onSubmit={onSubmit} />)
308
+ const name = await openEdit(user)
309
+ await user.type(name, "!")
310
+ await user.click(screen.getByRole("button", { name: "Save" }))
311
+ await waitFor(() => expect(onSubmit).toHaveBeenCalled())
312
+ await user.keyboard("{Escape}")
313
+ await screen.findByRole("alertdialog")
314
+
315
+ finishSave()
316
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull())
317
+ expect(screen.queryByRole("alertdialog")).toBeNull()
318
+ })
319
+ }
320
+
321
+ test("an edit made while a Save runs keeps the leave prompt asking once it saves", async () => {
322
+ const user = userEvent.setup()
323
+ let finishSave: () => void = () => undefined
324
+ const onSubmit = vi.fn(
325
+ () =>
326
+ new Promise<void>((resolve) => {
327
+ finishSave = resolve
328
+ })
329
+ )
330
+ render(<Harness onSubmit={onSubmit} />)
331
+ const name = await openEdit(user)
332
+ await user.type(name, "!")
333
+ await user.click(screen.getByRole("button", { name: "Save" }))
334
+ await waitFor(() => expect(onSubmit).toHaveBeenCalled())
335
+ await user.type(name, "?")
336
+ await user.keyboard("{Escape}")
337
+ const prompt = await screen.findByRole("alertdialog")
338
+
339
+ finishSave()
340
+ await waitFor(() =>
341
+ expect(screen.queryByRole("status", { hidden: true })).toBeNull()
342
+ )
343
+ expect(screen.getByRole("alertdialog")).toBe(prompt)
344
+ await user.click(
345
+ within(prompt).getByRole("button", { name: "Keep editing" })
346
+ )
347
+ await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull())
348
+ expect(name.value).toBe("Ada!?")
349
+ })
350
+
351
+ test("a Save that fails while the leave prompt asks focuses the invalid field once it closes", async () => {
352
+ const user = userEvent.setup()
353
+ let finishSave: (result: ActionResult<never>) => void = () => undefined
354
+ const action = vi.fn(
355
+ () =>
356
+ new Promise<ActionResult<never>>((resolve) => {
357
+ finishSave = resolve
358
+ })
359
+ )
360
+ render(<Harness action={action} />)
361
+ const name = await openEdit(user)
362
+ await user.type(name, "!")
363
+ await user.click(screen.getByRole("button", { name: "Save" }))
364
+ await waitFor(() => expect(action).toHaveBeenCalled())
365
+ await user.keyboard("{Escape}")
366
+ const prompt = await screen.findByRole("alertdialog")
367
+ await waitFor(() =>
368
+ expect(prompt.contains(document.activeElement)).toBe(true)
369
+ )
370
+
371
+ finishSave({
372
+ error: { fieldErrors: { email: ["Already taken"] }, formErrors: [] },
373
+ ok: false,
374
+ })
375
+ const email = screen.getByLabelText(EMAIL)
376
+ await waitFor(() => expect(email.getAttribute("aria-invalid")).toBe("true"))
377
+ expect(prompt.contains(document.activeElement)).toBe(true)
378
+ await user.click(
379
+ within(prompt).getByRole("button", { name: "Keep editing" })
380
+ )
381
+ await waitFor(() => expect(document.activeElement).toBe(email))
382
+ })
383
+
384
+ test("closing the dialog from outside closes its leave prompt for good", async () => {
385
+ const user = userEvent.setup()
386
+ const controller: { current?: FormDialogController<Values> } = {}
387
+ render(<Harness controller={controller} />)
388
+ const name = await openEdit(user)
389
+ await user.type(name, "!")
390
+ await user.keyboard("{Escape}")
391
+ await screen.findByRole("alertdialog")
392
+
393
+ act(() => controller.current?.close())
394
+ await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull())
395
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull())
396
+ await user.click(screen.getByRole("button", { name: "Edit" }))
397
+ await screen.findByRole("dialog")
398
+ expect(screen.queryByRole("alertdialog")).toBeNull()
399
+ })
400
+
280
401
  test("a field error from the server focuses that field", async () => {
281
402
  const user = userEvent.setup()
282
403
  const action = vi.fn(() =>
@@ -583,17 +704,46 @@ describe("FormDialog", () => {
583
704
  expect(screen.getByRole("dialog")).toBeTruthy()
584
705
  })
585
706
 
586
- test("disabled also turns off Save in the leave prompt", async () => {
707
+ for (const via of ["disabled", "useForm({ disabled })"]) {
708
+ test(`with ${via}, a changed dialog still asks and warns, without offering Save`, async () => {
709
+ const user = userEvent.setup()
710
+ const { rerender } = render(<Harness saveFromPrompt />)
711
+ const name = await openEdit(user)
712
+ await user.type(name, "!")
713
+
714
+ rerender(
715
+ <Harness
716
+ disabled={via === "disabled"}
717
+ formDisabled={via !== "disabled"}
718
+ saveFromPrompt
719
+ />
720
+ )
721
+ expect(warnsOnUnload()).toBe(true)
722
+ await user.click(screen.getByRole("button", { name: "Cancel" }))
723
+ const prompt = await screen.findByRole("alertdialog")
724
+ expect(within(prompt).getByText("Discard changes?")).toBeTruthy()
725
+ expect(within(prompt).queryByRole("button", { name: "Save" })).toBeNull()
726
+ await user.click(within(prompt).getByRole("button", { name: "Discard" }))
727
+ await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull())
728
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull())
729
+ })
730
+ }
731
+
732
+ test("disabled while the leave prompt asks keeps it, with its Save off", async () => {
587
733
  const user = userEvent.setup()
588
734
  const onSubmit = vi.fn()
589
735
  const { rerender } = render(<Harness onSubmit={onSubmit} saveFromPrompt />)
590
736
  const name = await openEdit(user)
591
737
  await user.type(name, "!")
592
- rerender(<Harness disabled onSubmit={onSubmit} saveFromPrompt />)
593
738
  await user.click(screen.getByRole("button", { name: "Cancel" }))
594
739
  const prompt = await screen.findByRole("alertdialog")
740
+
741
+ rerender(<Harness disabled onSubmit={onSubmit} saveFromPrompt />)
595
742
  await user.click(within(prompt).getByRole("button", { name: "Save" }))
596
743
  expect(onSubmit).not.toHaveBeenCalled()
744
+ expect(screen.getByRole("alertdialog")).toBe(prompt)
745
+ await user.click(within(prompt).getByRole("button", { name: "Discard" }))
746
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull())
597
747
  })
598
748
 
599
749
  test("Save in the leave prompt doesn't submit again while a submit runs", async () => {
@@ -110,7 +110,11 @@ type FormDialogProps<TValues> = {
110
110
  cancelLabel?: ReactNode
111
111
  /** Extra footer content, rendered before Cancel / Save. */
112
112
  footer?: ReactNode
113
- /** Close after a successful submit. Defaults to `true`. */
113
+ /**
114
+ * Close after a successful submit. Defaults to `true`. A close the user
115
+ * asked for while the submit ran goes through once it succeeds either way,
116
+ * unless they edited a field since.
117
+ */
114
118
  closeOnSubmit?: boolean
115
119
  /**
116
120
  * Shown above the footer when the submit handler throws. Defaults to the
@@ -127,13 +131,15 @@ type FormDialogProps<TValues> = {
127
131
  discardPrompt?: DiscardPromptText
128
132
  /**
129
133
  * Offer Save in the discard prompt, next to Discard: it submits, and closes
130
- * the dialog if the submit succeeds. Defaults to `false`.
134
+ * the dialog if the submit succeeds. Not in a prompt that opens while the
135
+ * dialog is disabled. Defaults to `false`.
131
136
  */
132
137
  saveFromPrompt?: boolean
133
138
  /**
134
139
  * Disable every field, Save and ⌘/Ctrl+Enter, as `useForm({ disabled })`
135
140
  * does. A `<fieldset disabled>` also disables native controls of your own.
136
- * Defaults to `false`.
141
+ * A discard prompt that opens while disabled doesn't offer Save. Defaults
142
+ * to `false`.
137
143
  */
138
144
  disabled?: boolean
139
145
  /** Class for the dialog popup (e.g. a wider `sm:max-w-lg`). */
@@ -257,8 +263,8 @@ function FormDialog<TValues>(props: FormDialogProps<TValues>) {
257
263
  useIsFormDisabled(form as unknown as AnyFormApi) || disabled
258
264
  const popupRef = useRef<HTMLDivElement>(null)
259
265
  const formRef = useRef<HTMLFormElement>(null)
260
- // Set when a Save from the discard prompt fails, so the prompt hands focus
261
- // to the first invalid field as it closes.
266
+ // Set when a submit fails while the discard prompt is open, so the prompt
267
+ // hands focus to the first invalid field as it closes.
262
268
  const focusInvalidOnPromptClose = useRef<boolean>(false)
263
269
  // The open prompt hides the dialog from assistive tech, so a form-level
264
270
  // error that shows up meanwhile (a Save from the prompt, or a footer Save
@@ -266,9 +272,13 @@ function FormDialog<TValues>(props: FormDialogProps<TValues>) {
266
272
  // the prompt has closed, which announces it.
267
273
  const announceOnPromptClosed = useRef<boolean>(false)
268
274
  const [formErrorKey, setFormErrorKey] = useState(0)
275
+ // Whether the discard prompt offers Save, set as it opens: a disabled
276
+ // dialog can't save. Kept while it's open, so the prompt doesn't change
277
+ // under the user; one disabled meanwhile only turns its Save off.
278
+ const [promptOffersSave, setPromptOffersSave] = useState(false)
269
279
  const { close, defaultValues, focus, isOpen, open, session } = dialog
270
280
  const DialogFormError = form.FormError ?? FormError
271
- const promptCopy = getPromptCopy(discardPrompt, saveFromPrompt)
281
+ const promptCopy = getPromptCopy(discardPrompt, promptOffersSave)
272
282
  // Read after a submit's await: the dialog may have closed, or reopened on
273
283
  // another record, while a slow save was in flight.
274
284
  const latest = useRef({ isOpen, session })
@@ -313,6 +323,12 @@ function FormDialog<TValues>(props: FormDialogProps<TValues>) {
313
323
  }
314
324
  }, [session])
315
325
 
326
+ // A held leave belongs to the session it was asked in. Drop it when that
327
+ // session ends some other way (`dialog.close()`, a submit that closes it,
328
+ // `dialog.open()` on another record), so the prompt never outlives it.
329
+ // biome-ignore lint/correctness/useExhaustiveDependencies: on every session change.
330
+ useLayoutEffect(() => cancelLeave, [isOpen, session])
331
+
316
332
  // Every way to submit (footer Save, ⌘/Ctrl+Enter, the prompt's Save) checks
317
333
  // this first. TanStack only refuses a second `handleSubmit` while one runs on
318
334
  // the very first attempt, and `isOpen` is false through the exit animation,
@@ -342,10 +358,25 @@ function FormDialog<TValues>(props: FormDialogProps<TValues>) {
342
358
  }
343
359
  const node = event.currentTarget
344
360
  const focused = node.ownerDocument.activeElement
361
+ // The fields stay editable while the submit runs. TanStack replaces
362
+ // `values` on every edit, so the same object means none came after.
363
+ const submitted = form.state.values
345
364
  const saved = await submit()
346
365
  if (!latest.current.isOpen || latest.current.session !== session) {
347
366
  return // the session this submit belongs to is over
348
367
  }
368
+ if (isConfirmingRef.current) {
369
+ // The user asked to leave while the submit ran. If it saved every
370
+ // change, that leave goes through, `closeOnSubmit` or not. After a
371
+ // failure or a later edit, the prompt waits for the user's answer, and
372
+ // it traps focus, so it moves focus to an invalid field as it closes.
373
+ if (saved && form.state.values === submitted) {
374
+ confirmLeave()
375
+ } else {
376
+ focusInvalidOnPromptClose.current = !saved
377
+ }
378
+ return
379
+ }
349
380
  if (saved) {
350
381
  if (closeOnSubmit) {
351
382
  // Close directly, past the guard: the changes are saved. The form is
@@ -392,6 +423,7 @@ function FormDialog<TValues>(props: FormDialogProps<TValues>) {
392
423
  if (next) {
393
424
  open()
394
425
  } else {
426
+ setPromptOffersSave(saveFromPrompt && !isDisabled)
395
427
  requestLeave(close)
396
428
  }
397
429
  }}
@@ -480,21 +512,24 @@ function FormDialog<TValues>(props: FormDialogProps<TValues>) {
480
512
  setFormErrorKey((key) => key + 1)
481
513
  }
482
514
  }}
483
- open={isConfirming}
515
+ // Closes in the same render as the dialog.
516
+ open={isConfirming && isOpen}
484
517
  >
485
518
  <AlertDialogContent
486
519
  // The prompt traps focus while open, so a failed Save's invalid
487
520
  // field gets focus as the prompt closes, not from `submit`.
488
521
  finalFocus={() => {
489
- if (!focusInvalidOnPromptClose.current) {
522
+ const focusInvalid = focusInvalidOnPromptClose.current
523
+ focusInvalidOnPromptClose.current = false
524
+ // After Discard, the closing dialog hands focus back itself.
525
+ if (!(focusInvalid && latest.current.isOpen)) {
490
526
  return true
491
527
  }
492
- focusInvalidOnPromptClose.current = false
493
528
  // Base UI focuses what this returns too; focusing here first
494
529
  // finds a field that takes focus, skipping disabled or hidden ones.
495
530
  return focusFirstInvalid(formRef.current) ?? true
496
531
  }}
497
- size={saveFromPrompt ? "default" : "sm"}
532
+ size={promptOffersSave ? "default" : "sm"}
498
533
  >
499
534
  <AlertDialogHeader>
500
535
  <AlertDialogTitle>{promptCopy.title}</AlertDialogTitle>
@@ -513,7 +548,7 @@ function FormDialog<TValues>(props: FormDialogProps<TValues>) {
513
548
  >
514
549
  {discardPrompt?.confirmLabel ?? "Discard"}
515
550
  </AlertDialogAction>
516
- {saveFromPrompt && (
551
+ {promptOffersSave && (
517
552
  <AlertDialogAction
518
553
  disabled={isDisabled || isSaving || isSubmitting}
519
554
  onClick={saveAndLeave}