@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,165 @@
1
+ // The items of `ComboboxField` and `MultiSelectField`: plain values, which
2
+ // are their own label, `{ value, label }` objects, or `{ label, items }`
3
+ // groups of either.
4
+
5
+ /** An item whose value isn't its label. */
6
+ type ItemObject<V> = { value: V; label: string }
7
+
8
+ /** Items under a heading. */
9
+ type ItemGroup<T> = { label: string; items: readonly T[] }
10
+
11
+ /** An item of `items`, or of one of its groups. */
12
+ type ItemOption<T> = T extends ItemGroup<infer I> ? I : T
13
+
14
+ /** What picking an item stores: `item.value` for an object, else the item. */
15
+ type ItemValue<T> =
16
+ T extends ItemGroup<infer I>
17
+ ? ItemValue<I>
18
+ : T extends ItemObject<infer V>
19
+ ? V
20
+ : T
21
+
22
+ /** Whether an item's value is a stored one, as `isItemEqualToValue` says. */
23
+ type ValueComparator = (itemValue: unknown, value: unknown) => boolean
24
+
25
+ function isItemObject(item: unknown): item is ItemObject<unknown> {
26
+ return (
27
+ typeof item === "object" &&
28
+ item !== null &&
29
+ "value" in item &&
30
+ "label" in item
31
+ )
32
+ }
33
+
34
+ /** What Base UI takes for a group: an object with an `items` array. */
35
+ function isItemGroup(item: unknown): item is ItemGroup<unknown> {
36
+ return (
37
+ typeof item === "object" &&
38
+ item !== null &&
39
+ Array.isArray((item as { items?: unknown }).items)
40
+ )
41
+ }
42
+
43
+ function itemValue(item: unknown): unknown {
44
+ return isItemObject(item) ? item.value : item
45
+ }
46
+
47
+ function itemLabel(item: unknown): string {
48
+ return isItemObject(item) ? item.label : String(item)
49
+ }
50
+
51
+ /** A React key per value: `1` and `"1"` get two, an object gets its JSON. */
52
+ function valueKey(value: unknown): string {
53
+ if (typeof value !== "object" || value === null) {
54
+ return `${typeof value}:${String(value)}`
55
+ }
56
+ try {
57
+ return `object:${JSON.stringify(value)}`
58
+ } catch {
59
+ return `object:${String(value)}`
60
+ }
61
+ }
62
+
63
+ type ItemList<T> = {
64
+ /**
65
+ * `items` as Base UI takes them. It tells a grouped list by its first entry
66
+ * alone, so once there is a group, each run of plain items beside one goes
67
+ * under a group with no heading (`label: ""`).
68
+ */
69
+ readonly items: readonly T[]
70
+ /** Every option, the grouped ones included, in order. */
71
+ readonly options: readonly ItemOption<T>[]
72
+ /** The first option of each `valueKey`, so finding a value is no scan. */
73
+ readonly byKey: ReadonlyMap<string, ItemOption<T>>
74
+ }
75
+
76
+ function toItemList<T>(items: readonly T[]): ItemList<T> {
77
+ const hasGroup = items.some(isItemGroup)
78
+ const options: ItemOption<T>[] = []
79
+ const groups: ItemGroup<unknown>[] = []
80
+ let run: unknown[] | undefined
81
+ for (const item of items) {
82
+ if (isItemGroup(item)) {
83
+ for (const option of item.items) {
84
+ options.push(option as ItemOption<T>)
85
+ }
86
+ groups.push(item)
87
+ run = undefined
88
+ } else {
89
+ options.push(item as ItemOption<T>)
90
+ if (hasGroup && !run) {
91
+ run = []
92
+ groups.push({ items: run, label: "" })
93
+ }
94
+ run?.push(item)
95
+ }
96
+ }
97
+ const byKey = new Map<string, ItemOption<T>>()
98
+ for (const option of options) {
99
+ const key = valueKey(itemValue(option))
100
+ if (!byKey.has(key)) {
101
+ byKey.set(key, option)
102
+ }
103
+ }
104
+ return {
105
+ byKey,
106
+ items: hasGroup ? (groups as unknown as readonly T[]) : items,
107
+ options,
108
+ }
109
+ }
110
+
111
+ /**
112
+ * `Object.is`, then `isItemEqualToValue`, which never sees an empty value.
113
+ */
114
+ function isSameValue(
115
+ a: unknown,
116
+ b: unknown,
117
+ isItemEqualToValue: ValueComparator | undefined
118
+ ): boolean {
119
+ if (Object.is(a, b)) {
120
+ return true
121
+ }
122
+ if (a === null || a === undefined || b === null || b === undefined) {
123
+ return false
124
+ }
125
+ return isItemEqualToValue?.(a, b) ?? false
126
+ }
127
+
128
+ /** The option whose value is `value`, if `items` has it. */
129
+ function findOption<T>(
130
+ list: ItemList<T>,
131
+ value: unknown,
132
+ isItemEqualToValue: ValueComparator | undefined
133
+ ): ItemOption<T> | undefined {
134
+ const hit = list.byKey.get(valueKey(value))
135
+ if (
136
+ hit !== undefined &&
137
+ isSameValue(itemValue(hit), value, isItemEqualToValue)
138
+ ) {
139
+ return hit
140
+ }
141
+ // A comparator can match values whose keys differ (`{ id }` against
142
+ // `{ id, name }`).
143
+ return list.options.find((option) =>
144
+ isSameValue(itemValue(option), value, isItemEqualToValue)
145
+ )
146
+ }
147
+
148
+ export type {
149
+ ItemGroup,
150
+ ItemList,
151
+ ItemObject,
152
+ ItemOption,
153
+ ItemValue,
154
+ ValueComparator,
155
+ }
156
+ export {
157
+ findOption,
158
+ isItemGroup,
159
+ isItemObject,
160
+ isSameValue,
161
+ itemLabel,
162
+ itemValue,
163
+ toItemList,
164
+ valueKey,
165
+ }
@@ -87,6 +87,15 @@ describe("getRequiredPaths", () => {
87
87
  expect(required.has("profile.bio")).toBe(false)
88
88
  })
89
89
 
90
+ test("a nullable object is not required, though its own fields are", () => {
91
+ const schema = z.object({
92
+ payer: z.object({ id: z.number(), kind: z.string().min(1) }).nullable(),
93
+ })
94
+ const required = getRequiredPaths(schema)
95
+ expect(required.has("payer")).toBe(false)
96
+ expect(required.has("payer.kind")).toBe(true)
97
+ })
98
+
90
99
  test("optional nested object is not descended into", () => {
91
100
  const schema = z.object({
92
101
  profile: z.object({ email: z.email() }).optional(),
@@ -221,6 +221,38 @@ function settleEmptied(
221
221
  return { descended, next }
222
222
  }
223
223
 
224
+ // The probe descends into a missing object with `{}`, so it never tries
225
+ // `null` there. Try it on the side: a `.nullable()` object accepts it, so it
226
+ // isn't required, though its own fields still are once it is set.
227
+ function clearNullableObjects(
228
+ schema: StandardSchemaLike,
229
+ filled: readonly ProbeFill[],
230
+ probe: Record<PropertyKey, unknown>,
231
+ required: Set<string>,
232
+ cleared: Set<string>
233
+ ): void {
234
+ for (const fill of filled) {
235
+ if (isEmptyProbe(fill.value) || cleared.has(fill.path)) {
236
+ continue
237
+ }
238
+ fillAt(probe, fill.keys, null, true)
239
+ const issues = validateSync(schema, probe)
240
+ fillAt(probe, fill.keys, fill.value, true)
241
+ const rejected = issues?.some((issue) => {
242
+ const path = joinPath(issue.path?.map(pathKey) ?? [])
243
+ return (
244
+ path === fill.path ||
245
+ path.startsWith(`${fill.path}.`) ||
246
+ path.startsWith(`${fill.path}[`)
247
+ )
248
+ })
249
+ if (issues && !rejected) {
250
+ required.delete(fill.path)
251
+ cleared.add(fill.path)
252
+ }
253
+ }
254
+ }
255
+
224
256
  function getRequiredPaths(schema: StandardSchemaLike): Set<string> {
225
257
  const required = new Set<string>()
226
258
  const cleared = new Set<string>()
@@ -234,6 +266,7 @@ function getRequiredPaths(schema: StandardSchemaLike): Set<string> {
234
266
  return required
235
267
  }
236
268
  const { failing, filled } = recordPass(issues, probe, required, cleared)
269
+ clearNullableObjects(schema, filled, probe, required, cleared)
237
270
  const settled = settleEmptied(emptied, failing, probe, required, cleared)
238
271
  emptied = [
239
272
  ...filled.filter(({ value }) => isEmptyProbe(value)),
@@ -6,6 +6,7 @@ import { type ReactNode, useCallback, useId } from "react"
6
6
  import { describedBy } from "./aria"
7
7
  import { useFieldContext } from "./contexts"
8
8
  import { emptyValueFor } from "./empty-value"
9
+ import { useIsFormDisabled } from "./form-disabled"
9
10
  import {
10
11
  useHideFieldErrors,
11
12
  useIsFieldRequired,
@@ -20,6 +21,10 @@ type UseFieldControlOptions = {
20
21
  * it, and by the error while one shows.
21
22
  */
22
23
  description?: ReactNode
24
+ /**
25
+ * Disable the control. A disabled form (`useForm({ disabled })`,
26
+ * `<Form disabled>`, `FormDialog`'s `disabled`) disables it either way.
27
+ */
23
28
  disabled?: boolean
24
29
  /**
25
30
  * The label the field renders, if any. The control gets `aria-labelledby`,
@@ -40,6 +45,7 @@ type FieldControlProps<TValue = unknown> = {
40
45
  "aria-invalid": true | undefined
41
46
  "aria-labelledby": string | undefined
42
47
  "aria-required": true | undefined
48
+ /** The field's own `disabled`, or the form's. */
43
49
  disabled: boolean | undefined
44
50
  id: string
45
51
  name: string
@@ -71,6 +77,8 @@ type FieldControl<TValue = unknown> = {
71
77
  errors: AnyFieldMeta["errors"]
72
78
  /** The control's id, for the label's `htmlFor`. */
73
79
  id: string
80
+ /** The field is disabled, by its own `disabled` or the form's. */
81
+ isDisabled: boolean
74
82
  isInvalid: boolean
75
83
  /** The field requires a value, by the schema or `required`. */
76
84
  isRequired: boolean
@@ -125,6 +133,7 @@ function useFieldControl<TValue = unknown>({
125
133
  const schemaRequired = useIsFieldRequired(field)
126
134
  const schemaRequiresValue = useIsFieldRequiredBySchema(field)
127
135
  const isRequired = required ?? schemaRequiresValue
136
+ const isDisabled = useIsFormDisabled(field.form) || Boolean(disabled)
128
137
  // `field` is rebuilt on every value change; these members aren't, so a
129
138
  // control that memoizes on `onChange` doesn't see a new one each render.
130
139
  const { form, handleBlur, handleChange, name } = field
@@ -158,7 +167,7 @@ function useFieldControl<TValue = unknown>({
158
167
  "aria-invalid": isInvalid || undefined,
159
168
  "aria-labelledby": labelId,
160
169
  "aria-required": isRequired || undefined,
161
- disabled,
170
+ disabled: isDisabled,
162
171
  id,
163
172
  name,
164
173
  onBlur: handleBlur,
@@ -169,6 +178,7 @@ function useFieldControl<TValue = unknown>({
169
178
  errorId,
170
179
  errors: showError ? field.state.meta.errors : [],
171
180
  id,
181
+ isDisabled,
172
182
  isInvalid,
173
183
  isRequired,
174
184
  labelId,
@@ -0,0 +1,76 @@
1
+ import { expectTypeOf, test } from "vitest"
2
+ import { z } from "zod"
3
+ import {
4
+ useAppFields,
5
+ useAppForm,
6
+ useForm,
7
+ withFieldGroup,
8
+ withForm,
9
+ } from "./create-form"
10
+
11
+ const schema = z.object({
12
+ address: z.object({ city: z.string(), street: z.string() }),
13
+ email: z.string(),
14
+ })
15
+ type Values = z.infer<typeof schema>
16
+ const VALUES: Values = { address: { city: "", street: "" }, email: "" }
17
+
18
+ // Typed by `defaultValues`, as in TanStack; `render` gets the kit's fields
19
+ // through `useAppFields`.
20
+ const EmailSection = withForm({
21
+ defaultValues: VALUES,
22
+ props: { title: "" },
23
+ render({ form, title }) {
24
+ expectTypeOf(title).toEqualTypeOf<string>()
25
+ expectTypeOf(form.state.values).toEqualTypeOf<Values>()
26
+ // biome-ignore lint/correctness/useHookAtTopLevel: `withForm` runs `render` inside a component; a .ts file can't return JSX to show it.
27
+ const { InputField } = useAppFields(form)
28
+ InputField({ name: "address.city" })
29
+ // @ts-expect-error — not a path of the form
30
+ InputField({ name: "phone" })
31
+ return null
32
+ },
33
+ })
34
+
35
+ const AddressGroup = withFieldGroup({
36
+ defaultValues: VALUES.address,
37
+ render: () => null,
38
+ })
39
+
40
+ test("withForm takes the form useForm returns, with a schema", () => {
41
+ const form = useForm({ onSubmit: () => undefined, schema })
42
+ EmailSection({ form, title: "Email" })
43
+ AddressGroup({ fields: "address", form })
44
+ })
45
+
46
+ test("withForm takes the form useForm returns, with a validation mode", () => {
47
+ const form = useForm({
48
+ onSubmit: () => undefined,
49
+ schema,
50
+ validators: "change",
51
+ })
52
+ EmailSection({ form, title: "Email" })
53
+ })
54
+
55
+ test("withForm takes the form useForm returns, without a schema", () => {
56
+ const form = useForm<Values>({
57
+ defaultValues: VALUES,
58
+ onSubmit: () => undefined,
59
+ })
60
+ EmailSection({ form, title: "Email" })
61
+ AddressGroup({ fields: "address", form })
62
+ })
63
+
64
+ test("withForm still takes the form useAppForm returns", () => {
65
+ const form = useAppForm({ defaultValues: VALUES, onSubmit: () => undefined })
66
+ EmailSection({ form, title: "Email" })
67
+ })
68
+
69
+ test("withForm rejects a form with other values", () => {
70
+ const form = useForm({
71
+ onSubmit: () => undefined,
72
+ schema: z.object({ name: z.string() }),
73
+ })
74
+ // @ts-expect-error — the section needs `email` and `address`
75
+ EmailSection({ form, title: "Email" })
76
+ })
@@ -0,0 +1,60 @@
1
+ import { cleanup, render, screen } from "@zeno-lib/test/testing-library"
2
+ import userEvent from "@zeno-lib/test/user-event"
3
+ import { afterEach, expect, test, vi } from "vitest"
4
+ import { z } from "zod"
5
+ import {
6
+ Form,
7
+ FormProvider,
8
+ useAppFields,
9
+ useForm,
10
+ withForm,
11
+ } from "./create-form"
12
+
13
+ const CITY = /City/
14
+
15
+ afterEach(() => {
16
+ cleanup()
17
+ })
18
+
19
+ const schema = z.object({
20
+ address: z.object({ city: z.string().min(1, "Enter a city") }),
21
+ name: z.string(),
22
+ })
23
+ type Profile = z.infer<typeof schema>
24
+
25
+ const AddressSection = withForm({
26
+ defaultValues: {} as Profile,
27
+ render: ({ form }) => {
28
+ const { InputField } = useAppFields(form)
29
+ return <InputField label="City" name="address.city" />
30
+ },
31
+ })
32
+
33
+ test("a withForm section edits and validates the parent's form", async () => {
34
+ const user = userEvent.setup()
35
+ const onSubmit = vi.fn()
36
+ function Profile() {
37
+ const form = useForm({ onSubmit: ({ value }) => onSubmit(value), schema })
38
+ const { SubmitButton } = form
39
+ return (
40
+ <FormProvider form={form}>
41
+ <Form>
42
+ <AddressSection form={form} />
43
+ <SubmitButton>Save</SubmitButton>
44
+ </Form>
45
+ </FormProvider>
46
+ )
47
+ }
48
+ render(<Profile />)
49
+
50
+ await user.click(screen.getByRole("button", { name: "Save" }))
51
+ expect(await screen.findByText("Enter a city")).toBeTruthy()
52
+ expect(document.activeElement).toBe(screen.getByLabelText(CITY))
53
+
54
+ await user.type(screen.getByLabelText(CITY), "Basel")
55
+ await user.click(screen.getByRole("button", { name: "Save" }))
56
+ expect(onSubmit).toHaveBeenCalledWith({
57
+ address: { city: "Basel" },
58
+ name: "",
59
+ })
60
+ })