@zeno-lib/forms 0.4.0 → 0.6.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 +8 -2
- package/dist/create-zeno-form.mjs +4 -0
- package/dist/form-element.mjs +7 -5
- package/dist/index.d.mts +2 -1
- package/dist/index.mjs +2 -1
- package/dist/lib/focus-first-invalid.d.mts +23 -0
- package/dist/lib/focus-first-invalid.mjs +37 -0
- package/dist/lib/submit-action.d.mts +8 -2
- package/dist/lib/submit-action.mjs +4 -2
- package/dist/lib/submit-error.d.mts +36 -0
- package/dist/lib/submit-error.mjs +66 -0
- package/dist/lib/use-field-control.d.mts +76 -0
- package/dist/lib/use-field-control.mjs +67 -0
- package/dist/lib/use-form-dialog.d.mts +15 -1
- package/dist/lib/use-form-dialog.mjs +20 -2
- package/package.json +1 -1
- package/src/control-field.test.tsx +338 -0
- package/src/create-form.tsx +5 -0
- package/src/create-zeno-form.tsx +32 -1
- package/src/fields/control-field.tsx +57 -0
- package/src/fields/field-shell.tsx +73 -0
- package/src/fields/form-error.tsx +61 -0
- package/src/fields/index.ts +3 -0
- package/src/fields/input-field.tsx +14 -48
- package/src/fields/textarea-field.tsx +14 -48
- package/src/form-dialog.test.tsx +493 -2
- package/src/form-dialog.tsx +217 -33
- package/src/form-element.tsx +13 -11
- package/src/form-error.test.tsx +255 -0
- package/src/index.ts +6 -0
- package/src/lib/focus-first-invalid.test.ts +22 -0
- package/src/lib/focus-first-invalid.ts +52 -0
- package/src/lib/submit-action.ts +18 -4
- package/src/lib/submit-error.ts +133 -0
- package/src/lib/use-field-control.ts +181 -0
- package/src/lib/use-form-dialog.ts +49 -1
- package/src/submit-action.test.tsx +103 -0
- package/src/use-app-fields.test-d.ts +51 -1
- package/src/use-form.focus-invalid.test.tsx +34 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { cleanup, render, screen } from "@zeno-lib/test/testing-library"
|
|
2
|
+
import userEvent from "@zeno-lib/test/user-event"
|
|
3
|
+
import type { ComponentProps } from "react"
|
|
4
|
+
import { afterEach, describe, expect, test, vi } from "vitest"
|
|
5
|
+
import { z } from "zod"
|
|
6
|
+
import { Form, FormProvider, useForm } from "./create-form"
|
|
7
|
+
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
cleanup()
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
const PHONE = /Phone/
|
|
13
|
+
const SUBMIT = { name: "Submit" }
|
|
14
|
+
|
|
15
|
+
// A control a `<label htmlFor>` can't name: only `aria-labelledby` does.
|
|
16
|
+
function StarRating({
|
|
17
|
+
disabled,
|
|
18
|
+
name: _name,
|
|
19
|
+
onChange,
|
|
20
|
+
value,
|
|
21
|
+
...props
|
|
22
|
+
}: Omit<ComponentProps<"div">, "onChange"> & {
|
|
23
|
+
disabled?: boolean
|
|
24
|
+
name?: string
|
|
25
|
+
onChange: (value: number) => void
|
|
26
|
+
value?: number | null
|
|
27
|
+
}) {
|
|
28
|
+
return (
|
|
29
|
+
<div role="radiogroup" {...props}>
|
|
30
|
+
{[1, 2, 3].map((star) => (
|
|
31
|
+
// biome-ignore lint/a11y/useSemanticElements: a custom radio group, as a star rating draws one.
|
|
32
|
+
<button
|
|
33
|
+
aria-checked={value === star}
|
|
34
|
+
disabled={disabled}
|
|
35
|
+
key={star}
|
|
36
|
+
onClick={() => onChange(star)}
|
|
37
|
+
role="radio"
|
|
38
|
+
type="button"
|
|
39
|
+
>
|
|
40
|
+
{star}
|
|
41
|
+
</button>
|
|
42
|
+
))}
|
|
43
|
+
</div>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// A text control that reports its value, not a change event.
|
|
48
|
+
function PhoneInput({
|
|
49
|
+
onChange,
|
|
50
|
+
...props
|
|
51
|
+
}: Omit<ComponentProps<"input">, "onChange" | "value"> & {
|
|
52
|
+
onChange: (value: string) => void
|
|
53
|
+
value: string
|
|
54
|
+
}) {
|
|
55
|
+
return (
|
|
56
|
+
<input
|
|
57
|
+
{...props}
|
|
58
|
+
onChange={(event) => onChange(event.target.value)}
|
|
59
|
+
type="tel"
|
|
60
|
+
/>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe("ControlField", () => {
|
|
65
|
+
test("names and describes its control, and flags it once invalid", async () => {
|
|
66
|
+
const user = userEvent.setup()
|
|
67
|
+
const schema = z.object({ rating: z.number("Rate it") })
|
|
68
|
+
function H() {
|
|
69
|
+
const form = useForm({ onSubmit: vi.fn(), schema })
|
|
70
|
+
const { ControlField, SubmitButton } = form
|
|
71
|
+
return (
|
|
72
|
+
<FormProvider form={form}>
|
|
73
|
+
<Form>
|
|
74
|
+
<ControlField
|
|
75
|
+
description="Out of three."
|
|
76
|
+
label="Rating"
|
|
77
|
+
name="rating"
|
|
78
|
+
render={(control) => <StarRating {...control} />}
|
|
79
|
+
/>
|
|
80
|
+
<SubmitButton>Submit</SubmitButton>
|
|
81
|
+
</Form>
|
|
82
|
+
</FormProvider>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
const { container } = render(<H />)
|
|
86
|
+
const group = screen.getByRole("radiogroup", { name: "Rating" })
|
|
87
|
+
const description = screen.getByText("Out of three.")
|
|
88
|
+
expect(group.getAttribute("aria-describedby")).toBe(description.id)
|
|
89
|
+
expect(group.hasAttribute("aria-invalid")).toBe(false)
|
|
90
|
+
|
|
91
|
+
await user.click(screen.getByRole("button", SUBMIT))
|
|
92
|
+
const error = await screen.findByRole("alert")
|
|
93
|
+
expect(error.textContent).toBe("Rate it")
|
|
94
|
+
expect(group.getAttribute("aria-invalid")).toBe("true")
|
|
95
|
+
expect(group.getAttribute("aria-describedby")).toBe(
|
|
96
|
+
`${description.id} ${error.id}`
|
|
97
|
+
)
|
|
98
|
+
expect(
|
|
99
|
+
container
|
|
100
|
+
.querySelector('[data-field="rating"]')
|
|
101
|
+
?.getAttribute("data-invalid")
|
|
102
|
+
).toBe("true")
|
|
103
|
+
|
|
104
|
+
await user.click(screen.getByRole("radio", { name: "2" }))
|
|
105
|
+
expect(screen.queryByRole("alert")).toBeNull()
|
|
106
|
+
expect(group.hasAttribute("aria-invalid")).toBe(false)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test("marks the field touched on blur, so its error shows", async () => {
|
|
110
|
+
const user = userEvent.setup()
|
|
111
|
+
const schema = z.object({ phone: z.string().min(1, "Enter a phone") })
|
|
112
|
+
function H() {
|
|
113
|
+
const form = useForm({ onSubmit: vi.fn(), schema })
|
|
114
|
+
const { ControlField } = form
|
|
115
|
+
return (
|
|
116
|
+
<FormProvider form={form}>
|
|
117
|
+
<Form>
|
|
118
|
+
<ControlField
|
|
119
|
+
label="Phone"
|
|
120
|
+
name="phone"
|
|
121
|
+
render={(control) => <PhoneInput {...control} />}
|
|
122
|
+
/>
|
|
123
|
+
</Form>
|
|
124
|
+
</FormProvider>
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
render(<H />)
|
|
128
|
+
await user.click(screen.getByLabelText(PHONE))
|
|
129
|
+
expect(screen.queryByRole("alert")).toBeNull()
|
|
130
|
+
await user.tab()
|
|
131
|
+
expect((await screen.findByRole("alert")).textContent).toBe("Enter a phone")
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test("takes required from the schema unless `required` says otherwise", () => {
|
|
135
|
+
const schema = z.object({
|
|
136
|
+
forced: z.string().optional(),
|
|
137
|
+
kept: z.string().min(1),
|
|
138
|
+
lifted: z.string().min(1),
|
|
139
|
+
open: z.string().optional(),
|
|
140
|
+
})
|
|
141
|
+
const FIELDS = [
|
|
142
|
+
{ name: "kept" },
|
|
143
|
+
{ name: "open" },
|
|
144
|
+
{ name: "forced", required: true },
|
|
145
|
+
{ name: "lifted", required: false },
|
|
146
|
+
] as const
|
|
147
|
+
function H({ requiredIndicator }: { requiredIndicator: boolean }) {
|
|
148
|
+
const form = useForm({ onSubmit: vi.fn(), requiredIndicator, schema })
|
|
149
|
+
const { ControlField } = form
|
|
150
|
+
return (
|
|
151
|
+
<FormProvider form={form}>
|
|
152
|
+
<Form>
|
|
153
|
+
{FIELDS.map((props) => (
|
|
154
|
+
<ControlField
|
|
155
|
+
key={props.name}
|
|
156
|
+
label={props.name}
|
|
157
|
+
render={(control) => (
|
|
158
|
+
<PhoneInput {...control} value={control.value ?? ""} />
|
|
159
|
+
)}
|
|
160
|
+
{...props}
|
|
161
|
+
/>
|
|
162
|
+
))}
|
|
163
|
+
</Form>
|
|
164
|
+
</FormProvider>
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
for (const requiredIndicator of [true, false]) {
|
|
168
|
+
const { container, unmount } = render(
|
|
169
|
+
<H requiredIndicator={requiredIndicator} />
|
|
170
|
+
)
|
|
171
|
+
const required = screen
|
|
172
|
+
.getAllByRole("textbox")
|
|
173
|
+
.filter((input) => input.getAttribute("aria-required") === "true")
|
|
174
|
+
.map((input) => input.getAttribute("name"))
|
|
175
|
+
expect(required).toEqual(["kept", "forced"])
|
|
176
|
+
// `required` overrides `requiredIndicator: false` too.
|
|
177
|
+
expect(
|
|
178
|
+
container.querySelectorAll('[data-slot="required-indicator"]')
|
|
179
|
+
).toHaveLength(requiredIndicator ? 2 : 1)
|
|
180
|
+
unmount()
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test("clearing stores the value the schema accepts as empty", async () => {
|
|
185
|
+
const user = userEvent.setup()
|
|
186
|
+
const schema = z.object({
|
|
187
|
+
count: z.number().nullable(),
|
|
188
|
+
nullable: z.string().min(1).nullable(),
|
|
189
|
+
optional: z.string().min(1).optional(),
|
|
190
|
+
tags: z.array(z.string()),
|
|
191
|
+
text: z.string(),
|
|
192
|
+
})
|
|
193
|
+
const captured: { values?: unknown } = {}
|
|
194
|
+
function H() {
|
|
195
|
+
const form = useForm({
|
|
196
|
+
defaultValues: {
|
|
197
|
+
count: 1,
|
|
198
|
+
nullable: "a",
|
|
199
|
+
optional: "b",
|
|
200
|
+
tags: ["c"],
|
|
201
|
+
text: "d",
|
|
202
|
+
},
|
|
203
|
+
onSubmit: vi.fn(),
|
|
204
|
+
schema,
|
|
205
|
+
})
|
|
206
|
+
const { ControlField, Subscribe } = form
|
|
207
|
+
return (
|
|
208
|
+
<FormProvider form={form}>
|
|
209
|
+
<Form>
|
|
210
|
+
{(["nullable", "optional", "text"] as const).map((name) => (
|
|
211
|
+
<ControlField
|
|
212
|
+
key={name}
|
|
213
|
+
label={name}
|
|
214
|
+
name={name}
|
|
215
|
+
render={(control) => (
|
|
216
|
+
<PhoneInput {...control} value={control.value ?? ""} />
|
|
217
|
+
)}
|
|
218
|
+
/>
|
|
219
|
+
))}
|
|
220
|
+
<ControlField
|
|
221
|
+
label="Tags"
|
|
222
|
+
name="tags"
|
|
223
|
+
render={({ onChange }) => (
|
|
224
|
+
<button onClick={() => onChange([])} type="button">
|
|
225
|
+
Clear tags
|
|
226
|
+
</button>
|
|
227
|
+
)}
|
|
228
|
+
/>
|
|
229
|
+
{/* A number control reports `NaN` once cleared. */}
|
|
230
|
+
<ControlField
|
|
231
|
+
label="Count"
|
|
232
|
+
name="count"
|
|
233
|
+
render={({ onChange }) => (
|
|
234
|
+
<button onClick={() => onChange(Number.NaN)} type="button">
|
|
235
|
+
Clear count
|
|
236
|
+
</button>
|
|
237
|
+
)}
|
|
238
|
+
/>
|
|
239
|
+
<Subscribe selector={(state) => state.values}>
|
|
240
|
+
{(values) => {
|
|
241
|
+
captured.values = values
|
|
242
|
+
return null
|
|
243
|
+
}}
|
|
244
|
+
</Subscribe>
|
|
245
|
+
</Form>
|
|
246
|
+
</FormProvider>
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
render(<H />)
|
|
250
|
+
await user.clear(screen.getByLabelText("nullable"))
|
|
251
|
+
await user.clear(screen.getByLabelText("optional"))
|
|
252
|
+
await user.clear(screen.getByLabelText("text"))
|
|
253
|
+
await user.click(screen.getByRole("button", { name: "Clear tags" }))
|
|
254
|
+
await user.click(screen.getByRole("button", { name: "Clear count" }))
|
|
255
|
+
expect(captured.values).toStrictEqual({
|
|
256
|
+
count: null,
|
|
257
|
+
nullable: null,
|
|
258
|
+
optional: undefined,
|
|
259
|
+
tags: [],
|
|
260
|
+
text: "",
|
|
261
|
+
})
|
|
262
|
+
expect(schema.safeParse(captured.values).success).toBe(true)
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
test("keeps one `onChange` across renders, so a control can memoize on it", async () => {
|
|
266
|
+
const user = userEvent.setup()
|
|
267
|
+
const handlers = new Set<unknown>()
|
|
268
|
+
function H() {
|
|
269
|
+
const form = useForm({ defaultValues: { phone: "" }, onSubmit: vi.fn() })
|
|
270
|
+
const { ControlField } = form
|
|
271
|
+
return (
|
|
272
|
+
<FormProvider form={form}>
|
|
273
|
+
<Form>
|
|
274
|
+
<ControlField
|
|
275
|
+
label="Phone"
|
|
276
|
+
name="phone"
|
|
277
|
+
render={(control) => {
|
|
278
|
+
handlers.add(control.onChange)
|
|
279
|
+
return <PhoneInput {...control} />
|
|
280
|
+
}}
|
|
281
|
+
/>
|
|
282
|
+
</Form>
|
|
283
|
+
</FormProvider>
|
|
284
|
+
)
|
|
285
|
+
}
|
|
286
|
+
render(<H />)
|
|
287
|
+
await user.type(screen.getByLabelText(PHONE), "079")
|
|
288
|
+
expect(handlers.size).toBe(1)
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
test("refuses a change event, rather than storing it", async () => {
|
|
292
|
+
const user = userEvent.setup()
|
|
293
|
+
const errors: unknown[] = []
|
|
294
|
+
const onError = (event: ErrorEvent) => {
|
|
295
|
+
event.preventDefault()
|
|
296
|
+
errors.push(event.error)
|
|
297
|
+
}
|
|
298
|
+
window.addEventListener("error", onError)
|
|
299
|
+
const captured: { values?: unknown } = {}
|
|
300
|
+
function H() {
|
|
301
|
+
const form = useForm({ defaultValues: { phone: "" }, onSubmit: vi.fn() })
|
|
302
|
+
const { ControlField, Subscribe } = form
|
|
303
|
+
return (
|
|
304
|
+
<FormProvider form={form}>
|
|
305
|
+
<Form>
|
|
306
|
+
<ControlField
|
|
307
|
+
label="Phone"
|
|
308
|
+
name="phone"
|
|
309
|
+
render={({ onChange }) => (
|
|
310
|
+
// The mistake: an event handed to `onChange` as the value.
|
|
311
|
+
<button
|
|
312
|
+
onClick={(event) => onChange(event as never)}
|
|
313
|
+
type="button"
|
|
314
|
+
>
|
|
315
|
+
Set
|
|
316
|
+
</button>
|
|
317
|
+
)}
|
|
318
|
+
/>
|
|
319
|
+
<Subscribe selector={(state) => state.values}>
|
|
320
|
+
{(values) => {
|
|
321
|
+
captured.values = values
|
|
322
|
+
return null
|
|
323
|
+
}}
|
|
324
|
+
</Subscribe>
|
|
325
|
+
</Form>
|
|
326
|
+
</FormProvider>
|
|
327
|
+
)
|
|
328
|
+
}
|
|
329
|
+
try {
|
|
330
|
+
render(<H />)
|
|
331
|
+
await user.click(screen.getByRole("button", { name: "Set" }))
|
|
332
|
+
} finally {
|
|
333
|
+
window.removeEventListener("error", onError)
|
|
334
|
+
}
|
|
335
|
+
expect(errors).toEqual([expect.any(TypeError)])
|
|
336
|
+
expect(captured.values).toStrictEqual({ phone: "" })
|
|
337
|
+
})
|
|
338
|
+
})
|
package/src/create-form.tsx
CHANGED
|
@@ -6,8 +6,10 @@ import { createZenoForm } from "@zeno-lib/forms"
|
|
|
6
6
|
import { CheckboxField } from "./fields/checkbox-field"
|
|
7
7
|
import { CheckboxGroupField } from "./fields/checkbox-group-field"
|
|
8
8
|
import { ComboboxField } from "./fields/combobox-field"
|
|
9
|
+
import { ControlField } from "./fields/control-field"
|
|
9
10
|
import { DatePickerField } from "./fields/date-picker-field"
|
|
10
11
|
import { EmailField } from "./fields/email-field"
|
|
12
|
+
import { FormError } from "./fields/form-error"
|
|
11
13
|
import { InputField } from "./fields/input-field"
|
|
12
14
|
import { MoneyField } from "./fields/money-field"
|
|
13
15
|
import { MultiSelectField } from "./fields/multi-select-field"
|
|
@@ -31,6 +33,7 @@ const { useAppForm, useForm, withFieldGroup, withForm } = createZenoForm({
|
|
|
31
33
|
CheckboxField,
|
|
32
34
|
CheckboxGroupField,
|
|
33
35
|
ComboboxField,
|
|
36
|
+
ControlField,
|
|
34
37
|
DatePickerField,
|
|
35
38
|
EmailField,
|
|
36
39
|
InputField,
|
|
@@ -48,6 +51,7 @@ const { useAppForm, useForm, withFieldGroup, withForm } = createZenoForm({
|
|
|
48
51
|
YearField,
|
|
49
52
|
},
|
|
50
53
|
formComponents: {
|
|
54
|
+
FormError,
|
|
51
55
|
ResetButton,
|
|
52
56
|
SubmitButton,
|
|
53
57
|
},
|
|
@@ -55,5 +59,6 @@ const { useAppForm, useForm, withFieldGroup, withForm } = createZenoForm({
|
|
|
55
59
|
|
|
56
60
|
export * from "@zeno-lib/forms"
|
|
57
61
|
export { ValidationSpinner } from "./addons/validation-spinner"
|
|
62
|
+
export { FieldShell } from "./fields/field-shell"
|
|
58
63
|
export { RadioGroupFieldItem } from "./fields/radio-group-field"
|
|
59
64
|
export { useAppForm, useForm, withFieldGroup, withForm }
|
package/src/create-zeno-form.tsx
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import type {
|
|
5
5
|
AnyFormApi,
|
|
6
6
|
DeepKeys,
|
|
7
|
+
DeepValue,
|
|
7
8
|
FormAsyncValidateOrFn,
|
|
8
9
|
FormOptions,
|
|
9
10
|
FormValidateOrFn,
|
|
@@ -18,6 +19,8 @@ import {
|
|
|
18
19
|
getRequiredPaths,
|
|
19
20
|
type StandardSchemaLike,
|
|
20
21
|
} from "./lib/schema-required"
|
|
22
|
+
import { setSubmitError } from "./lib/submit-error"
|
|
23
|
+
import type { FieldControlProps } from "./lib/use-field-control"
|
|
21
24
|
import { useRebasedDefaultValues } from "./lib/use-rebased-default-values"
|
|
22
25
|
import { useUnsavedChangesWarning } from "./lib/use-unsaved-changes-warning"
|
|
23
26
|
import { ValidationError } from "./lib/validation-error"
|
|
@@ -272,9 +275,26 @@ type WithValidators = {
|
|
|
272
275
|
// biome-ignore lint/suspicious/noExplicitAny: field components accept arbitrary props.
|
|
273
276
|
type AnyComponent = React.ComponentType<any>
|
|
274
277
|
|
|
278
|
+
// A field that hands `render` a `FieldControlProps` (the kit's `ControlField`)
|
|
279
|
+
// gets the control typed with the value at `name`, which its own generic
|
|
280
|
+
// can't see. Matching on the parameter, not on `render` as a whole, leaves a
|
|
281
|
+
// field whose `render` takes something else (or nothing) as declared.
|
|
282
|
+
type WithControlValue<P, V> = P extends {
|
|
283
|
+
render: (control: infer C) => ReactNode
|
|
284
|
+
}
|
|
285
|
+
? // biome-ignore lint/suspicious/noExplicitAny: matches any value type.
|
|
286
|
+
[C] extends [FieldControlProps<any>]
|
|
287
|
+
? Omit<P, "render"> & {
|
|
288
|
+
render: (control: FieldControlProps<V>) => ReactNode
|
|
289
|
+
}
|
|
290
|
+
: P
|
|
291
|
+
: P
|
|
292
|
+
|
|
275
293
|
type FieldWrappers<FC extends Record<string, AnyComponent>, T> = {
|
|
276
294
|
[K in keyof FC]: <N extends DeepKeys<T>>(
|
|
277
|
-
props: ComponentProps<FC[K]
|
|
295
|
+
props: WithControlValue<ComponentProps<FC[K]>, DeepValue<T, N>> & {
|
|
296
|
+
name: N
|
|
297
|
+
} & WithValidators
|
|
278
298
|
) => ReactNode
|
|
279
299
|
}
|
|
280
300
|
|
|
@@ -439,6 +459,7 @@ export function createZenoForm<
|
|
|
439
459
|
|
|
440
460
|
const wrappedOnSubmit = userOnSubmit
|
|
441
461
|
? ((async (props: { formApi: AnyFormApi; value: TFormData }) => {
|
|
462
|
+
const { submissionAttempts } = props.formApi.state
|
|
442
463
|
try {
|
|
443
464
|
await (
|
|
444
465
|
userOnSubmit as unknown as (
|
|
@@ -450,6 +471,16 @@ export function createZenoForm<
|
|
|
450
471
|
applyValidationError(props.formApi, error)
|
|
451
472
|
return
|
|
452
473
|
}
|
|
474
|
+
// Kept for `FormError`, unless an edit, a reset (a dialog closed
|
|
475
|
+
// meanwhile) or a newer submit came first: then it is about
|
|
476
|
+
// values no longer on screen.
|
|
477
|
+
const { state } = props.formApi
|
|
478
|
+
if (
|
|
479
|
+
state.values === props.value &&
|
|
480
|
+
state.submissionAttempts === submissionAttempts
|
|
481
|
+
) {
|
|
482
|
+
setSubmitError(props.formApi, error)
|
|
483
|
+
}
|
|
453
484
|
throw error
|
|
454
485
|
}
|
|
455
486
|
}) as typeof userOnSubmit)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type FieldControlProps,
|
|
5
|
+
useFieldControl,
|
|
6
|
+
} from "@zeno-lib/forms/lib/use-field-control"
|
|
7
|
+
import type { ReactNode } from "react"
|
|
8
|
+
import { FieldShell } from "./field-shell"
|
|
9
|
+
|
|
10
|
+
type ControlFieldProps<TValue = unknown> = {
|
|
11
|
+
description?: ReactNode
|
|
12
|
+
disabled?: boolean
|
|
13
|
+
label?: ReactNode
|
|
14
|
+
/**
|
|
15
|
+
* Renders the control, given the props that bind it to the field: `id`,
|
|
16
|
+
* `name`, `value`, `onChange(value)`, `onBlur`, `disabled` and the
|
|
17
|
+
* `aria-*`. Spread them on a control that takes its value that way, or map
|
|
18
|
+
* them for one that doesn't. Keep `aria-invalid` on the focusable element
|
|
19
|
+
* (or a group root around it): a failed submit focuses the first invalid
|
|
20
|
+
* field by it.
|
|
21
|
+
*/
|
|
22
|
+
render: (control: FieldControlProps<TValue>) => ReactNode
|
|
23
|
+
/**
|
|
24
|
+
* Mark the field required, or not, over the schema. Drives the `*` and
|
|
25
|
+
* `aria-required`.
|
|
26
|
+
*/
|
|
27
|
+
required?: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Binds a controlled component the kit has no field for (a phone input, a
|
|
32
|
+
* star rating, a color picker) in the kit's field layout. Clearing it stores
|
|
33
|
+
* the field's empty value, as the kit's fields do.
|
|
34
|
+
*/
|
|
35
|
+
function ControlField<TValue = unknown>({
|
|
36
|
+
description,
|
|
37
|
+
disabled,
|
|
38
|
+
label,
|
|
39
|
+
render,
|
|
40
|
+
required,
|
|
41
|
+
}: ControlFieldProps<TValue>) {
|
|
42
|
+
const { controlProps, ...shell } = useFieldControl<TValue>({
|
|
43
|
+
description,
|
|
44
|
+
disabled,
|
|
45
|
+
label,
|
|
46
|
+
required,
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<FieldShell {...shell} description={description} label={label}>
|
|
51
|
+
{render(controlProps)}
|
|
52
|
+
</FieldShell>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type { ControlFieldProps }
|
|
57
|
+
export { ControlField }
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import type { FieldControl } from "@zeno-lib/forms/lib/use-field-control"
|
|
4
|
+
import type { ReactNode } from "react"
|
|
5
|
+
import {
|
|
6
|
+
Field,
|
|
7
|
+
FieldDescription,
|
|
8
|
+
FieldError,
|
|
9
|
+
FieldLabel,
|
|
10
|
+
} from "@/components/ui/field"
|
|
11
|
+
import { RequiredIndicator } from "../lib/required-indicator"
|
|
12
|
+
|
|
13
|
+
type FieldShellProps = Pick<
|
|
14
|
+
FieldControl,
|
|
15
|
+
| "descriptionId"
|
|
16
|
+
| "errorId"
|
|
17
|
+
| "errors"
|
|
18
|
+
| "id"
|
|
19
|
+
| "isInvalid"
|
|
20
|
+
| "labelId"
|
|
21
|
+
| "name"
|
|
22
|
+
| "showRequiredIndicator"
|
|
23
|
+
> & {
|
|
24
|
+
/** The control, bound with `useFieldControl`'s `controlProps`. */
|
|
25
|
+
children: ReactNode
|
|
26
|
+
description?: ReactNode
|
|
27
|
+
label?: ReactNode
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The field layout every kit field shares: the label with its `*`, the
|
|
32
|
+
* control, the description and the error, tied together by the ids
|
|
33
|
+
* `useFieldControl` returns. Spread the rest of its result here:
|
|
34
|
+
*
|
|
35
|
+
* ```tsx
|
|
36
|
+
* const { controlProps, ...shell } = useFieldControl({ description, label })
|
|
37
|
+
* <FieldShell {...shell} description={description} label={label}>
|
|
38
|
+
* <MyControl {...controlProps} />
|
|
39
|
+
* </FieldShell>
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
function FieldShell({
|
|
43
|
+
children,
|
|
44
|
+
description,
|
|
45
|
+
descriptionId,
|
|
46
|
+
errorId,
|
|
47
|
+
errors,
|
|
48
|
+
id,
|
|
49
|
+
isInvalid,
|
|
50
|
+
label,
|
|
51
|
+
labelId,
|
|
52
|
+
name,
|
|
53
|
+
showRequiredIndicator,
|
|
54
|
+
}: FieldShellProps) {
|
|
55
|
+
return (
|
|
56
|
+
<Field data-field={name} data-invalid={isInvalid}>
|
|
57
|
+
{label && (
|
|
58
|
+
<FieldLabel htmlFor={id} id={labelId}>
|
|
59
|
+
{label}
|
|
60
|
+
{showRequiredIndicator && <RequiredIndicator />}
|
|
61
|
+
</FieldLabel>
|
|
62
|
+
)}
|
|
63
|
+
{children}
|
|
64
|
+
{description && (
|
|
65
|
+
<FieldDescription id={descriptionId}>{description}</FieldDescription>
|
|
66
|
+
)}
|
|
67
|
+
{errors.length > 0 && <FieldError errors={errors} id={errorId} />}
|
|
68
|
+
</Field>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type { FieldShellProps }
|
|
73
|
+
export { FieldShell }
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useFormContext } from "@zeno-lib/forms/lib/contexts"
|
|
4
|
+
import { useSubmitError } from "@zeno-lib/forms/lib/submit-error"
|
|
5
|
+
import { useSelector } from "@zeno-lib/forms/tanstack"
|
|
6
|
+
import type { ComponentProps, ReactNode } from "react"
|
|
7
|
+
import { FieldError } from "@/components/ui/field"
|
|
8
|
+
|
|
9
|
+
type FormErrorProps = Omit<
|
|
10
|
+
ComponentProps<typeof FieldError>,
|
|
11
|
+
"children" | "errors"
|
|
12
|
+
> & {
|
|
13
|
+
/**
|
|
14
|
+
* Shown when the submit handler throws (a network failure, an unexpected
|
|
15
|
+
* server error). Defaults to `"Something went wrong. Try again."`. A
|
|
16
|
+
* function gets what was thrown.
|
|
17
|
+
*/
|
|
18
|
+
submitErrorMessage?: ReactNode | ((error: unknown) => ReactNode)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The form-level error, as a `role="alert"` message: the `formErrors` an
|
|
23
|
+
* action returns through `submitAction`, a `ValidationError`'s `formError`,
|
|
24
|
+
* or `submitErrorMessage` when the submit throws. Renders nothing otherwise.
|
|
25
|
+
* It clears when the user edits the form, and a thrown one also when they
|
|
26
|
+
* submit again.
|
|
27
|
+
*/
|
|
28
|
+
function FormError({
|
|
29
|
+
submitErrorMessage = "Something went wrong. Try again.",
|
|
30
|
+
...props
|
|
31
|
+
}: FormErrorProps) {
|
|
32
|
+
const form = useFormContext()
|
|
33
|
+
const formError = useSelector(form.store, (state) => state.errorMap.onSubmit)
|
|
34
|
+
const submitError = useSubmitError(form)
|
|
35
|
+
|
|
36
|
+
// `submitAction` joins several messages with a line break. A schema's
|
|
37
|
+
// form-level result is an object, not a message, and isn't shown here. It
|
|
38
|
+
// wins over a thrown submit: it says what went wrong.
|
|
39
|
+
if (typeof formError === "string" && formError !== "") {
|
|
40
|
+
return (
|
|
41
|
+
<FieldError
|
|
42
|
+
data-slot="form-error"
|
|
43
|
+
errors={formError.split("\n").map((message) => ({ message }))}
|
|
44
|
+
{...props}
|
|
45
|
+
/>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
if (!submitError) {
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
return (
|
|
52
|
+
<FieldError data-slot="form-error" {...props}>
|
|
53
|
+
{typeof submitErrorMessage === "function"
|
|
54
|
+
? submitErrorMessage(submitError.error)
|
|
55
|
+
: submitErrorMessage}
|
|
56
|
+
</FieldError>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type { FormErrorProps }
|
|
61
|
+
export { FormError }
|
package/src/fields/index.ts
CHANGED
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
export * from "./checkbox-field"
|
|
3
3
|
export * from "./checkbox-group-field"
|
|
4
4
|
export * from "./combobox-field"
|
|
5
|
+
export * from "./control-field"
|
|
5
6
|
export * from "./date-picker-field"
|
|
6
7
|
export * from "./email-field"
|
|
8
|
+
export * from "./field-shell"
|
|
9
|
+
export * from "./form-error"
|
|
7
10
|
export * from "./input-field"
|
|
8
11
|
export * from "./money-field"
|
|
9
12
|
export * from "./multi-select-field"
|