@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 +1 -1
- package/src/form-dialog.test.tsx +153 -3
- package/src/form-dialog.tsx +46 -11
package/package.json
CHANGED
package/src/form-dialog.test.tsx
CHANGED
|
@@ -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
|
-
|
|
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 () => {
|
package/src/form-dialog.tsx
CHANGED
|
@@ -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
|
-
/**
|
|
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.
|
|
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
|
-
*
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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={
|
|
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
|
-
{
|
|
551
|
+
{promptOffersSave && (
|
|
517
552
|
<AlertDialogAction
|
|
518
553
|
disabled={isDisabled || isSaving || isSubmitting}
|
|
519
554
|
onClick={saveAndLeave}
|