@zeno-lib/forms 0.6.0 → 0.7.1

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 (59) hide show
  1. package/dist/create-zeno-form.d.mts +19 -4
  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 +2 -1
  6. package/dist/index.mjs +2 -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/use-field-control.d.mts +7 -0
  18. package/dist/lib/use-field-control.mjs +4 -1
  19. package/package.json +1 -1
  20. package/src/create-form.tsx +34 -29
  21. package/src/create-zeno-form.tsx +124 -51
  22. package/src/date-picker-field.test.tsx +108 -0
  23. package/src/default-props.test-d.ts +66 -0
  24. package/src/default-props.test.tsx +165 -0
  25. package/src/fields/checkbox-field.tsx +5 -0
  26. package/src/fields/checkbox-group-field.tsx +6 -2
  27. package/src/fields/combobox-field.tsx +107 -40
  28. package/src/fields/date-picker-field.tsx +34 -11
  29. package/src/fields/field-shell.tsx +8 -1
  30. package/src/fields/input-field.tsx +2 -0
  31. package/src/fields/multi-select-field.tsx +100 -42
  32. package/src/fields/otp-field.tsx +8 -2
  33. package/src/fields/radio-group-field.tsx +9 -1
  34. package/src/fields/reset-button.tsx +4 -0
  35. package/src/fields/select-field.tsx +9 -1
  36. package/src/fields/slider-field.tsx +9 -1
  37. package/src/fields/submit-button.tsx +3 -1
  38. package/src/fields/switch-field.tsx +5 -0
  39. package/src/fields/textarea-field.tsx +2 -0
  40. package/src/form-dialog.test.tsx +255 -4
  41. package/src/form-dialog.tsx +187 -51
  42. package/src/form-disabled.test.tsx +329 -0
  43. package/src/form-element.tsx +64 -29
  44. package/src/grouped-options.test-d.ts +59 -0
  45. package/src/grouped-options.test.tsx +251 -0
  46. package/src/index.ts +1 -0
  47. package/src/lib/date-value.test.ts +14 -0
  48. package/src/lib/date-value.ts +70 -0
  49. package/src/lib/default-props.ts +19 -0
  50. package/src/lib/empty-value.test.ts +19 -0
  51. package/src/lib/empty-value.ts +45 -1
  52. package/src/lib/form-disabled.ts +95 -0
  53. package/src/lib/item-options.test.ts +11 -0
  54. package/src/lib/item-options.ts +165 -0
  55. package/src/lib/schema-required.test.ts +9 -0
  56. package/src/lib/schema-required.ts +33 -0
  57. package/src/lib/use-field-control.ts +11 -1
  58. package/src/with-form.test-d.ts +76 -0
  59. package/src/with-form.test.tsx +60 -0
@@ -0,0 +1,251 @@
1
+ import { cleanup, render, screen, within } from "@zeno-lib/test/testing-library"
2
+ import userEvent from "@zeno-lib/test/user-event"
3
+ import { useState } 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 party = z.object({ id: z.number(), kind: z.enum(["company", "person"]) })
13
+ type Party = z.infer<typeof party>
14
+
15
+ const schema = z.object({
16
+ parties: z.array(party),
17
+ payer: party.nullable(),
18
+ })
19
+ type Values = z.infer<typeof schema>
20
+
21
+ // Rebuilt on every render, as options mapped from fetched rows are, so no
22
+ // item value is ever the stored object. A person and a company share an id.
23
+ function partyGroups() {
24
+ return [
25
+ {
26
+ items: [{ label: "Ada Lovelace", value: { id: 1, kind: "person" } }],
27
+ label: "People",
28
+ },
29
+ {
30
+ items: [
31
+ { label: "Globex", value: { id: 1, kind: "company" } },
32
+ { label: "Initech", value: { id: 2, kind: "company" } },
33
+ ],
34
+ label: "Companies",
35
+ },
36
+ ]
37
+ }
38
+
39
+ const isSameParty = (a: Party, b: Party) => a.kind === b.kind && a.id === b.id
40
+
41
+ function Harness({ captured }: { captured: { values?: Values } }) {
42
+ const form = useForm({
43
+ defaultValues: {
44
+ parties: [{ id: 1, kind: "company" }],
45
+ payer: { id: 1, kind: "company" },
46
+ } as Values,
47
+ onSubmit: vi.fn(),
48
+ schema,
49
+ })
50
+ const { ComboboxField, MultiSelectField, Subscribe } = form
51
+ return (
52
+ <FormProvider form={form}>
53
+ <Form>
54
+ <ComboboxField
55
+ isItemEqualToValue={isSameParty}
56
+ items={partyGroups()}
57
+ label="Payer"
58
+ name="payer"
59
+ />
60
+ <MultiSelectField
61
+ isItemEqualToValue={isSameParty}
62
+ items={partyGroups()}
63
+ label="Parties"
64
+ name="parties"
65
+ />
66
+ <Subscribe selector={(state) => state.values}>
67
+ {(values) => {
68
+ captured.values = values
69
+ return null
70
+ }}
71
+ </Subscribe>
72
+ </Form>
73
+ </FormProvider>
74
+ )
75
+ }
76
+
77
+ function fieldRoot(container: HTMLElement, name: string): HTMLElement {
78
+ const root = container.querySelector<HTMLElement>(`[data-field="${name}"]`)
79
+ if (!root) {
80
+ throw new Error(`no ${name} field`)
81
+ }
82
+ return root
83
+ }
84
+
85
+ function chips(container: HTMLElement): string[] {
86
+ return [
87
+ ...fieldRoot(container, "parties").querySelectorAll(
88
+ '[data-slot="combobox-chip"]'
89
+ ),
90
+ ].map((chip) => chip.textContent ?? "")
91
+ }
92
+
93
+ describe("grouped options with object values", () => {
94
+ test("ComboboxField keeps a stored object selected, stores the picked one, and clears to the empty value", async () => {
95
+ const user = userEvent.setup()
96
+ const captured: { values?: Values } = {}
97
+ const { container, rerender } = render(<Harness captured={captured} />)
98
+ const input = screen.getByRole("combobox", { name: "Payer" })
99
+ expect(input).toHaveProperty("value", "Globex")
100
+
101
+ await user.click(input)
102
+ const companies = await screen.findByRole("group", { name: "Companies" })
103
+ expect(
104
+ within(companies)
105
+ .getByRole("option", { name: "Globex" })
106
+ .getAttribute("aria-selected")
107
+ ).toBe("true")
108
+ await user.click(screen.getByRole("option", { name: "Ada Lovelace" }))
109
+ expect(captured.values?.payer).toEqual({ id: 1, kind: "person" })
110
+
111
+ rerender(<Harness captured={captured} />)
112
+ expect(input).toHaveProperty("value", "Ada Lovelace")
113
+
114
+ await user.click(
115
+ within(fieldRoot(container, "payer")).getByRole("button", {
116
+ name: "Clear",
117
+ })
118
+ )
119
+ expect(captured.values?.payer).toBeNull()
120
+ })
121
+
122
+ test("MultiSelectField keeps stored objects selected, adds the picked one, and clears", async () => {
123
+ const user = userEvent.setup()
124
+ const captured: { values?: Values } = {}
125
+ const { container, rerender } = render(<Harness captured={captured} />)
126
+ expect(chips(container)).toEqual(["Globex"])
127
+
128
+ await user.click(screen.getByRole("combobox", { name: "Parties" }))
129
+ const people = await screen.findByRole("group", { name: "People" })
130
+ await user.click(
131
+ within(people).getByRole("option", { name: "Ada Lovelace" })
132
+ )
133
+ await user.keyboard("{Escape}")
134
+ expect(captured.values?.parties).toEqual([
135
+ { id: 1, kind: "company" },
136
+ { id: 1, kind: "person" },
137
+ ])
138
+
139
+ rerender(<Harness captured={captured} />)
140
+ expect(chips(container)).toEqual(["Globex", "Ada Lovelace"])
141
+
142
+ await user.click(
143
+ within(fieldRoot(container, "parties")).getByRole("button", {
144
+ name: "Clear",
145
+ })
146
+ )
147
+ expect(captured.values?.parties).toEqual([])
148
+ })
149
+ })
150
+
151
+ describe("grouped options", () => {
152
+ test.each([
153
+ ["a group first", [{ items: ["Ada"], label: "People" }, "Globex"]],
154
+ ["a plain item first", ["Globex", { items: ["Ada"], label: "People" }]],
155
+ ])("mix groups and plain items, with %s", async (_, items) => {
156
+ const user = userEvent.setup()
157
+ const captured: { values?: { name: string | null } } = {}
158
+ function H() {
159
+ const form = useForm({
160
+ defaultValues: { name: null as string | null },
161
+ onSubmit: vi.fn(),
162
+ })
163
+ return (
164
+ <FormProvider form={form}>
165
+ <form.ComboboxField items={items} label="Name" name="name" />
166
+ <form.Subscribe selector={(state) => state.values}>
167
+ {(values) => {
168
+ captured.values = values
169
+ return null
170
+ }}
171
+ </form.Subscribe>
172
+ </FormProvider>
173
+ )
174
+ }
175
+ render(<H />)
176
+ const input = screen.getByRole("combobox", { name: "Name" })
177
+ await user.type(input, "Ad")
178
+ const people = await screen.findByRole("group", { name: "People" })
179
+ await user.click(within(people).getByRole("option", { name: "Ada" }))
180
+ expect(captured.values?.name).toBe("Ada")
181
+ await user.clear(input)
182
+ await user.type(input, "Glo")
183
+ await user.click(await screen.findByRole("option", { name: "Globex" }))
184
+ expect(captured.values?.name).toBe("Globex")
185
+ })
186
+
187
+ test("ComboboxField keeps what the user types while the items are rebuilt", async () => {
188
+ const user = userEvent.setup()
189
+ function H() {
190
+ const [, setQuery] = useState("")
191
+ const form = useForm({
192
+ defaultValues: { payer: { id: 1, kind: "company" } } as Values,
193
+ onSubmit: vi.fn(),
194
+ schema,
195
+ })
196
+ return (
197
+ <FormProvider form={form}>
198
+ <form.ComboboxField
199
+ isItemEqualToValue={isSameParty}
200
+ items={partyGroups()}
201
+ label="Payer"
202
+ name="payer"
203
+ onInputValueChange={setQuery}
204
+ />
205
+ </FormProvider>
206
+ )
207
+ }
208
+ render(<H />)
209
+ const input = screen.getByRole("combobox", { name: "Payer" })
210
+ await user.type(input, " Corp")
211
+ expect(input).toHaveProperty("value", "Globex Corp")
212
+ })
213
+
214
+ test("MultiSelectField keeps a stored value whose item isn't loaded yet", async () => {
215
+ const user = userEvent.setup()
216
+ const captured: { values?: Values } = {}
217
+ function H() {
218
+ const form = useForm({
219
+ defaultValues: { parties: [{ id: 9, kind: "company" }] } as Values,
220
+ onSubmit: vi.fn(),
221
+ schema,
222
+ })
223
+ return (
224
+ <FormProvider form={form}>
225
+ <form.MultiSelectField
226
+ isItemEqualToValue={isSameParty}
227
+ // The companies are still loading.
228
+ items={partyGroups().slice(0, 1)}
229
+ label="Parties"
230
+ name="parties"
231
+ />
232
+ <form.Subscribe selector={(state) => state.values}>
233
+ {(values) => {
234
+ captured.values = values
235
+ return null
236
+ }}
237
+ </form.Subscribe>
238
+ </FormProvider>
239
+ )
240
+ }
241
+ render(<H />)
242
+ await user.click(screen.getByRole("combobox", { name: "Parties" }))
243
+ await user.click(
244
+ await screen.findByRole("option", { name: "Ada Lovelace" })
245
+ )
246
+ expect(captured.values?.parties).toEqual([
247
+ { id: 9, kind: "company" },
248
+ { id: 1, kind: "person" },
249
+ ])
250
+ })
251
+ })
package/src/index.ts CHANGED
@@ -12,6 +12,7 @@ export {
12
12
  } from "./lib/action-result"
13
13
  export { applyValidationError } from "./lib/apply-validation-error"
14
14
  export { useFieldContext, useFormContext } from "./lib/contexts"
15
+ export { useIsFormDisabled } from "./lib/form-disabled"
15
16
  export {
16
17
  applyActionError,
17
18
  type SubmitActionOptions,
@@ -0,0 +1,14 @@
1
+ import { describe, expect, test } from "vitest"
2
+ import { formatIsoDate, parseDateValue } from "./date-value"
3
+
4
+ describe("parseDateValue", () => {
5
+ test("reads a day the month doesn't have as empty, not as next month's", () => {
6
+ expect(parseDateValue("2026-02-31")).toBeUndefined()
7
+ })
8
+
9
+ test("keeps a year below 100, which `new Date(y, m, d)` reads as 19yy", () => {
10
+ const date = parseDateValue("0042-03-01")
11
+ expect(date?.getFullYear()).toBe(42)
12
+ expect(date && formatIsoDate(date)).toBe("0042-03-01")
13
+ })
14
+ })
@@ -0,0 +1,70 @@
1
+ /**
2
+ * What a date field stores for a picked day:
3
+ * - `"date"`: a `Date`, at local midnight.
4
+ * - `"iso-date"`: the day as `YYYY-MM-DD`, as `z.iso.date()` accepts and a
5
+ * `date` column stores it.
6
+ * - `"iso-datetime"`: the day's local midnight as an ISO 8601 timestamp in
7
+ * UTC (`toISOString()`), as `z.iso.datetime()` accepts and a timestamp
8
+ * column takes it.
9
+ */
10
+ type DateValueType = "date" | "iso-date" | "iso-datetime"
11
+
12
+ const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/
13
+
14
+ function pad(value: number, length: number): string {
15
+ return String(value).padStart(length, "0")
16
+ }
17
+
18
+ // `new Date("2026-10-09")` reads a date-only string as UTC midnight, which is
19
+ // the day before anywhere west of UTC. Read it as the local day instead.
20
+ function parseIsoDate(value: string): Date | undefined {
21
+ const match = ISO_DATE.exec(value)
22
+ if (!match) {
23
+ return
24
+ }
25
+ const [year, month, day] = match.slice(1).map(Number) as [
26
+ number,
27
+ number,
28
+ number,
29
+ ]
30
+ const date = new Date(year, month - 1, day)
31
+ // `new Date` reads the years 0 to 99 as 1900 to 1999.
32
+ date.setFullYear(year, month - 1, day)
33
+ // `2026-02-31` would roll over to March.
34
+ return date.getMonth() === month - 1 ? date : undefined
35
+ }
36
+
37
+ /**
38
+ * The day a stored value stands for, as a local `Date`, or `undefined` for an
39
+ * empty or unreadable one. Reads a `Date`, a `YYYY-MM-DD` string as that day
40
+ * in local time, and any other string `Date` parses, such as an ISO 8601
41
+ * timestamp.
42
+ */
43
+ function parseDateValue(value: unknown): Date | undefined {
44
+ let date: Date | undefined
45
+ if (value instanceof Date) {
46
+ date = value
47
+ } else if (typeof value === "string" && value !== "") {
48
+ date = ISO_DATE.test(value) ? parseIsoDate(value) : new Date(value)
49
+ }
50
+ return date && !Number.isNaN(date.getTime()) ? date : undefined
51
+ }
52
+
53
+ /** The day of a local `Date` as `YYYY-MM-DD`, without going through UTC. */
54
+ function formatIsoDate(date: Date): string {
55
+ return `${pad(date.getFullYear(), 4)}-${pad(date.getMonth() + 1, 2)}-${pad(date.getDate(), 2)}`
56
+ }
57
+
58
+ /** The value to store for `date`, a day picked in local time. */
59
+ function toDateValue(date: Date, type: DateValueType): Date | string {
60
+ if (type === "iso-date") {
61
+ return formatIsoDate(date)
62
+ }
63
+ if (type === "iso-datetime") {
64
+ return date.toISOString()
65
+ }
66
+ return date
67
+ }
68
+
69
+ export type { DateValueType }
70
+ export { formatIsoDate, parseDateValue, toDateValue }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The props a component renders with: `props` over `defaults`. A prop passed
3
+ * as `undefined` keeps its default, as a destructuring default would.
4
+ */
5
+ export function withDefaultProps<P extends object>(
6
+ defaults: Partial<P> | undefined,
7
+ props: P
8
+ ): P {
9
+ if (defaults === undefined) {
10
+ return props
11
+ }
12
+ const merged: Record<string, unknown> = { ...defaults }
13
+ for (const [key, value] of Object.entries(props)) {
14
+ if (value !== undefined) {
15
+ merged[key] = value
16
+ }
17
+ }
18
+ return merged as P
19
+ }
@@ -69,4 +69,23 @@ describe("withEmptyStartValues", () => {
69
69
  ownerId: null,
70
70
  })
71
71
  })
72
+
73
+ test("starts an object the defaults only partly fill at its empty value, when it can be absent", () => {
74
+ const reference = z.object({ id: z.uuid(), table: z.string() })
75
+ const schema = z.object({
76
+ address: z.object({ city: z.string(), street: z.string() }).nullable(),
77
+ owner: reference,
78
+ payee: reference.optional(),
79
+ payer: reference.nullable(),
80
+ })
81
+ const start = withEmptyStartValues(schema, extractZodDefaults(schema))
82
+ expect(start).toEqual({
83
+ // Valid as filled, so its inputs stay controlled.
84
+ address: { city: "", street: "" },
85
+ // Required: its own fields still report what is missing.
86
+ owner: { id: "", table: "" },
87
+ payee: undefined,
88
+ payer: null,
89
+ })
90
+ })
72
91
  })
@@ -89,21 +89,65 @@ function isTypeEmpty(value: unknown): boolean {
89
89
  return value === "" || (Array.isArray(value) && value.length === 0)
90
90
  }
91
91
 
92
+ function isUnder(name: string, prefix: string): boolean {
93
+ return (
94
+ name === prefix ||
95
+ name.startsWith(`${prefix}.`) ||
96
+ name.startsWith(`${prefix}[`)
97
+ )
98
+ }
99
+
100
+ // The outermost object above `keys` that the defaults fill and the schema
101
+ // lets be absent, with its empty value (`null` or `undefined`).
102
+ function emptiableAncestor(
103
+ schema: StandardSchemaLike,
104
+ values: unknown,
105
+ keys: readonly PropertyKey[]
106
+ ): { keys: readonly PropertyKey[]; value: unknown } | null {
107
+ for (let length = 1; length < keys.length; length += 1) {
108
+ const prefix = keys.slice(0, length)
109
+ const start = valueAt(values, prefix)
110
+ if (typeof start !== "object" || start === null || Array.isArray(start)) {
111
+ continue
112
+ }
113
+ const empty = emptyValueFor(schema, values, toFieldName(prefix), start)
114
+ if (empty === null || empty === undefined) {
115
+ return { keys: prefix, value: empty }
116
+ }
117
+ }
118
+ return null
119
+ }
120
+
92
121
  // `extractZodDefaults` starts every string at `""` and every array at `[]`.
93
122
  // Where the schema rejects that (`z.uuid().nullable()`,
94
123
  // `z.email().optional()`), start the field at its empty value instead, so an
95
124
  // untouched optional field passes. A required one keeps `""` or `[]`.
125
+ //
126
+ // An object the defaults only partly fill (`{ table: "" }` for
127
+ // `z.object({ table: z.string(), id: z.number() }).nullable()`, a combobox's
128
+ // value) would block submit on a path no field shows: start it at its empty
129
+ // value too, when the schema lets it be absent.
96
130
  function withEmptyStartValues(
97
131
  schema: StandardSchemaLike,
98
132
  defaults: Record<string, unknown>
99
133
  ): Record<string, unknown> {
100
134
  const issues = validateSync(schema, defaults)
101
135
  let result: unknown = defaults
136
+ const emptied: string[] = []
102
137
  for (const issue of issues ?? []) {
103
138
  const name = toFieldName(issue.path)
104
139
  const keys = toPathKeys(name)
140
+ if (keys.length === 0 || emptied.some((prefix) => isUnder(name, prefix))) {
141
+ continue
142
+ }
143
+ const ancestor = emptiableAncestor(schema, result, keys)
144
+ if (ancestor) {
145
+ result = withValueAt(result, ancestor.keys, ancestor.value)
146
+ emptied.push(toFieldName(ancestor.keys))
147
+ continue
148
+ }
105
149
  const start = valueAt(defaults, keys)
106
- if (keys.length === 0 || !isTypeEmpty(start)) {
150
+ if (!isTypeEmpty(start)) {
107
151
  continue
108
152
  }
109
153
  const empty = emptyValueFor(schema, result, name, start)
@@ -0,0 +1,95 @@
1
+ "use client"
2
+
3
+ import type { AnyFormApi } from "@tanstack/react-form"
4
+ import {
5
+ createContext,
6
+ useCallback,
7
+ useContext,
8
+ useLayoutEffect,
9
+ useSyncExternalStore,
10
+ } from "react"
11
+
12
+ import { formContext } from "./contexts"
13
+
14
+ // `useForm({ disabled })`, per form. Keyed by `form.store`, as the rest of the
15
+ // per-form Zeno state is (`validation-modes.ts`), and read through
16
+ // `useSyncExternalStore`, so a toggle re-renders every field, a memoized one
17
+ // or one with no provider above it included.
18
+ const disabledOption = new WeakMap<object, boolean>()
19
+ const listeners = new WeakMap<object, Set<() => void>>()
20
+
21
+ function subscribe(key: object, listener: () => void): () => void {
22
+ let set = listeners.get(key)
23
+ if (!set) {
24
+ set = new Set()
25
+ listeners.set(key, set)
26
+ }
27
+ set.add(listener)
28
+ return () => {
29
+ set.delete(listener)
30
+ }
31
+ }
32
+
33
+ /** Apply `useForm({ disabled })` to `form`. */
34
+ function useDisabledOption(form: AnyFormApi, disabled: boolean): void {
35
+ const key = form.store
36
+ // Seeded while rendering, so the first render, the server's included,
37
+ // already has it. A change lands on commit, so a render React drops (a
38
+ // suspended transition) never reaches the fields.
39
+ if (!disabledOption.has(key)) {
40
+ disabledOption.set(key, disabled)
41
+ }
42
+ useLayoutEffect(() => {
43
+ if (disabledOption.get(key) === disabled) {
44
+ return
45
+ }
46
+ disabledOption.set(key, disabled)
47
+ for (const listener of listeners.get(key) ?? []) {
48
+ listener()
49
+ }
50
+ }, [key, disabled])
51
+ }
52
+
53
+ type FormDisabledScope = {
54
+ readonly disabled: boolean
55
+ /** The `store` of the form it disables. */
56
+ readonly key: object
57
+ }
58
+
59
+ /**
60
+ * The disabled state `FormProvider` and `<Form>` give what they wrap, for
61
+ * their own form only: a field of another form rendered inside (a quick-add
62
+ * popover) keeps that form's state.
63
+ */
64
+ const FormDisabledContext = createContext<FormDisabledScope | null>(null)
65
+
66
+ /**
67
+ * Whether the form is disabled, by `useForm({ disabled })`,
68
+ * `<FormProvider disabled>`, `<Form disabled>` or `FormDialog`'s `disabled`.
69
+ * Every kit field reads it, so a control the fieldset of a disabled dialog
70
+ * can't reach (a checkbox, a switch, a radio, a slider) turns off too.
71
+ *
72
+ * Reads the form of the nearest provider; inside a field, pass `field.form`.
73
+ * `useForm({ disabled })` applies even where no provider of the form is
74
+ * mounted.
75
+ */
76
+ function useIsFormDisabled(form?: AnyFormApi): boolean {
77
+ const scope = useContext(FormDisabledContext)
78
+ const contextForm = useContext(formContext) as AnyFormApi | null
79
+ const key = (form ?? contextForm)?.store
80
+ const subscribeToKey = useCallback(
81
+ (listener: () => void) => (key ? subscribe(key, listener) : () => null),
82
+ [key]
83
+ )
84
+ const getSnapshot = () => (key ? (disabledOption.get(key) ?? false) : false)
85
+ const byOption = useSyncExternalStore(
86
+ subscribeToKey,
87
+ getSnapshot,
88
+ getSnapshot
89
+ )
90
+ const byScope =
91
+ scope?.disabled === true && (key === undefined || scope.key === key)
92
+ return byScope || byOption
93
+ }
94
+
95
+ export { FormDisabledContext, useDisabledOption, useIsFormDisabled }
@@ -0,0 +1,11 @@
1
+ import { describe, expect, test } from "vitest"
2
+
3
+ import { valueKey } from "./item-options"
4
+
5
+ describe("valueKey", () => {
6
+ test("keeps values that print alike apart, so React keys don't collide", () => {
7
+ expect(valueKey(1)).not.toBe(valueKey("1"))
8
+ expect(valueKey(null)).not.toBe(valueKey("null"))
9
+ expect(valueKey({ id: 1 })).not.toBe(valueKey('{"id":1}'))
10
+ })
11
+ })