@zeno-lib/forms 0.5.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 +27 -6
- 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 +3 -1
- package/dist/index.mjs +3 -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/submit-action.d.mts +8 -2
- package/dist/lib/submit-action.mjs +4 -2
- package/dist/lib/use-field-control.d.mts +83 -0
- package/dist/lib/use-field-control.mjs +70 -0
- package/package.json +1 -1
- package/src/control-field.test.tsx +338 -0
- package/src/create-form.tsx +36 -28
- package/src/create-zeno-form.tsx +144 -52
- 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/control-field.tsx +57 -0
- package/src/fields/date-picker-field.tsx +34 -11
- package/src/fields/field-shell.tsx +80 -0
- package/src/fields/index.ts +2 -0
- package/src/fields/input-field.tsx +16 -48
- 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 +16 -48
- 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 +7 -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/submit-action.ts +18 -4
- package/src/lib/use-field-control.ts +191 -0
- package/src/submit-action.test.tsx +103 -0
- package/src/use-app-fields.test-d.ts +51 -1
- package/src/with-form.test-d.ts +76 -0
- package/src/with-form.test.tsx +60 -0
|
@@ -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
|
+
})
|
package/src/form-element.tsx
CHANGED
|
@@ -1,52 +1,87 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import type { AnyFormApi } from "@tanstack/react-form"
|
|
4
|
-
import
|
|
4
|
+
import {
|
|
5
|
+
type ComponentProps,
|
|
6
|
+
type FormEvent,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
useMemo,
|
|
9
|
+
} from "react"
|
|
5
10
|
|
|
6
11
|
import { FormProvider as RawFormProvider, useFormContext } from "./lib/contexts"
|
|
7
12
|
import { focusFirstInvalid, restoreFocus } from "./lib/focus-first-invalid"
|
|
13
|
+
import { FormDisabledContext, useIsFormDisabled } from "./lib/form-disabled"
|
|
8
14
|
import { reportSubmitError } from "./lib/submit-error"
|
|
9
15
|
|
|
10
16
|
type FormProviderProps = {
|
|
11
17
|
children: ReactNode
|
|
18
|
+
/**
|
|
19
|
+
* Disable every field of the form and its submit button, as
|
|
20
|
+
* `useForm({ disabled })` does. A field's own `disabled` still applies.
|
|
21
|
+
*/
|
|
22
|
+
disabled?: boolean
|
|
12
23
|
form: { handleSubmit: () => unknown }
|
|
13
24
|
}
|
|
14
25
|
|
|
15
|
-
function FormProvider({ children, form }: FormProviderProps) {
|
|
26
|
+
function FormProvider({ children, disabled = false, form }: FormProviderProps) {
|
|
27
|
+
const { store } = form as AnyFormApi
|
|
28
|
+
const scope = useMemo(() => ({ disabled, key: store }), [disabled, store])
|
|
16
29
|
return (
|
|
17
|
-
<RawFormProvider value={form as AnyFormApi}>
|
|
30
|
+
<RawFormProvider value={form as AnyFormApi}>
|
|
31
|
+
<FormDisabledContext value={scope}>{children}</FormDisabledContext>
|
|
32
|
+
</RawFormProvider>
|
|
18
33
|
)
|
|
19
34
|
}
|
|
20
35
|
|
|
21
|
-
type FormProps = Omit<ComponentProps<"form">, "onSubmit">
|
|
36
|
+
type FormProps = Omit<ComponentProps<"form">, "onSubmit"> & {
|
|
37
|
+
/**
|
|
38
|
+
* Disable every field and button of the kit inside, and ignore a submit. A
|
|
39
|
+
* field's own `disabled` still applies. A button outside the `<form>`
|
|
40
|
+
* (`form="id"`) isn't inside: disable the form with `useForm({ disabled })`
|
|
41
|
+
* or `<FormProvider disabled>` to reach it.
|
|
42
|
+
*/
|
|
43
|
+
disabled?: boolean
|
|
44
|
+
}
|
|
22
45
|
|
|
23
|
-
function Form({ children, className, ...props }: FormProps) {
|
|
46
|
+
function Form({ children, className, disabled = false, ...props }: FormProps) {
|
|
24
47
|
const form = useFormContext()
|
|
48
|
+
const isDisabled = useIsFormDisabled(form) || disabled
|
|
49
|
+
const { store } = form
|
|
50
|
+
const scope = useMemo(
|
|
51
|
+
() => ({ disabled: isDisabled, key: store }),
|
|
52
|
+
[isDisabled, store]
|
|
53
|
+
)
|
|
25
54
|
return (
|
|
26
|
-
<
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
event
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
55
|
+
<FormDisabledContext value={scope}>
|
|
56
|
+
<form
|
|
57
|
+
className={className}
|
|
58
|
+
noValidate
|
|
59
|
+
onSubmit={async (event: FormEvent<HTMLFormElement>) => {
|
|
60
|
+
event.preventDefault()
|
|
61
|
+
event.stopPropagation()
|
|
62
|
+
// A `<button form={id}>` outside the form can still submit it.
|
|
63
|
+
if (isDisabled) {
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
const node = event.currentTarget
|
|
67
|
+
const focused = node.ownerDocument.activeElement
|
|
68
|
+
// Caught so the user can retry; `useForm` keeps it for `FormError`.
|
|
69
|
+
await Promise.resolve(form.handleSubmit()).catch(reportSubmitError)
|
|
70
|
+
const { isSubmitSuccessful, isValid } = form.state
|
|
71
|
+
if (!isValid && focusFirstInvalid(node)) {
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
// A form-level failure has no field to fix, so focus stays where it
|
|
75
|
+
// was, or goes back there if the submit button's disabling dropped it.
|
|
76
|
+
if (!(isValid && isSubmitSuccessful)) {
|
|
77
|
+
restoreFocus(focused)
|
|
78
|
+
}
|
|
79
|
+
}}
|
|
80
|
+
{...props}
|
|
81
|
+
>
|
|
82
|
+
{children}
|
|
83
|
+
</form>
|
|
84
|
+
</FormDisabledContext>
|
|
50
85
|
)
|
|
51
86
|
}
|
|
52
87
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { expectTypeOf, test } from "vitest"
|
|
2
|
+
import { useForm } from "./create-form"
|
|
3
|
+
import { ComboboxField } from "./fields/combobox-field"
|
|
4
|
+
import { MultiSelectField } from "./fields/multi-select-field"
|
|
5
|
+
|
|
6
|
+
type Party = { id: number; kind: "company" | "person" }
|
|
7
|
+
type PartyItem = { label: string; value: Party }
|
|
8
|
+
|
|
9
|
+
const GROUPS: { label: string; items: PartyItem[] }[] = [
|
|
10
|
+
{
|
|
11
|
+
items: [{ label: "Ada", value: { id: 1, kind: "person" } }],
|
|
12
|
+
label: "People",
|
|
13
|
+
},
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
const isSameParty = (a: Party, b: Party) => a.kind === b.kind && a.id === b.id
|
|
17
|
+
|
|
18
|
+
test("grouped items type renderItem with an item and isItemEqualToValue with its value", () => {
|
|
19
|
+
ComboboxField({
|
|
20
|
+
isItemEqualToValue: (a, b) => {
|
|
21
|
+
expectTypeOf(a).toEqualTypeOf<Party>()
|
|
22
|
+
return isSameParty(a, b)
|
|
23
|
+
},
|
|
24
|
+
items: GROUPS,
|
|
25
|
+
renderItem: (item) => {
|
|
26
|
+
expectTypeOf(item).toEqualTypeOf<PartyItem>()
|
|
27
|
+
return null
|
|
28
|
+
},
|
|
29
|
+
})
|
|
30
|
+
MultiSelectField({
|
|
31
|
+
isItemEqualToValue: (a, b) => {
|
|
32
|
+
expectTypeOf(a).toEqualTypeOf<Party>()
|
|
33
|
+
return isSameParty(a, b)
|
|
34
|
+
},
|
|
35
|
+
items: GROUPS,
|
|
36
|
+
renderItem: (item) => {
|
|
37
|
+
expectTypeOf(item).toEqualTypeOf<PartyItem>()
|
|
38
|
+
return null
|
|
39
|
+
},
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
// `form.ComboboxField` reads the field's props with `T` as `unknown`.
|
|
44
|
+
test("form fields take a comparator typed with the value", () => {
|
|
45
|
+
const form = useForm({
|
|
46
|
+
defaultValues: { parties: [] as Party[], payer: null as Party | null },
|
|
47
|
+
onSubmit: () => undefined,
|
|
48
|
+
})
|
|
49
|
+
form.ComboboxField({
|
|
50
|
+
isItemEqualToValue: isSameParty,
|
|
51
|
+
items: GROUPS,
|
|
52
|
+
name: "payer",
|
|
53
|
+
})
|
|
54
|
+
form.MultiSelectField({
|
|
55
|
+
isItemEqualToValue: isSameParty,
|
|
56
|
+
items: GROUPS,
|
|
57
|
+
name: "parties",
|
|
58
|
+
})
|
|
59
|
+
})
|