@zeno-lib/forms 0.6.0 → 0.7.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.
- 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 +102 -1
- package/src/form-dialog.tsx +143 -42
- 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.tsx
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import { FormProvider } from "@zeno-lib/forms"
|
|
4
|
+
import { withDefaultProps } from "@zeno-lib/forms/lib/default-props"
|
|
4
5
|
import {
|
|
5
6
|
focusFirstInvalid,
|
|
6
7
|
restoreFocus,
|
|
7
8
|
} from "@zeno-lib/forms/lib/focus-first-invalid"
|
|
9
|
+
import { useIsFormDisabled } from "@zeno-lib/forms/lib/form-disabled"
|
|
8
10
|
import { reportSubmitError } from "@zeno-lib/forms/lib/submit-error"
|
|
9
11
|
import {
|
|
10
12
|
type FormDialogController,
|
|
@@ -14,10 +16,13 @@ import {
|
|
|
14
16
|
import { useUnsavedChangesWarning } from "@zeno-lib/forms/lib/use-unsaved-changes-warning"
|
|
15
17
|
import { type AnyFormApi, useSelector } from "@zeno-lib/forms/tanstack"
|
|
16
18
|
import {
|
|
19
|
+
type ComponentType,
|
|
20
|
+
createContext,
|
|
17
21
|
type FormEvent,
|
|
18
22
|
type KeyboardEvent,
|
|
19
23
|
type ReactElement,
|
|
20
24
|
type ReactNode,
|
|
25
|
+
useContext,
|
|
21
26
|
useId,
|
|
22
27
|
useLayoutEffect,
|
|
23
28
|
useRef,
|
|
@@ -45,7 +50,7 @@ import {
|
|
|
45
50
|
DialogTrigger,
|
|
46
51
|
} from "@/components/ui/dialog"
|
|
47
52
|
import { Spinner } from "@/components/ui/spinner"
|
|
48
|
-
import { FormError } from "./fields/form-error"
|
|
53
|
+
import { FormError, type FormErrorProps } from "./fields/form-error"
|
|
49
54
|
|
|
50
55
|
type DiscardPromptText = {
|
|
51
56
|
title?: ReactNode
|
|
@@ -54,6 +59,10 @@ type DiscardPromptText = {
|
|
|
54
59
|
cancelLabel?: ReactNode
|
|
55
60
|
/** The Save action's label (`saveFromPrompt`). Defaults to `submitLabel`. */
|
|
56
61
|
saveLabel?: ReactNode
|
|
62
|
+
/** The title with `saveFromPrompt`. Defaults to `title`. */
|
|
63
|
+
saveTitle?: ReactNode
|
|
64
|
+
/** The description with `saveFromPrompt`. Defaults to `description`. */
|
|
65
|
+
saveDescription?: ReactNode
|
|
57
66
|
}
|
|
58
67
|
|
|
59
68
|
// The slice of the `useForm()` API the dialog uses. Structural, so any Zeno /
|
|
@@ -77,6 +86,11 @@ type DialogForm<TValues> = {
|
|
|
77
86
|
readonly state: DialogFormState
|
|
78
87
|
handleSubmit: () => unknown
|
|
79
88
|
reset: (values?: TValues) => void
|
|
89
|
+
/**
|
|
90
|
+
* The form's own `FormError` (`useForm` attaches it), rendered with the
|
|
91
|
+
* app's `defaultProps`. The dialog falls back to the kit's.
|
|
92
|
+
*/
|
|
93
|
+
FormError?: ComponentType<Pick<FormErrorProps, "submitErrorMessage">>
|
|
80
94
|
}
|
|
81
95
|
|
|
82
96
|
type FormDialogProps<TValues> = {
|
|
@@ -99,9 +113,9 @@ type FormDialogProps<TValues> = {
|
|
|
99
113
|
/** Close after a successful submit. Defaults to `true`. */
|
|
100
114
|
closeOnSubmit?: boolean
|
|
101
115
|
/**
|
|
102
|
-
* Shown above the footer when the submit handler throws. Defaults to
|
|
103
|
-
*
|
|
104
|
-
* was thrown.
|
|
116
|
+
* Shown above the footer when the submit handler throws. Defaults to the
|
|
117
|
+
* form's `FormError` default: your `defaultProps`, or
|
|
118
|
+
* `"Something went wrong. Try again."`. A function gets what was thrown.
|
|
105
119
|
*/
|
|
106
120
|
submitErrorMessage?: ReactNode | ((error: unknown) => ReactNode)
|
|
107
121
|
/**
|
|
@@ -117,8 +131,9 @@ type FormDialogProps<TValues> = {
|
|
|
117
131
|
*/
|
|
118
132
|
saveFromPrompt?: boolean
|
|
119
133
|
/**
|
|
120
|
-
* Disable
|
|
121
|
-
*
|
|
134
|
+
* Disable every field, Save and ⌘/Ctrl+Enter, as `useForm({ disabled })`
|
|
135
|
+
* does. A `<fieldset disabled>` also disables native controls of your own.
|
|
136
|
+
* Defaults to `false`.
|
|
122
137
|
*/
|
|
123
138
|
disabled?: boolean
|
|
124
139
|
/** Class for the dialog popup (e.g. a wider `sm:max-w-lg`). */
|
|
@@ -127,6 +142,87 @@ type FormDialogProps<TValues> = {
|
|
|
127
142
|
formClassName?: string
|
|
128
143
|
}
|
|
129
144
|
|
|
145
|
+
/** The `FormDialog` props a `FormDialogProvider` can set for every dialog. */
|
|
146
|
+
type FormDialogDefaults = Omit<
|
|
147
|
+
FormDialogProps<unknown>,
|
|
148
|
+
| "children"
|
|
149
|
+
| "description"
|
|
150
|
+
| "dialog"
|
|
151
|
+
| "disabled"
|
|
152
|
+
| "footer"
|
|
153
|
+
| "form"
|
|
154
|
+
| "title"
|
|
155
|
+
| "trigger"
|
|
156
|
+
>
|
|
157
|
+
|
|
158
|
+
const FormDialogDefaultsContext = createContext<FormDialogDefaults | undefined>(
|
|
159
|
+
undefined
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
// `props` over `defaults`, with the discard prompt's copy merged key by key.
|
|
163
|
+
function withDialogDefaults<P extends FormDialogDefaults>(
|
|
164
|
+
defaults: FormDialogDefaults | undefined,
|
|
165
|
+
props: P
|
|
166
|
+
): P {
|
|
167
|
+
if (defaults === undefined) {
|
|
168
|
+
return props
|
|
169
|
+
}
|
|
170
|
+
const merged = withDefaultProps(defaults as Partial<P>, props)
|
|
171
|
+
// With `saveFromPrompt`, a `save*` key falls back to `title`, `description`
|
|
172
|
+
// or `submitLabel`. The nearest level wins: one that sets the fallback but
|
|
173
|
+
// not the `save*` key drops the `save*` key set further up.
|
|
174
|
+
const own = props.discardPrompt ?? {}
|
|
175
|
+
const inherited: DiscardPromptText = { ...defaults.discardPrompt }
|
|
176
|
+
if (props.submitLabel !== undefined) {
|
|
177
|
+
inherited.saveLabel = undefined
|
|
178
|
+
}
|
|
179
|
+
if (own.title !== undefined) {
|
|
180
|
+
inherited.saveTitle = undefined
|
|
181
|
+
}
|
|
182
|
+
if (own.description !== undefined) {
|
|
183
|
+
inherited.saveDescription = undefined
|
|
184
|
+
}
|
|
185
|
+
merged.discardPrompt = withDefaultProps(inherited, own)
|
|
186
|
+
return merged
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// The prompt's title and description for its mode.
|
|
190
|
+
function getPromptCopy(
|
|
191
|
+
text: DiscardPromptText | undefined,
|
|
192
|
+
saveFromPrompt: boolean
|
|
193
|
+
) {
|
|
194
|
+
if (saveFromPrompt) {
|
|
195
|
+
return {
|
|
196
|
+
description:
|
|
197
|
+
text?.saveDescription ??
|
|
198
|
+
text?.description ??
|
|
199
|
+
"Your unsaved changes will be lost unless you save them.",
|
|
200
|
+
title: text?.saveTitle ?? text?.title ?? "Save changes?",
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
description: text?.description ?? "Your unsaved changes will be lost.",
|
|
205
|
+
title: text?.title ?? "Discard changes?",
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Sets the default props of every `FormDialog` below it, such as its copy or
|
|
211
|
+
* `saveFromPrompt`. A prop passed to a dialog wins, and `discardPrompt` merges
|
|
212
|
+
* key by key. A nested provider merges over its parent the same way.
|
|
213
|
+
*/
|
|
214
|
+
function FormDialogProvider({
|
|
215
|
+
children,
|
|
216
|
+
...defaults
|
|
217
|
+
}: FormDialogDefaults & { children: ReactNode }) {
|
|
218
|
+
const parent = useContext(FormDialogDefaultsContext)
|
|
219
|
+
return (
|
|
220
|
+
<FormDialogDefaultsContext value={withDialogDefaults(parent, defaults)}>
|
|
221
|
+
{children}
|
|
222
|
+
</FormDialogDefaultsContext>
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
|
|
130
226
|
/**
|
|
131
227
|
* A dialog-hosted form: opens with fresh values and fields per session,
|
|
132
228
|
* submits from a footer button outside the `<form>` (via `form="id"`), shows a
|
|
@@ -135,26 +231,30 @@ type FormDialogProps<TValues> = {
|
|
|
135
231
|
* submit) above the footer, closes on success, and asks before discarding
|
|
136
232
|
* unsaved changes (Cancel, ×, Escape, outside press) or leaving the page.
|
|
137
233
|
*/
|
|
138
|
-
function FormDialog<TValues>({
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
234
|
+
function FormDialog<TValues>(props: FormDialogProps<TValues>) {
|
|
235
|
+
const {
|
|
236
|
+
cancelLabel = "Cancel",
|
|
237
|
+
children,
|
|
238
|
+
className,
|
|
239
|
+
closeOnSubmit = true,
|
|
240
|
+
description,
|
|
241
|
+
dialog,
|
|
242
|
+
disabled = false,
|
|
243
|
+
discardPrompt,
|
|
244
|
+
footer,
|
|
245
|
+
form,
|
|
246
|
+
formClassName,
|
|
247
|
+
guard = true,
|
|
248
|
+
saveFromPrompt = false,
|
|
249
|
+
submitErrorMessage,
|
|
250
|
+
submitLabel = "Save",
|
|
251
|
+
title,
|
|
252
|
+
trigger,
|
|
253
|
+
} = withDialogDefaults(useContext(FormDialogDefaultsContext), props)
|
|
157
254
|
const formId = useId()
|
|
255
|
+
// `useForm({ disabled })` locks the dialog as this prop does.
|
|
256
|
+
const isDisabled =
|
|
257
|
+
useIsFormDisabled(form as unknown as AnyFormApi) || disabled
|
|
158
258
|
const popupRef = useRef<HTMLDivElement>(null)
|
|
159
259
|
const formRef = useRef<HTMLFormElement>(null)
|
|
160
260
|
// Set when a Save from the discard prompt fails, so the prompt hands focus
|
|
@@ -167,6 +267,8 @@ function FormDialog<TValues>({
|
|
|
167
267
|
const announceOnPromptClosed = useRef<boolean>(false)
|
|
168
268
|
const [formErrorKey, setFormErrorKey] = useState(0)
|
|
169
269
|
const { close, defaultValues, focus, isOpen, open, session } = dialog
|
|
270
|
+
const DialogFormError = form.FormError ?? FormError
|
|
271
|
+
const promptCopy = getPromptCopy(discardPrompt, saveFromPrompt)
|
|
170
272
|
// Read after a submit's await: the dialog may have closed, or reopened on
|
|
171
273
|
// another record, while a slow save was in flight.
|
|
172
274
|
const latest = useRef({ isOpen, session })
|
|
@@ -216,7 +318,7 @@ function FormDialog<TValues>({
|
|
|
216
318
|
// the very first attempt, and `isOpen` is false through the exit animation,
|
|
217
319
|
// while the fields still hold the values just saved.
|
|
218
320
|
function canSubmit(): boolean {
|
|
219
|
-
return isOpen && !(
|
|
321
|
+
return isOpen && !(isDisabled || form.state.isSubmitting)
|
|
220
322
|
}
|
|
221
323
|
|
|
222
324
|
// Resolves whether the form saved. A submit handler that throws leaves
|
|
@@ -308,7 +410,7 @@ function FormDialog<TValues>({
|
|
|
308
410
|
initialFocus={() => {
|
|
309
411
|
// A disabled control can't take focus.
|
|
310
412
|
const target =
|
|
311
|
-
focus && !
|
|
413
|
+
focus && !isDisabled
|
|
312
414
|
? findFieldElement(popupRef.current, focus)
|
|
313
415
|
: null
|
|
314
416
|
return target ?? true
|
|
@@ -320,11 +422,11 @@ function FormDialog<TValues>({
|
|
|
320
422
|
<DialogTitle>{title}</DialogTitle>
|
|
321
423
|
{description && <DialogDescription>{description}</DialogDescription>}
|
|
322
424
|
</DialogHeader>
|
|
323
|
-
<FormProvider form={form}>
|
|
425
|
+
<FormProvider disabled={disabled} form={form}>
|
|
324
426
|
{/* Around the <form>, not inside it, so `formClassName` child
|
|
325
427
|
selectors (`space-y-4`) still reach the fields. `min-w-0` is
|
|
326
428
|
for a browser that ignores `contents` on a fieldset. */}
|
|
327
|
-
<fieldset className="contents min-w-0" disabled={
|
|
429
|
+
<fieldset className="contents min-w-0" disabled={isDisabled}>
|
|
328
430
|
{/* Keyed on the session so every open mounts fresh fields. The
|
|
329
431
|
popup stays mounted through its exit animation, so reopening
|
|
330
432
|
before it ends would keep each field's local state (and any
|
|
@@ -343,7 +445,7 @@ function FormDialog<TValues>({
|
|
|
343
445
|
{/* Next to the Save button that failed, and outside the <form>, so
|
|
344
446
|
it stays in view when the fields scroll and adds no child for
|
|
345
447
|
`formClassName` selectors to match. */}
|
|
346
|
-
<
|
|
448
|
+
<DialogFormError
|
|
347
449
|
key={formErrorKey}
|
|
348
450
|
submitErrorMessage={submitErrorMessage}
|
|
349
451
|
/>
|
|
@@ -354,7 +456,7 @@ function FormDialog<TValues>({
|
|
|
354
456
|
{cancelLabel}
|
|
355
457
|
</DialogClose>
|
|
356
458
|
<Button
|
|
357
|
-
disabled={
|
|
459
|
+
disabled={isDisabled || isSubmitting}
|
|
358
460
|
form={formId}
|
|
359
461
|
type="submit"
|
|
360
462
|
>
|
|
@@ -395,15 +497,9 @@ function FormDialog<TValues>({
|
|
|
395
497
|
size={saveFromPrompt ? "default" : "sm"}
|
|
396
498
|
>
|
|
397
499
|
<AlertDialogHeader>
|
|
398
|
-
<AlertDialogTitle>
|
|
399
|
-
{discardPrompt?.title ??
|
|
400
|
-
(saveFromPrompt ? "Save changes?" : "Discard changes?")}
|
|
401
|
-
</AlertDialogTitle>
|
|
500
|
+
<AlertDialogTitle>{promptCopy.title}</AlertDialogTitle>
|
|
402
501
|
<AlertDialogDescription>
|
|
403
|
-
{
|
|
404
|
-
(saveFromPrompt
|
|
405
|
-
? "Your unsaved changes will be lost unless you save them."
|
|
406
|
-
: "Your unsaved changes will be lost.")}
|
|
502
|
+
{promptCopy.description}
|
|
407
503
|
</AlertDialogDescription>
|
|
408
504
|
</AlertDialogHeader>
|
|
409
505
|
<AlertDialogFooter>
|
|
@@ -419,7 +515,7 @@ function FormDialog<TValues>({
|
|
|
419
515
|
</AlertDialogAction>
|
|
420
516
|
{saveFromPrompt && (
|
|
421
517
|
<AlertDialogAction
|
|
422
|
-
disabled={
|
|
518
|
+
disabled={isDisabled || isSaving || isSubmitting}
|
|
423
519
|
onClick={saveAndLeave}
|
|
424
520
|
>
|
|
425
521
|
{isSaving && <Spinner />}
|
|
@@ -433,5 +529,10 @@ function FormDialog<TValues>({
|
|
|
433
529
|
)
|
|
434
530
|
}
|
|
435
531
|
|
|
436
|
-
export type {
|
|
437
|
-
|
|
532
|
+
export type {
|
|
533
|
+
DialogForm,
|
|
534
|
+
DiscardPromptText,
|
|
535
|
+
FormDialogDefaults,
|
|
536
|
+
FormDialogProps,
|
|
537
|
+
}
|
|
538
|
+
export { FormDialog, FormDialogProvider }
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import {
|
|
2
|
+
cleanup,
|
|
3
|
+
render,
|
|
4
|
+
screen,
|
|
5
|
+
waitFor,
|
|
6
|
+
} from "@zeno-lib/test/testing-library"
|
|
7
|
+
import userEvent from "@zeno-lib/test/user-event"
|
|
8
|
+
import { memo, type ReactNode, useState } from "react"
|
|
9
|
+
import { createPortal } from "react-dom"
|
|
10
|
+
import { afterEach, describe, expect, test, vi } from "vitest"
|
|
11
|
+
import { Form, FormProvider, RadioGroupFieldItem, useForm } from "./create-form"
|
|
12
|
+
import { FormDialog } from "./form-dialog"
|
|
13
|
+
import { useFormDialog } from "./lib/use-form-dialog"
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
cleanup()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
type Values = { accept: boolean; name: string; notify: boolean; plan: string }
|
|
20
|
+
const DEFAULTS: Values = {
|
|
21
|
+
accept: false,
|
|
22
|
+
name: "",
|
|
23
|
+
notify: false,
|
|
24
|
+
plan: "free",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type Box = { current: Values | undefined }
|
|
28
|
+
|
|
29
|
+
// The fields that draw their own controls, which a `<fieldset disabled>`
|
|
30
|
+
// doesn't reach, plus a native input. `notify` passes `disabled={false}`.
|
|
31
|
+
function renderFields(form: ReturnType<typeof useTestForm>) {
|
|
32
|
+
const { CheckboxField, InputField, RadioGroupField, SwitchField } = form
|
|
33
|
+
return (
|
|
34
|
+
<>
|
|
35
|
+
<CheckboxField label="Accept" name="accept" />
|
|
36
|
+
<SwitchField disabled={false} label="Notify" name="notify" />
|
|
37
|
+
<RadioGroupField label="Plan" name="plan">
|
|
38
|
+
<RadioGroupFieldItem value="free">Free</RadioGroupFieldItem>
|
|
39
|
+
<RadioGroupFieldItem value="pro">Pro</RadioGroupFieldItem>
|
|
40
|
+
</RadioGroupField>
|
|
41
|
+
<InputField label="Name" name="name" />
|
|
42
|
+
</>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function useTestForm(options: { disabled?: boolean; onSubmit?: () => void }) {
|
|
47
|
+
return useForm({
|
|
48
|
+
defaultValues: DEFAULTS,
|
|
49
|
+
disabled: options.disabled,
|
|
50
|
+
onSubmit: options.onSubmit ?? vi.fn(),
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function Capture({
|
|
55
|
+
captured,
|
|
56
|
+
form,
|
|
57
|
+
}: {
|
|
58
|
+
captured: Box
|
|
59
|
+
form: ReturnType<typeof useTestForm>
|
|
60
|
+
}) {
|
|
61
|
+
const { Subscribe } = form
|
|
62
|
+
return (
|
|
63
|
+
<Subscribe selector={(state) => state.values}>
|
|
64
|
+
{(values) => {
|
|
65
|
+
captured.current = values
|
|
66
|
+
return null
|
|
67
|
+
}}
|
|
68
|
+
</Subscribe>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const DRAWN_CONTROLS = [
|
|
73
|
+
["checkbox", "Accept"],
|
|
74
|
+
["switch", "Notify"],
|
|
75
|
+
["radio", "Pro"],
|
|
76
|
+
] as const
|
|
77
|
+
|
|
78
|
+
// Clicks each control the fields draw, then checks that none took the click
|
|
79
|
+
// and that each shows as disabled. They are spans over a hidden input: a
|
|
80
|
+
// `<fieldset disabled>` disables only the input, so the span still looks,
|
|
81
|
+
// focuses and announces as enabled.
|
|
82
|
+
async function expectDrawnControlsDisabled(
|
|
83
|
+
user: ReturnType<typeof userEvent.setup>,
|
|
84
|
+
captured: Box
|
|
85
|
+
) {
|
|
86
|
+
const [checkbox, toggle, radio] = DRAWN_CONTROLS.map(([role, name]) =>
|
|
87
|
+
screen.getByRole(role, { name })
|
|
88
|
+
) as [HTMLElement, HTMLElement, HTMLElement]
|
|
89
|
+
await user.click(checkbox)
|
|
90
|
+
await user.click(toggle)
|
|
91
|
+
await user.click(radio)
|
|
92
|
+
expect(captured.current).toEqual(DEFAULTS)
|
|
93
|
+
for (const [role, name] of DRAWN_CONTROLS) {
|
|
94
|
+
expect(screen.getByRole(role, { name }).hasAttribute("data-disabled")).toBe(
|
|
95
|
+
true
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
describe("a disabled form", () => {
|
|
101
|
+
test.each(["useForm({ disabled })", "<Form disabled>"])(
|
|
102
|
+
"%s turns off every field, a field's `disabled={false}` included, and the submit",
|
|
103
|
+
async (via) => {
|
|
104
|
+
const user = userEvent.setup()
|
|
105
|
+
const onSubmit = vi.fn()
|
|
106
|
+
const captured: Box = { current: undefined }
|
|
107
|
+
function H() {
|
|
108
|
+
const form = useTestForm({
|
|
109
|
+
disabled: via.startsWith("useForm"),
|
|
110
|
+
onSubmit,
|
|
111
|
+
})
|
|
112
|
+
const { SubmitButton } = form
|
|
113
|
+
return (
|
|
114
|
+
<FormProvider form={form}>
|
|
115
|
+
<Form disabled={via.startsWith("<Form")} id="profile">
|
|
116
|
+
{renderFields(form)}
|
|
117
|
+
<SubmitButton>Save</SubmitButton>
|
|
118
|
+
<Capture captured={captured} form={form} />
|
|
119
|
+
</Form>
|
|
120
|
+
<button form="profile" type="submit">
|
|
121
|
+
Save from outside
|
|
122
|
+
</button>
|
|
123
|
+
</FormProvider>
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
render(<H />)
|
|
127
|
+
await expectDrawnControlsDisabled(user, captured)
|
|
128
|
+
expect(
|
|
129
|
+
screen.getByRole("textbox", { name: "Name" }).matches(":disabled")
|
|
130
|
+
).toBe(true)
|
|
131
|
+
expect(
|
|
132
|
+
screen.getByRole("button", { name: "Save" }).matches(":disabled")
|
|
133
|
+
).toBe(true)
|
|
134
|
+
await user.click(
|
|
135
|
+
screen.getByRole("button", { name: "Save from outside" })
|
|
136
|
+
)
|
|
137
|
+
expect(onSubmit).not.toHaveBeenCalled()
|
|
138
|
+
}
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
test("a field's own `disabled` still applies in an enabled form", async () => {
|
|
142
|
+
const user = userEvent.setup()
|
|
143
|
+
const captured: Box = { current: undefined }
|
|
144
|
+
function H() {
|
|
145
|
+
const form = useTestForm({})
|
|
146
|
+
const { CheckboxField, SwitchField } = form
|
|
147
|
+
return (
|
|
148
|
+
<FormProvider form={form}>
|
|
149
|
+
<Form>
|
|
150
|
+
<CheckboxField label="Accept" name="accept" />
|
|
151
|
+
<SwitchField disabled label="Notify" name="notify" />
|
|
152
|
+
<Capture captured={captured} form={form} />
|
|
153
|
+
</Form>
|
|
154
|
+
</FormProvider>
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
render(<H />)
|
|
158
|
+
await user.click(screen.getByRole("checkbox", { name: "Accept" }))
|
|
159
|
+
await user.click(screen.getByRole("switch", { name: "Notify" }))
|
|
160
|
+
expect(captured.current).toEqual({ ...DEFAULTS, accept: true })
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test("toggling `useForm({ disabled })` reaches fields under a memoized parent", async () => {
|
|
164
|
+
const user = userEvent.setup()
|
|
165
|
+
const captured: Box = { current: undefined }
|
|
166
|
+
const Section = memo(({ form }: { form: ReturnType<typeof useTestForm> }) =>
|
|
167
|
+
renderFields(form)
|
|
168
|
+
)
|
|
169
|
+
function H() {
|
|
170
|
+
const [locked, setLocked] = useState(false)
|
|
171
|
+
const form = useTestForm({ disabled: locked })
|
|
172
|
+
return (
|
|
173
|
+
<FormProvider form={form}>
|
|
174
|
+
<button onClick={() => setLocked(true)} type="button">
|
|
175
|
+
Lock
|
|
176
|
+
</button>
|
|
177
|
+
<Form>
|
|
178
|
+
<Section form={form} />
|
|
179
|
+
<Capture captured={captured} form={form} />
|
|
180
|
+
</Form>
|
|
181
|
+
</FormProvider>
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
render(<H />)
|
|
185
|
+
await user.click(screen.getByRole("button", { name: "Lock" }))
|
|
186
|
+
await expectDrawnControlsDisabled(user, captured)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
test("toggling `useForm({ disabled })` reaches memoized fields with no Zeno provider", async () => {
|
|
190
|
+
const user = userEvent.setup()
|
|
191
|
+
const captured: Box = { current: undefined }
|
|
192
|
+
const Section = memo(({ form }: { form: ReturnType<typeof useTestForm> }) =>
|
|
193
|
+
renderFields(form)
|
|
194
|
+
)
|
|
195
|
+
function H() {
|
|
196
|
+
const [locked, setLocked] = useState(false)
|
|
197
|
+
const form = useTestForm({ disabled: locked })
|
|
198
|
+
return (
|
|
199
|
+
<form.AppForm>
|
|
200
|
+
<button onClick={() => setLocked(true)} type="button">
|
|
201
|
+
Lock
|
|
202
|
+
</button>
|
|
203
|
+
<Section form={form} />
|
|
204
|
+
<Capture captured={captured} form={form} />
|
|
205
|
+
</form.AppForm>
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
render(<H />)
|
|
209
|
+
await user.click(screen.getByRole("button", { name: "Lock" }))
|
|
210
|
+
await expectDrawnControlsDisabled(user, captured)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
test("a form rendered inside another's `<Form disabled>` keeps its own state", async () => {
|
|
214
|
+
const user = userEvent.setup()
|
|
215
|
+
const onSubmit = vi.fn()
|
|
216
|
+
function H() {
|
|
217
|
+
const outer = useTestForm({})
|
|
218
|
+
const inner = useForm({ defaultValues: { title: "" }, onSubmit })
|
|
219
|
+
return (
|
|
220
|
+
<FormProvider form={outer}>
|
|
221
|
+
<Form disabled>
|
|
222
|
+
<outer.InputField label="Name" name="name" />
|
|
223
|
+
{/* A quick-add popover: portalled, under TanStack's provider. */}
|
|
224
|
+
{createPortal(
|
|
225
|
+
<inner.AppForm>
|
|
226
|
+
<Form>
|
|
227
|
+
<inner.InputField label="Title" name="title" />
|
|
228
|
+
<inner.SubmitButton>Add</inner.SubmitButton>
|
|
229
|
+
</Form>
|
|
230
|
+
</inner.AppForm>,
|
|
231
|
+
document.body
|
|
232
|
+
)}
|
|
233
|
+
</Form>
|
|
234
|
+
</FormProvider>
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
render(<H />)
|
|
238
|
+
expect(
|
|
239
|
+
screen.getByRole("textbox", { name: "Name" }).matches(":disabled")
|
|
240
|
+
).toBe(true)
|
|
241
|
+
await user.type(screen.getByRole("textbox", { name: "Title" }), "Draft")
|
|
242
|
+
await user.click(screen.getByRole("button", { name: "Add" }))
|
|
243
|
+
expect(onSubmit).toHaveBeenCalledOnce()
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
test("CheckboxGroupField dims each option's label", () => {
|
|
247
|
+
function H() {
|
|
248
|
+
const form = useForm({
|
|
249
|
+
defaultValues: { channels: [] as string[] },
|
|
250
|
+
disabled: true,
|
|
251
|
+
})
|
|
252
|
+
return (
|
|
253
|
+
<FormProvider form={form}>
|
|
254
|
+
<form.CheckboxGroupField
|
|
255
|
+
items={[{ label: "Email", value: "email" }]}
|
|
256
|
+
label="Channels"
|
|
257
|
+
name="channels"
|
|
258
|
+
/>
|
|
259
|
+
</FormProvider>
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
render(<H />)
|
|
263
|
+
const option = screen.getByRole("checkbox", { name: "Email" })
|
|
264
|
+
expect(
|
|
265
|
+
option.closest('[data-slot="field"]')?.getAttribute("data-disabled")
|
|
266
|
+
).toBe("true")
|
|
267
|
+
})
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
describe("FormDialog", () => {
|
|
271
|
+
function DialogHarness({
|
|
272
|
+
captured,
|
|
273
|
+
children,
|
|
274
|
+
disabled,
|
|
275
|
+
formDisabled,
|
|
276
|
+
}: {
|
|
277
|
+
captured: Box
|
|
278
|
+
children?: ReactNode
|
|
279
|
+
disabled?: boolean
|
|
280
|
+
formDisabled?: boolean
|
|
281
|
+
}) {
|
|
282
|
+
const dialog = useFormDialog({ defaultValues: DEFAULTS })
|
|
283
|
+
const form = useForm({
|
|
284
|
+
defaultValues: dialog.defaultValues,
|
|
285
|
+
disabled: formDisabled,
|
|
286
|
+
onSubmit: vi.fn(),
|
|
287
|
+
})
|
|
288
|
+
return (
|
|
289
|
+
<>
|
|
290
|
+
<button onClick={() => dialog.open()} type="button">
|
|
291
|
+
Open
|
|
292
|
+
</button>
|
|
293
|
+
<FormDialog
|
|
294
|
+
dialog={dialog}
|
|
295
|
+
disabled={disabled}
|
|
296
|
+
form={form}
|
|
297
|
+
title="Profile"
|
|
298
|
+
>
|
|
299
|
+
{renderFields(form)}
|
|
300
|
+
<Capture captured={captured} form={form} />
|
|
301
|
+
{children}
|
|
302
|
+
</FormDialog>
|
|
303
|
+
</>
|
|
304
|
+
)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
test("disabled reaches the checkbox, switch and radio its fieldset can't", async () => {
|
|
308
|
+
const user = userEvent.setup()
|
|
309
|
+
const captured: Box = { current: undefined }
|
|
310
|
+
render(<DialogHarness captured={captured} disabled />)
|
|
311
|
+
await user.click(screen.getByRole("button", { name: "Open" }))
|
|
312
|
+
await screen.findByRole("dialog")
|
|
313
|
+
await expectDrawnControlsDisabled(user, captured)
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
test("`useForm({ disabled })` turns off Save, as the prop does", async () => {
|
|
317
|
+
const user = userEvent.setup()
|
|
318
|
+
const captured: Box = { current: undefined }
|
|
319
|
+
render(<DialogHarness captured={captured} formDisabled />)
|
|
320
|
+
await user.click(screen.getByRole("button", { name: "Open" }))
|
|
321
|
+
await screen.findByRole("dialog")
|
|
322
|
+
await waitFor(() =>
|
|
323
|
+
expect(
|
|
324
|
+
screen.getByRole("button", { name: "Save" }).matches(":disabled")
|
|
325
|
+
).toBe(true)
|
|
326
|
+
)
|
|
327
|
+
await expectDrawnControlsDisabled(user, captured)
|
|
328
|
+
})
|
|
329
|
+
})
|