@zeno-lib/forms 0.3.0 → 0.4.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.mjs +5 -3
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +3 -3
- package/dist/lib/empty-value.d.mts +12 -0
- package/dist/lib/empty-value.mjs +68 -0
- package/dist/lib/schema-defaults.d.mts +9 -1
- package/dist/lib/schema-defaults.mjs +33 -1
- package/dist/lib/schema-required.d.mts +2 -1
- package/dist/lib/schema-required.mjs +49 -21
- package/dist/lib/submit-action.mjs +1 -1
- package/dist/lib/use-is-invalid.d.mts +2 -1
- package/dist/lib/use-is-invalid.mjs +5 -2
- package/dist/lib/validation-modes.d.mts +5 -1
- package/dist/lib/validation-modes.mjs +9 -4
- package/package.json +1 -1
- package/src/create-zeno-form.tsx +13 -5
- package/src/fields/checkbox-field.tsx +8 -1
- package/src/fields/combobox-field.tsx +29 -12
- package/src/fields/date-picker-field.tsx +37 -9
- package/src/fields/email-field.tsx +8 -0
- package/src/fields/input-field.tsx +6 -2
- package/src/fields/money-field.tsx +3 -1
- package/src/fields/multi-select-field.tsx +8 -1
- package/src/fields/number-field.tsx +9 -7
- package/src/fields/otp-field.tsx +8 -1
- package/src/fields/percentage-field.tsx +3 -1
- package/src/fields/radio-group-field.tsx +11 -4
- package/src/fields/select-field.tsx +26 -3
- package/src/fields/switch-field.tsx +8 -1
- package/src/fields/textarea-field.tsx +8 -1
- package/src/fields/year-field.tsx +3 -1
- package/src/index.ts +1 -0
- package/src/lib/empty-value.test.ts +72 -0
- package/src/lib/empty-value.ts +130 -0
- package/src/lib/schema-defaults.ts +50 -1
- package/src/lib/schema-required.test.ts +24 -2
- package/src/lib/schema-required.ts +81 -43
- package/src/lib/use-is-invalid.ts +14 -1
- package/src/lib/validation-modes.ts +18 -6
- package/src/use-app-fields.test.tsx +312 -2
|
@@ -1,13 +1,29 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
cleanup,
|
|
3
|
+
fireEvent,
|
|
4
|
+
render,
|
|
5
|
+
screen,
|
|
6
|
+
waitFor,
|
|
7
|
+
within,
|
|
8
|
+
} from "@zeno-lib/test/testing-library"
|
|
2
9
|
import userEvent from "@zeno-lib/test/user-event"
|
|
3
10
|
import { afterEach, describe, expect, test, vi } from "vitest"
|
|
4
11
|
import { z } from "zod"
|
|
5
|
-
import {
|
|
12
|
+
import { SelectItem } from "@/components/ui/select"
|
|
13
|
+
import { Form, FormProvider, RadioGroupFieldItem, useForm } from "./create-form"
|
|
6
14
|
|
|
7
15
|
const CONTACT_EMAIL_LABEL = /Contact email/
|
|
8
16
|
const PASSWORD_LABEL = /Password/
|
|
9
17
|
const NAME_LABEL = /Name/
|
|
10
18
|
const ACCEPT_NAME = /Accept/
|
|
19
|
+
const TITLE_LABEL = /Title/
|
|
20
|
+
const NOTE_LABEL = /Note/
|
|
21
|
+
const DAY_LABEL = /Day/
|
|
22
|
+
const PICKED_DAY = /October 9/
|
|
23
|
+
const ROLE_LABEL = /Role/
|
|
24
|
+
const OWNER_LABEL = /Owner/
|
|
25
|
+
const PICK_A_ROLE = "Pick a role"
|
|
26
|
+
const PICK_A_DAY = "Pick a day"
|
|
11
27
|
|
|
12
28
|
afterEach(() => {
|
|
13
29
|
cleanup()
|
|
@@ -164,3 +180,297 @@ describe("field ids", () => {
|
|
|
164
180
|
expect(dialog.getAttribute("aria-describedby")).toBe(hints[1]?.id)
|
|
165
181
|
})
|
|
166
182
|
})
|
|
183
|
+
|
|
184
|
+
describe("ComboboxField", () => {
|
|
185
|
+
test("offers to clear an optional value but not a required one", () => {
|
|
186
|
+
const schema = z.object({
|
|
187
|
+
forced: z.string().optional(),
|
|
188
|
+
kind: z.string().min(1),
|
|
189
|
+
note: z.string().optional(),
|
|
190
|
+
overridden: z.string().min(1),
|
|
191
|
+
})
|
|
192
|
+
const ITEMS = ["a", "b"]
|
|
193
|
+
function H() {
|
|
194
|
+
const form = useForm({
|
|
195
|
+
defaultValues: { forced: "a", kind: "a", note: "a", overridden: "a" },
|
|
196
|
+
onSubmit: vi.fn(),
|
|
197
|
+
schema,
|
|
198
|
+
})
|
|
199
|
+
const { ComboboxField } = form
|
|
200
|
+
return (
|
|
201
|
+
<FormProvider form={form}>
|
|
202
|
+
<Form>
|
|
203
|
+
<ComboboxField items={ITEMS} name="note" />
|
|
204
|
+
<ComboboxField items={ITEMS} name="kind" />
|
|
205
|
+
<ComboboxField items={ITEMS} name="forced" required />
|
|
206
|
+
<ComboboxField items={ITEMS} name="overridden" showClear />
|
|
207
|
+
</Form>
|
|
208
|
+
</FormProvider>
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
const { container } = render(<H />)
|
|
212
|
+
const hasClear = (name: string) => {
|
|
213
|
+
const root = container.querySelector<HTMLElement>(
|
|
214
|
+
`[data-field="${name}"]`
|
|
215
|
+
)
|
|
216
|
+
return root
|
|
217
|
+
? within(root).queryByRole("button", { name: "Clear" }) !== null
|
|
218
|
+
: false
|
|
219
|
+
}
|
|
220
|
+
expect(hasClear("note")).toBe(true)
|
|
221
|
+
expect(hasClear("kind")).toBe(false)
|
|
222
|
+
expect(hasClear("forced")).toBe(false)
|
|
223
|
+
expect(hasClear("overridden")).toBe(true)
|
|
224
|
+
})
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
describe("required fields", () => {
|
|
228
|
+
test("a required control says so to assistive tech, with or without the `*`", () => {
|
|
229
|
+
const schema = z.object({
|
|
230
|
+
kind: z.string().min(1),
|
|
231
|
+
note: z.string(),
|
|
232
|
+
title: z.string().min(1),
|
|
233
|
+
})
|
|
234
|
+
function H({ requiredIndicator }: { requiredIndicator: boolean }) {
|
|
235
|
+
const form = useForm({
|
|
236
|
+
defaultValues: { kind: "a" },
|
|
237
|
+
onSubmit: vi.fn(),
|
|
238
|
+
requiredIndicator,
|
|
239
|
+
schema,
|
|
240
|
+
})
|
|
241
|
+
const { ComboboxField, InputField } = form
|
|
242
|
+
return (
|
|
243
|
+
<FormProvider form={form}>
|
|
244
|
+
<Form>
|
|
245
|
+
<InputField label="Title" name="title" />
|
|
246
|
+
<InputField label="Note" name="note" />
|
|
247
|
+
<ComboboxField items={["a", "b"]} label="Kind" name="kind" />
|
|
248
|
+
</Form>
|
|
249
|
+
</FormProvider>
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
for (const requiredIndicator of [true, false]) {
|
|
253
|
+
const { container, unmount } = render(
|
|
254
|
+
<H requiredIndicator={requiredIndicator} />
|
|
255
|
+
)
|
|
256
|
+
const title = screen.getByRole("textbox", { name: TITLE_LABEL })
|
|
257
|
+
const note = screen.getByRole("textbox", { name: NOTE_LABEL })
|
|
258
|
+
expect(title.getAttribute("aria-required")).toBe("true")
|
|
259
|
+
expect(note.hasAttribute("aria-required")).toBe(false)
|
|
260
|
+
expect(
|
|
261
|
+
container.querySelectorAll('[data-slot="required-indicator"]')
|
|
262
|
+
).toHaveLength(requiredIndicator ? 2 : 0)
|
|
263
|
+
expect(
|
|
264
|
+
container.querySelector(
|
|
265
|
+
'[data-field="kind"] [data-slot="combobox-clear"]'
|
|
266
|
+
)
|
|
267
|
+
).toBeNull()
|
|
268
|
+
unmount()
|
|
269
|
+
}
|
|
270
|
+
})
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
describe("DatePickerField", () => {
|
|
274
|
+
const DAY = new Date(2026, 9, 9)
|
|
275
|
+
|
|
276
|
+
async function pickSelectedDayAgain(required: boolean) {
|
|
277
|
+
const captured: { day?: Date } = {}
|
|
278
|
+
function H() {
|
|
279
|
+
const form = useForm({
|
|
280
|
+
defaultValues: { day: DAY as Date | undefined },
|
|
281
|
+
onSubmit: vi.fn(),
|
|
282
|
+
})
|
|
283
|
+
const { DatePickerField, Subscribe } = form
|
|
284
|
+
return (
|
|
285
|
+
<FormProvider form={form}>
|
|
286
|
+
<Form>
|
|
287
|
+
<DatePickerField label="Day" name="day" required={required} />
|
|
288
|
+
<Subscribe selector={(state) => state.values.day}>
|
|
289
|
+
{(day) => {
|
|
290
|
+
captured.day = day
|
|
291
|
+
return null
|
|
292
|
+
}}
|
|
293
|
+
</Subscribe>
|
|
294
|
+
</Form>
|
|
295
|
+
</FormProvider>
|
|
296
|
+
)
|
|
297
|
+
}
|
|
298
|
+
const user = userEvent.setup()
|
|
299
|
+
render(<H />)
|
|
300
|
+
fireEvent.click(screen.getByRole("button", { name: DAY_LABEL }))
|
|
301
|
+
await user.click(await screen.findByRole("button", { name: PICKED_DAY }))
|
|
302
|
+
return captured.day
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
test("picking a required date's day again keeps it", async () => {
|
|
306
|
+
expect(await pickSelectedDayAgain(true)).toEqual(DAY)
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
test("picking an optional date's day again clears it", async () => {
|
|
310
|
+
expect(await pickSelectedDayAgain(false)).toBeUndefined()
|
|
311
|
+
})
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
describe("ComboboxField with items still loading", () => {
|
|
315
|
+
test("shows a stored id's label once its item arrives, never the raw id", () => {
|
|
316
|
+
function H({ items }: { items: { label: string; value: string }[] }) {
|
|
317
|
+
const form = useForm({
|
|
318
|
+
defaultValues: { ownerId: "user-7" },
|
|
319
|
+
onSubmit: vi.fn(),
|
|
320
|
+
})
|
|
321
|
+
const { ComboboxField } = form
|
|
322
|
+
return (
|
|
323
|
+
<FormProvider form={form}>
|
|
324
|
+
<Form>
|
|
325
|
+
<ComboboxField items={items} label="Owner" name="ownerId" />
|
|
326
|
+
</Form>
|
|
327
|
+
</FormProvider>
|
|
328
|
+
)
|
|
329
|
+
}
|
|
330
|
+
const { rerender } = render(<H items={[]} />)
|
|
331
|
+
const input = screen.getByRole("combobox", { name: OWNER_LABEL })
|
|
332
|
+
expect(input).toHaveProperty("value", "")
|
|
333
|
+
rerender(<H items={[{ label: "Ada Lovelace", value: "user-7" }]} />)
|
|
334
|
+
expect(input).toHaveProperty("value", "Ada Lovelace")
|
|
335
|
+
})
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
describe("popup fields", () => {
|
|
339
|
+
test("a required select shows its error once the popup closes, not while it is open", async () => {
|
|
340
|
+
const user = userEvent.setup()
|
|
341
|
+
function H() {
|
|
342
|
+
const form = useForm({
|
|
343
|
+
onSubmit: vi.fn(),
|
|
344
|
+
schema: z.object({ role: z.string().min(1, PICK_A_ROLE) }),
|
|
345
|
+
})
|
|
346
|
+
const { SelectField } = form
|
|
347
|
+
return (
|
|
348
|
+
<FormProvider form={form}>
|
|
349
|
+
<Form>
|
|
350
|
+
<SelectField label="Role" name="role">
|
|
351
|
+
<SelectItem value="admin">Admin</SelectItem>
|
|
352
|
+
</SelectField>
|
|
353
|
+
</Form>
|
|
354
|
+
</FormProvider>
|
|
355
|
+
)
|
|
356
|
+
}
|
|
357
|
+
render(<H />)
|
|
358
|
+
await user.click(screen.getByRole("combobox", { name: ROLE_LABEL }))
|
|
359
|
+
await screen.findByRole("option", { name: "Admin" })
|
|
360
|
+
expect(screen.queryByText(PICK_A_ROLE)).toBeNull()
|
|
361
|
+
await user.keyboard("{Escape}")
|
|
362
|
+
expect(await screen.findByText(PICK_A_ROLE)).toBeTruthy()
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
test("a required date shows its error once the popover closes, not while it is open", async () => {
|
|
366
|
+
const user = userEvent.setup()
|
|
367
|
+
function H() {
|
|
368
|
+
const form = useForm({
|
|
369
|
+
onSubmit: vi.fn(),
|
|
370
|
+
schema: z.object({ day: z.date({ error: PICK_A_DAY }) }),
|
|
371
|
+
})
|
|
372
|
+
const { DatePickerField } = form
|
|
373
|
+
return (
|
|
374
|
+
<FormProvider form={form}>
|
|
375
|
+
<Form>
|
|
376
|
+
<DatePickerField label="Day" name="day" />
|
|
377
|
+
</Form>
|
|
378
|
+
</FormProvider>
|
|
379
|
+
)
|
|
380
|
+
}
|
|
381
|
+
render(<H />)
|
|
382
|
+
const trigger = screen.getByRole("button", { name: DAY_LABEL })
|
|
383
|
+
trigger.focus()
|
|
384
|
+
fireEvent.click(trigger)
|
|
385
|
+
await screen.findByRole("grid")
|
|
386
|
+
// The trigger's blur is what used to mark the field touched.
|
|
387
|
+
await waitFor(() => expect(document.activeElement).not.toBe(trigger))
|
|
388
|
+
expect(screen.queryByText(PICK_A_DAY)).toBeNull()
|
|
389
|
+
await user.keyboard("{Escape}")
|
|
390
|
+
expect(await screen.findByText(PICK_A_DAY)).toBeTruthy()
|
|
391
|
+
})
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
describe("RadioGroupField", () => {
|
|
395
|
+
test("stores each item's value as is", async () => {
|
|
396
|
+
const user = userEvent.setup()
|
|
397
|
+
const captured: { consent?: unknown } = {}
|
|
398
|
+
function H() {
|
|
399
|
+
const form = useForm({
|
|
400
|
+
defaultValues: { consent: undefined as boolean | undefined },
|
|
401
|
+
onSubmit: vi.fn(),
|
|
402
|
+
})
|
|
403
|
+
const { RadioGroupField, Subscribe } = form
|
|
404
|
+
return (
|
|
405
|
+
<FormProvider form={form}>
|
|
406
|
+
<Form>
|
|
407
|
+
<RadioGroupField label="Consent" name="consent">
|
|
408
|
+
<RadioGroupFieldItem value={true}>Yes</RadioGroupFieldItem>
|
|
409
|
+
<RadioGroupFieldItem value={false}>No</RadioGroupFieldItem>
|
|
410
|
+
</RadioGroupField>
|
|
411
|
+
<Subscribe selector={(state) => state.values.consent}>
|
|
412
|
+
{(consent) => {
|
|
413
|
+
captured.consent = consent
|
|
414
|
+
return null
|
|
415
|
+
}}
|
|
416
|
+
</Subscribe>
|
|
417
|
+
</Form>
|
|
418
|
+
</FormProvider>
|
|
419
|
+
)
|
|
420
|
+
}
|
|
421
|
+
render(<H />)
|
|
422
|
+
await user.click(screen.getByRole("radio", { name: "No" }))
|
|
423
|
+
expect(captured.consent).toBe(false)
|
|
424
|
+
await user.click(screen.getByRole("radio", { name: "Yes" }))
|
|
425
|
+
expect(captured.consent).toBe(true)
|
|
426
|
+
})
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
describe("clearing a field", () => {
|
|
430
|
+
test("empties it to a value its schema accepts", async () => {
|
|
431
|
+
const user = userEvent.setup()
|
|
432
|
+
const OWNER_ID = "00000000-0000-4000-8000-000000000001"
|
|
433
|
+
const schema = z.object({ kind: z.string(), ownerId: z.uuid().nullable() })
|
|
434
|
+
const captured: { values?: unknown } = {}
|
|
435
|
+
function H() {
|
|
436
|
+
const form = useForm({
|
|
437
|
+
defaultValues: { kind: "a", ownerId: OWNER_ID },
|
|
438
|
+
onSubmit: vi.fn(),
|
|
439
|
+
schema,
|
|
440
|
+
})
|
|
441
|
+
const { ComboboxField, Subscribe } = form
|
|
442
|
+
return (
|
|
443
|
+
<FormProvider form={form}>
|
|
444
|
+
<Form>
|
|
445
|
+
<ComboboxField items={["a", "b"]} label="Kind" name="kind" />
|
|
446
|
+
<ComboboxField
|
|
447
|
+
items={[{ label: "Ada Lovelace", value: OWNER_ID }]}
|
|
448
|
+
label="Owner"
|
|
449
|
+
name="ownerId"
|
|
450
|
+
/>
|
|
451
|
+
<Subscribe selector={(state) => state.values}>
|
|
452
|
+
{(values) => {
|
|
453
|
+
captured.values = values
|
|
454
|
+
return null
|
|
455
|
+
}}
|
|
456
|
+
</Subscribe>
|
|
457
|
+
</Form>
|
|
458
|
+
</FormProvider>
|
|
459
|
+
)
|
|
460
|
+
}
|
|
461
|
+
const { container } = render(<H />)
|
|
462
|
+
const clearButton = (name: string) => {
|
|
463
|
+
const root = container.querySelector<HTMLElement>(
|
|
464
|
+
`[data-field="${name}"]`
|
|
465
|
+
)
|
|
466
|
+
if (!root) {
|
|
467
|
+
throw new Error(`no ${name} field`)
|
|
468
|
+
}
|
|
469
|
+
return within(root).getByRole("button", { name: "Clear" })
|
|
470
|
+
}
|
|
471
|
+
await user.click(clearButton("kind"))
|
|
472
|
+
await user.click(clearButton("ownerId"))
|
|
473
|
+
expect(captured.values).toEqual({ kind: "", ownerId: null })
|
|
474
|
+
expect(schema.safeParse(captured.values).success).toBe(true)
|
|
475
|
+
})
|
|
476
|
+
})
|