@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.
Files changed (67) hide show
  1. package/dist/create-zeno-form.d.mts +27 -6
  2. package/dist/create-zeno-form.mjs +29 -6
  3. package/dist/form-element.d.mts +16 -3
  4. package/dist/form-element.mjs +38 -18
  5. package/dist/index.d.mts +3 -1
  6. package/dist/index.mjs +3 -1
  7. package/dist/lib/date-value.d.mts +24 -0
  8. package/dist/lib/date-value.mjs +37 -0
  9. package/dist/lib/default-props.d.mts +7 -0
  10. package/dist/lib/default-props.mjs +13 -0
  11. package/dist/lib/empty-value.mjs +25 -1
  12. package/dist/lib/form-disabled.d.mts +28 -0
  13. package/dist/lib/form-disabled.mjs +54 -0
  14. package/dist/lib/item-options.d.mts +45 -0
  15. package/dist/lib/item-options.mjs +70 -0
  16. package/dist/lib/schema-required.mjs +17 -0
  17. package/dist/lib/submit-action.d.mts +8 -2
  18. package/dist/lib/submit-action.mjs +4 -2
  19. package/dist/lib/use-field-control.d.mts +83 -0
  20. package/dist/lib/use-field-control.mjs +70 -0
  21. package/package.json +1 -1
  22. package/src/control-field.test.tsx +338 -0
  23. package/src/create-form.tsx +36 -28
  24. package/src/create-zeno-form.tsx +144 -52
  25. package/src/date-picker-field.test.tsx +108 -0
  26. package/src/default-props.test-d.ts +66 -0
  27. package/src/default-props.test.tsx +165 -0
  28. package/src/fields/checkbox-field.tsx +5 -0
  29. package/src/fields/checkbox-group-field.tsx +6 -2
  30. package/src/fields/combobox-field.tsx +107 -40
  31. package/src/fields/control-field.tsx +57 -0
  32. package/src/fields/date-picker-field.tsx +34 -11
  33. package/src/fields/field-shell.tsx +80 -0
  34. package/src/fields/index.ts +2 -0
  35. package/src/fields/input-field.tsx +16 -48
  36. package/src/fields/multi-select-field.tsx +100 -42
  37. package/src/fields/otp-field.tsx +8 -2
  38. package/src/fields/radio-group-field.tsx +9 -1
  39. package/src/fields/reset-button.tsx +4 -0
  40. package/src/fields/select-field.tsx +9 -1
  41. package/src/fields/slider-field.tsx +9 -1
  42. package/src/fields/submit-button.tsx +3 -1
  43. package/src/fields/switch-field.tsx +5 -0
  44. package/src/fields/textarea-field.tsx +16 -48
  45. package/src/form-dialog.test.tsx +102 -1
  46. package/src/form-dialog.tsx +143 -42
  47. package/src/form-disabled.test.tsx +329 -0
  48. package/src/form-element.tsx +64 -29
  49. package/src/grouped-options.test-d.ts +59 -0
  50. package/src/grouped-options.test.tsx +251 -0
  51. package/src/index.ts +7 -0
  52. package/src/lib/date-value.test.ts +14 -0
  53. package/src/lib/date-value.ts +70 -0
  54. package/src/lib/default-props.ts +19 -0
  55. package/src/lib/empty-value.test.ts +19 -0
  56. package/src/lib/empty-value.ts +45 -1
  57. package/src/lib/form-disabled.ts +95 -0
  58. package/src/lib/item-options.test.ts +11 -0
  59. package/src/lib/item-options.ts +165 -0
  60. package/src/lib/schema-required.test.ts +9 -0
  61. package/src/lib/schema-required.ts +33 -0
  62. package/src/lib/submit-action.ts +18 -4
  63. package/src/lib/use-field-control.ts +191 -0
  64. package/src/submit-action.test.tsx +103 -0
  65. package/src/use-app-fields.test-d.ts +51 -1
  66. package/src/with-form.test-d.ts +76 -0
  67. 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
+ })
@@ -1,52 +1,87 @@
1
1
  "use client"
2
2
 
3
3
  import type { AnyFormApi } from "@tanstack/react-form"
4
- import type { ComponentProps, FormEvent, ReactNode } from "react"
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}>{children}</RawFormProvider>
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
- <form
27
- className={className}
28
- noValidate
29
- onSubmit={async (event: FormEvent<HTMLFormElement>) => {
30
- event.preventDefault()
31
- event.stopPropagation()
32
- const node = event.currentTarget
33
- const focused = node.ownerDocument.activeElement
34
- // Caught so the user can retry; `useForm` keeps it for `FormError`.
35
- await Promise.resolve(form.handleSubmit()).catch(reportSubmitError)
36
- const { isSubmitSuccessful, isValid } = form.state
37
- if (!isValid && focusFirstInvalid(node)) {
38
- return
39
- }
40
- // A form-level failure has no field to fix, so focus stays where it
41
- // was, or goes back there if the submit button's disabling dropped it.
42
- if (!(isValid && isSubmitSuccessful)) {
43
- restoreFocus(focused)
44
- }
45
- }}
46
- {...props}
47
- >
48
- {children}
49
- </form>
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
+ })