@zeno-lib/forms 0.6.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/dist/create-zeno-form.d.mts +19 -4
- package/dist/create-zeno-form.mjs +29 -6
- package/dist/form-element.d.mts +16 -3
- package/dist/form-element.mjs +38 -18
- package/dist/index.d.mts +2 -1
- package/dist/index.mjs +2 -1
- package/dist/lib/date-value.d.mts +24 -0
- package/dist/lib/date-value.mjs +37 -0
- package/dist/lib/default-props.d.mts +7 -0
- package/dist/lib/default-props.mjs +13 -0
- package/dist/lib/empty-value.mjs +25 -1
- package/dist/lib/form-disabled.d.mts +28 -0
- package/dist/lib/form-disabled.mjs +54 -0
- package/dist/lib/item-options.d.mts +45 -0
- package/dist/lib/item-options.mjs +70 -0
- package/dist/lib/schema-required.mjs +17 -0
- package/dist/lib/use-field-control.d.mts +7 -0
- package/dist/lib/use-field-control.mjs +4 -1
- package/package.json +1 -1
- package/src/create-form.tsx +34 -29
- package/src/create-zeno-form.tsx +124 -51
- package/src/date-picker-field.test.tsx +108 -0
- package/src/default-props.test-d.ts +66 -0
- package/src/default-props.test.tsx +165 -0
- package/src/fields/checkbox-field.tsx +5 -0
- package/src/fields/checkbox-group-field.tsx +6 -2
- package/src/fields/combobox-field.tsx +107 -40
- package/src/fields/date-picker-field.tsx +34 -11
- package/src/fields/field-shell.tsx +8 -1
- package/src/fields/input-field.tsx +2 -0
- package/src/fields/multi-select-field.tsx +100 -42
- package/src/fields/otp-field.tsx +8 -2
- package/src/fields/radio-group-field.tsx +9 -1
- package/src/fields/reset-button.tsx +4 -0
- package/src/fields/select-field.tsx +9 -1
- package/src/fields/slider-field.tsx +9 -1
- package/src/fields/submit-button.tsx +3 -1
- package/src/fields/switch-field.tsx +5 -0
- package/src/fields/textarea-field.tsx +2 -0
- package/src/form-dialog.test.tsx +255 -4
- package/src/form-dialog.tsx +187 -51
- package/src/form-disabled.test.tsx +329 -0
- package/src/form-element.tsx +64 -29
- package/src/grouped-options.test-d.ts +59 -0
- package/src/grouped-options.test.tsx +251 -0
- package/src/index.ts +1 -0
- package/src/lib/date-value.test.ts +14 -0
- package/src/lib/date-value.ts +70 -0
- package/src/lib/default-props.ts +19 -0
- package/src/lib/empty-value.test.ts +19 -0
- package/src/lib/empty-value.ts +45 -1
- package/src/lib/form-disabled.ts +95 -0
- package/src/lib/item-options.test.ts +11 -0
- package/src/lib/item-options.ts +165 -0
- package/src/lib/schema-required.test.ts +9 -0
- package/src/lib/schema-required.ts +33 -0
- package/src/lib/use-field-control.ts +11 -1
- package/src/with-form.test-d.ts +76 -0
- package/src/with-form.test.tsx +60 -0
package/src/form-dialog.test.tsx
CHANGED
|
@@ -11,10 +11,14 @@ import { type ReactNode, useState } from "react"
|
|
|
11
11
|
import { afterEach, describe, expect, onTestFinished, test, vi } from "vitest"
|
|
12
12
|
import { z } from "zod"
|
|
13
13
|
import { useForm } from "./create-form"
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
type DiscardPromptText,
|
|
16
|
+
FormDialog,
|
|
17
|
+
FormDialogProvider,
|
|
18
|
+
} from "./form-dialog"
|
|
15
19
|
import type { ActionResult } from "./lib/action-result"
|
|
16
20
|
import { submitAction } from "./lib/submit-action"
|
|
17
|
-
import { useFormDialog } from "./lib/use-form-dialog"
|
|
21
|
+
import { type FormDialogController, useFormDialog } from "./lib/use-form-dialog"
|
|
18
22
|
|
|
19
23
|
const NAME = /Name/
|
|
20
24
|
const EMAIL = /Email/
|
|
@@ -50,22 +54,36 @@ function Harness({
|
|
|
50
54
|
children,
|
|
51
55
|
onSubmit = vi.fn(),
|
|
52
56
|
closeOnSubmit,
|
|
57
|
+
controller,
|
|
53
58
|
disabled,
|
|
59
|
+
discardPrompt,
|
|
60
|
+
formDisabled,
|
|
54
61
|
saveFromPrompt,
|
|
55
62
|
submitErrorMessage,
|
|
63
|
+
submitLabel,
|
|
56
64
|
}: {
|
|
57
65
|
/** Submit through `submitAction` with this server action instead. */
|
|
58
66
|
action?: (values: Values) => Promise<ActionResult<unknown>>
|
|
59
67
|
children?: ReactNode
|
|
60
68
|
onSubmit?: (values: Values) => unknown
|
|
61
69
|
closeOnSubmit?: boolean
|
|
70
|
+
/** Gets the dialog's controller, to close it from outside. */
|
|
71
|
+
controller?: { current?: FormDialogController<Values> }
|
|
62
72
|
disabled?: boolean
|
|
73
|
+
discardPrompt?: DiscardPromptText
|
|
74
|
+
/** Disable the form through `useForm({ disabled })`. */
|
|
75
|
+
formDisabled?: boolean
|
|
63
76
|
saveFromPrompt?: boolean
|
|
64
77
|
submitErrorMessage?: (error: unknown) => ReactNode
|
|
78
|
+
submitLabel?: ReactNode
|
|
65
79
|
}) {
|
|
66
80
|
const dialog = useFormDialog({ defaultValues: BLANK })
|
|
81
|
+
if (controller) {
|
|
82
|
+
controller.current = dialog
|
|
83
|
+
}
|
|
67
84
|
const form = useForm({
|
|
68
85
|
defaultValues: dialog.defaultValues,
|
|
86
|
+
disabled: formDisabled,
|
|
69
87
|
onSubmit: (submit) =>
|
|
70
88
|
action ? submitAction(submit, action) : onSubmit(submit.value),
|
|
71
89
|
schema,
|
|
@@ -86,9 +104,11 @@ function Harness({
|
|
|
86
104
|
closeOnSubmit={closeOnSubmit}
|
|
87
105
|
dialog={dialog}
|
|
88
106
|
disabled={disabled}
|
|
107
|
+
discardPrompt={discardPrompt}
|
|
89
108
|
form={form}
|
|
90
109
|
saveFromPrompt={saveFromPrompt}
|
|
91
110
|
submitErrorMessage={submitErrorMessage}
|
|
111
|
+
submitLabel={submitLabel}
|
|
92
112
|
title="Person"
|
|
93
113
|
>
|
|
94
114
|
<InputField label="Name" name="name" />
|
|
@@ -109,6 +129,13 @@ function dropFocus() {
|
|
|
109
129
|
sink.remove()
|
|
110
130
|
}
|
|
111
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
|
+
|
|
112
139
|
// The alerts added where assistive tech could see them. One added under an
|
|
113
140
|
// `aria-hidden` ancestor (the dialog, while the leave prompt is open) is never
|
|
114
141
|
// announced, not even once that ancestor is shown again.
|
|
@@ -267,6 +294,110 @@ describe("FormDialog", () => {
|
|
|
267
294
|
expect(screen.getByRole("dialog")).toBeTruthy()
|
|
268
295
|
})
|
|
269
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
|
+
|
|
270
401
|
test("a field error from the server focuses that field", async () => {
|
|
271
402
|
const user = userEvent.setup()
|
|
272
403
|
const action = vi.fn(() =>
|
|
@@ -573,17 +704,46 @@ describe("FormDialog", () => {
|
|
|
573
704
|
expect(screen.getByRole("dialog")).toBeTruthy()
|
|
574
705
|
})
|
|
575
706
|
|
|
576
|
-
|
|
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 () => {
|
|
577
733
|
const user = userEvent.setup()
|
|
578
734
|
const onSubmit = vi.fn()
|
|
579
735
|
const { rerender } = render(<Harness onSubmit={onSubmit} saveFromPrompt />)
|
|
580
736
|
const name = await openEdit(user)
|
|
581
737
|
await user.type(name, "!")
|
|
582
|
-
rerender(<Harness disabled onSubmit={onSubmit} saveFromPrompt />)
|
|
583
738
|
await user.click(screen.getByRole("button", { name: "Cancel" }))
|
|
584
739
|
const prompt = await screen.findByRole("alertdialog")
|
|
740
|
+
|
|
741
|
+
rerender(<Harness disabled onSubmit={onSubmit} saveFromPrompt />)
|
|
585
742
|
await user.click(within(prompt).getByRole("button", { name: "Save" }))
|
|
586
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())
|
|
587
747
|
})
|
|
588
748
|
|
|
589
749
|
test("Save in the leave prompt doesn't submit again while a submit runs", async () => {
|
|
@@ -650,3 +810,94 @@ describe("FormDialog", () => {
|
|
|
650
810
|
expect(outer).not.toHaveBeenCalled()
|
|
651
811
|
})
|
|
652
812
|
})
|
|
813
|
+
|
|
814
|
+
describe("FormDialogProvider", () => {
|
|
815
|
+
test("sets the defaults of the dialogs below it; a dialog's own prop wins", async () => {
|
|
816
|
+
const user = userEvent.setup()
|
|
817
|
+
render(
|
|
818
|
+
<FormDialogProvider
|
|
819
|
+
cancelLabel="Abbrechen"
|
|
820
|
+
discardPrompt={{
|
|
821
|
+
confirmLabel: "Verwerfen",
|
|
822
|
+
saveLabel: "Sichern",
|
|
823
|
+
saveTitle: "Speichern?",
|
|
824
|
+
title: "Verwerfen?",
|
|
825
|
+
}}
|
|
826
|
+
>
|
|
827
|
+
<FormDialogProvider saveFromPrompt submitLabel="Speichern">
|
|
828
|
+
<Harness discardPrompt={{ title: "Leave this person?" }} />
|
|
829
|
+
</FormDialogProvider>
|
|
830
|
+
</FormDialogProvider>
|
|
831
|
+
)
|
|
832
|
+
const name = await openEdit(user)
|
|
833
|
+
const dialog = screen.getByRole("dialog")
|
|
834
|
+
within(dialog).getByRole("button", { name: "Speichern" })
|
|
835
|
+
await user.type(name, "!")
|
|
836
|
+
await user.click(within(dialog).getByRole("button", { name: "Abbrechen" }))
|
|
837
|
+
|
|
838
|
+
// The prompt's copy merges key by key, and the nearest level wins: the
|
|
839
|
+
// dialog's title over the outer provider's `saveTitle`, its Discard
|
|
840
|
+
// label, and Save labelled by the inner provider's `submitLabel` over the
|
|
841
|
+
// outer one's `saveLabel`.
|
|
842
|
+
const prompt = await screen.findByRole("alertdialog")
|
|
843
|
+
within(prompt).getByText("Leave this person?")
|
|
844
|
+
within(prompt).getByRole("button", { name: "Verwerfen" })
|
|
845
|
+
within(prompt).getByRole("button", { name: "Speichern" })
|
|
846
|
+
})
|
|
847
|
+
|
|
848
|
+
test.each([
|
|
849
|
+
[true, "Unsaved changes", "Save them before you leave?"],
|
|
850
|
+
[false, "Discard changes?", "They will be lost."],
|
|
851
|
+
])(
|
|
852
|
+
"a provider words the prompt for each mode (saveFromPrompt: %s)",
|
|
853
|
+
async (saveFromPrompt, title, description) => {
|
|
854
|
+
const user = userEvent.setup()
|
|
855
|
+
render(
|
|
856
|
+
<FormDialogProvider
|
|
857
|
+
discardPrompt={{
|
|
858
|
+
description: "They will be lost.",
|
|
859
|
+
saveDescription: "Save them before you leave?",
|
|
860
|
+
saveTitle: "Unsaved changes",
|
|
861
|
+
title: "Discard changes?",
|
|
862
|
+
}}
|
|
863
|
+
>
|
|
864
|
+
<Harness saveFromPrompt={saveFromPrompt} />
|
|
865
|
+
</FormDialogProvider>
|
|
866
|
+
)
|
|
867
|
+
const name = await openEdit(user)
|
|
868
|
+
await user.type(name, "!")
|
|
869
|
+
await user.keyboard("{Escape}")
|
|
870
|
+
const prompt = await screen.findByRole("alertdialog")
|
|
871
|
+
within(prompt).getByText(title)
|
|
872
|
+
within(prompt).getByText(description)
|
|
873
|
+
}
|
|
874
|
+
)
|
|
875
|
+
|
|
876
|
+
test("a dialog's submitLabel labels the prompt's Save over a provider's saveLabel", async () => {
|
|
877
|
+
const user = userEvent.setup()
|
|
878
|
+
render(
|
|
879
|
+
<FormDialogProvider discardPrompt={{ saveLabel: "Speichern" }}>
|
|
880
|
+
<Harness saveFromPrompt submitLabel="Create invoice" />
|
|
881
|
+
</FormDialogProvider>
|
|
882
|
+
)
|
|
883
|
+
const name = await openEdit(user)
|
|
884
|
+
await user.type(name, "!")
|
|
885
|
+
await user.keyboard("{Escape}")
|
|
886
|
+
const prompt = await screen.findByRole("alertdialog")
|
|
887
|
+
within(prompt).getByRole("button", { name: "Create invoice" })
|
|
888
|
+
})
|
|
889
|
+
|
|
890
|
+
test("a dialog's own false wins over the provider's true", async () => {
|
|
891
|
+
const user = userEvent.setup()
|
|
892
|
+
render(
|
|
893
|
+
<FormDialogProvider saveFromPrompt>
|
|
894
|
+
<Harness saveFromPrompt={false} />
|
|
895
|
+
</FormDialogProvider>
|
|
896
|
+
)
|
|
897
|
+
const name = await openEdit(user)
|
|
898
|
+
await user.type(name, "!")
|
|
899
|
+
await user.keyboard("{Escape}")
|
|
900
|
+
const prompt = await screen.findByRole("alertdialog")
|
|
901
|
+
expect(within(prompt).queryByRole("button", { name: "Save" })).toBeNull()
|
|
902
|
+
})
|
|
903
|
+
})
|