@zeno-lib/forms 0.0.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 (48) hide show
  1. package/package.json +47 -0
  2. package/src/addons/index.ts +2 -0
  3. package/src/addons/validation-spinner.tsx +25 -0
  4. package/src/fields/checkbox-field.tsx +80 -0
  5. package/src/fields/combobox-field.tsx +189 -0
  6. package/src/fields/date-picker-field.tsx +141 -0
  7. package/src/fields/email-field.tsx +21 -0
  8. package/src/fields/index.ts +16 -0
  9. package/src/fields/input-field.tsx +95 -0
  10. package/src/fields/number-field.tsx +33 -0
  11. package/src/fields/otp-field.tsx +111 -0
  12. package/src/fields/password-field.tsx +20 -0
  13. package/src/fields/radio-group-field.tsx +105 -0
  14. package/src/fields/reset-button.tsx +29 -0
  15. package/src/fields/select-field.tsx +113 -0
  16. package/src/fields/slider-field.tsx +103 -0
  17. package/src/fields/submit-button.tsx +31 -0
  18. package/src/fields/switch-field.tsx +80 -0
  19. package/src/fields/textarea-field.tsx +91 -0
  20. package/src/form.tsx +105 -0
  21. package/src/lib/apply-validation-error.test.ts +97 -0
  22. package/src/lib/apply-validation-error.ts +31 -0
  23. package/src/lib/aria.ts +7 -0
  24. package/src/lib/contexts.ts +9 -0
  25. package/src/lib/required-indicator.tsx +22 -0
  26. package/src/lib/schema-defaults.test.ts +195 -0
  27. package/src/lib/schema-defaults.ts +127 -0
  28. package/src/lib/schema-required.test.ts +132 -0
  29. package/src/lib/schema-required.ts +64 -0
  30. package/src/lib/use-is-invalid.ts +109 -0
  31. package/src/lib/use-unsaved-changes-warning.ts +57 -0
  32. package/src/lib/validation-error.test.ts +41 -0
  33. package/src/lib/validation-error.ts +30 -0
  34. package/src/lib/validation-logic.test.ts +261 -0
  35. package/src/lib/validation-logic.ts +192 -0
  36. package/src/lib/validation-modes.test-d.ts +9 -0
  37. package/src/lib/validation-modes.test.ts +72 -0
  38. package/src/lib/validation-modes.ts +73 -0
  39. package/src/tanstack.ts +2 -0
  40. package/src/use-app-fields.test-d.ts +112 -0
  41. package/src/use-app-fields.test.tsx +163 -0
  42. package/src/use-app-fields.tsx +272 -0
  43. package/src/use-form.cascade.test.tsx +83 -0
  44. package/src/use-form.test-d.ts +153 -0
  45. package/src/use-form.test.tsx +348 -0
  46. package/src/use-form.tsx +450 -0
  47. package/src/use-form.unsaved-warning.test.tsx +192 -0
  48. package/src/use-form.validation-modes.test.tsx +149 -0
@@ -0,0 +1,272 @@
1
+ "use client"
2
+
3
+ import type { DeepKeys } from "@tanstack/react-form"
4
+ import { type ComponentProps, useMemo } from "react"
5
+
6
+ import { CheckboxField as CheckboxFieldImpl } from "./fields/checkbox-field"
7
+ import { ComboboxField as ComboboxFieldImpl } from "./fields/combobox-field"
8
+ import { DatePickerField as DatePickerFieldImpl } from "./fields/date-picker-field"
9
+ import { EmailField as EmailFieldImpl } from "./fields/email-field"
10
+ import { InputField as InputFieldImpl } from "./fields/input-field"
11
+ import { NumberField as NumberFieldImpl } from "./fields/number-field"
12
+ import { OtpField as OtpFieldImpl } from "./fields/otp-field"
13
+ import { PasswordField as PasswordFieldImpl } from "./fields/password-field"
14
+ import { RadioGroupField as RadioGroupFieldImpl } from "./fields/radio-group-field"
15
+ import { SelectField as SelectFieldImpl } from "./fields/select-field"
16
+ import { SliderField as SliderFieldImpl } from "./fields/slider-field"
17
+ import { SwitchField as SwitchFieldImpl } from "./fields/switch-field"
18
+ import { TextAreaField as TextAreaFieldImpl } from "./fields/textarea-field"
19
+
20
+ type FormDataOf<F> = F extends { state: { values: infer V } } ? V : never
21
+
22
+ type AnyAppForm = {
23
+ AppField: unknown
24
+ state: { values: unknown }
25
+ }
26
+
27
+ type WithDefaultName<T, D extends string> =
28
+ D extends DeepKeys<T> ? { name?: DeepKeys<T> } : { name: DeepKeys<T> }
29
+
30
+ // Loose validators type for the typed wrappers — name-aware validator typing
31
+ // from TanStack Form would collide with our per-field generics. Callers pass
32
+ // standard schemas (Zod) or functions; both flow through to `<Field>` at
33
+ // runtime unchanged. The structural shape mirrors TanStack's `FieldValidators`
34
+ // slots so prop-name autocomplete still works.
35
+ type AnyFieldValidators = {
36
+ onMount?: unknown
37
+ onChange?: unknown
38
+ onChangeAsync?: unknown
39
+ onChangeAsyncDebounceMs?: number
40
+ onBlur?: unknown
41
+ onBlurAsync?: unknown
42
+ onBlurAsyncDebounceMs?: number
43
+ onSubmit?: unknown
44
+ onSubmitAsync?: unknown
45
+ onSubmitAsyncDebounceMs?: number
46
+ onDynamic?: unknown
47
+ onDynamicAsync?: unknown
48
+ }
49
+
50
+ // Mirrors TanStack's `FieldListeners`. Same loose-shape rationale as
51
+ // `AnyFieldValidators`: prop-name autocomplete with no per-field generic
52
+ // collisions. Use for cascade patterns where changing one field updates
53
+ // another (`form.setFieldValue(...)` inside `onChange`).
54
+ type AnyFieldListeners = {
55
+ onChange?: unknown
56
+ onChangeDebounceMs?: number
57
+ onBlur?: unknown
58
+ onBlurDebounceMs?: number
59
+ onMount?: unknown
60
+ onSubmit?: unknown
61
+ }
62
+
63
+ type WithValidators = {
64
+ validators?: AnyFieldValidators
65
+ listeners?: AnyFieldListeners
66
+ }
67
+
68
+ function useAppFields<TForm extends AnyAppForm>(form: TForm) {
69
+ type T = FormDataOf<TForm>
70
+
71
+ return useMemo(() => {
72
+ const Field = form.AppField as React.ComponentType<{
73
+ name: string
74
+ validators?: AnyFieldValidators
75
+ listeners?: AnyFieldListeners
76
+ children: () => React.ReactNode
77
+ }>
78
+
79
+ return {
80
+ CheckboxField<N extends DeepKeys<T>>({
81
+ name,
82
+ validators,
83
+ listeners,
84
+ ...props
85
+ }: ComponentProps<typeof CheckboxFieldImpl> & {
86
+ name: N
87
+ } & WithValidators) {
88
+ return (
89
+ <Field listeners={listeners} name={name} validators={validators}>
90
+ {() => <CheckboxFieldImpl {...props} />}
91
+ </Field>
92
+ )
93
+ },
94
+ ComboboxField<N extends DeepKeys<T>>({
95
+ name,
96
+ validators,
97
+ listeners,
98
+ ...props
99
+ }: ComponentProps<typeof ComboboxFieldImpl> & {
100
+ name: N
101
+ } & WithValidators) {
102
+ return (
103
+ <Field listeners={listeners} name={name} validators={validators}>
104
+ {() => <ComboboxFieldImpl {...props} />}
105
+ </Field>
106
+ )
107
+ },
108
+ DatePickerField<N extends DeepKeys<T>>({
109
+ name,
110
+ validators,
111
+ listeners,
112
+ ...props
113
+ }: ComponentProps<typeof DatePickerFieldImpl> & {
114
+ name: N
115
+ } & WithValidators) {
116
+ return (
117
+ <Field listeners={listeners} name={name} validators={validators}>
118
+ {() => <DatePickerFieldImpl {...props} />}
119
+ </Field>
120
+ )
121
+ },
122
+ EmailField({
123
+ name,
124
+ validators,
125
+ listeners,
126
+ ...props
127
+ }: ComponentProps<typeof EmailFieldImpl> &
128
+ WithDefaultName<T, "email"> &
129
+ WithValidators) {
130
+ return (
131
+ <Field
132
+ listeners={listeners}
133
+ name={(name ?? "email") as string}
134
+ validators={validators}
135
+ >
136
+ {() => <EmailFieldImpl {...props} />}
137
+ </Field>
138
+ )
139
+ },
140
+ InputField<N extends DeepKeys<T>>({
141
+ name,
142
+ validators,
143
+ listeners,
144
+ ...props
145
+ }: ComponentProps<typeof InputFieldImpl> & { name: N } & WithValidators) {
146
+ return (
147
+ <Field listeners={listeners} name={name} validators={validators}>
148
+ {() => <InputFieldImpl {...props} />}
149
+ </Field>
150
+ )
151
+ },
152
+ NumberField<N extends DeepKeys<T>>({
153
+ name,
154
+ validators,
155
+ listeners,
156
+ ...props
157
+ }: ComponentProps<typeof NumberFieldImpl> & {
158
+ name: N
159
+ } & WithValidators) {
160
+ return (
161
+ <Field listeners={listeners} name={name} validators={validators}>
162
+ {() => <NumberFieldImpl {...props} />}
163
+ </Field>
164
+ )
165
+ },
166
+ OtpField<N extends DeepKeys<T>>({
167
+ name,
168
+ validators,
169
+ listeners,
170
+ ...props
171
+ }: ComponentProps<typeof OtpFieldImpl> & {
172
+ name: N
173
+ } & WithValidators) {
174
+ return (
175
+ <Field listeners={listeners} name={name} validators={validators}>
176
+ {() => <OtpFieldImpl {...props} />}
177
+ </Field>
178
+ )
179
+ },
180
+ PasswordField({
181
+ name,
182
+ validators,
183
+ listeners,
184
+ ...props
185
+ }: ComponentProps<typeof PasswordFieldImpl> &
186
+ WithDefaultName<T, "password"> &
187
+ WithValidators) {
188
+ return (
189
+ <Field
190
+ listeners={listeners}
191
+ name={(name ?? "password") as string}
192
+ validators={validators}
193
+ >
194
+ {() => <PasswordFieldImpl {...props} />}
195
+ </Field>
196
+ )
197
+ },
198
+ RadioGroupField<N extends DeepKeys<T>>({
199
+ name,
200
+ validators,
201
+ listeners,
202
+ ...props
203
+ }: ComponentProps<typeof RadioGroupFieldImpl> & {
204
+ name: N
205
+ } & WithValidators) {
206
+ return (
207
+ <Field listeners={listeners} name={name} validators={validators}>
208
+ {() => <RadioGroupFieldImpl {...props} />}
209
+ </Field>
210
+ )
211
+ },
212
+ SelectField<N extends DeepKeys<T>>({
213
+ name,
214
+ validators,
215
+ listeners,
216
+ ...props
217
+ }: ComponentProps<typeof SelectFieldImpl> & {
218
+ name: N
219
+ } & WithValidators) {
220
+ return (
221
+ <Field listeners={listeners} name={name} validators={validators}>
222
+ {() => <SelectFieldImpl {...props} />}
223
+ </Field>
224
+ )
225
+ },
226
+ SliderField<N extends DeepKeys<T>>({
227
+ name,
228
+ validators,
229
+ listeners,
230
+ ...props
231
+ }: ComponentProps<typeof SliderFieldImpl> & {
232
+ name: N
233
+ } & WithValidators) {
234
+ return (
235
+ <Field listeners={listeners} name={name} validators={validators}>
236
+ {() => <SliderFieldImpl {...props} />}
237
+ </Field>
238
+ )
239
+ },
240
+ SwitchField<N extends DeepKeys<T>>({
241
+ name,
242
+ validators,
243
+ listeners,
244
+ ...props
245
+ }: ComponentProps<typeof SwitchFieldImpl> & {
246
+ name: N
247
+ } & WithValidators) {
248
+ return (
249
+ <Field listeners={listeners} name={name} validators={validators}>
250
+ {() => <SwitchFieldImpl {...props} />}
251
+ </Field>
252
+ )
253
+ },
254
+ TextAreaField<N extends DeepKeys<T>>({
255
+ name,
256
+ validators,
257
+ listeners,
258
+ ...props
259
+ }: ComponentProps<typeof TextAreaFieldImpl> & {
260
+ name: N
261
+ } & WithValidators) {
262
+ return (
263
+ <Field listeners={listeners} name={name} validators={validators}>
264
+ {() => <TextAreaFieldImpl {...props} />}
265
+ </Field>
266
+ )
267
+ },
268
+ }
269
+ }, [form])
270
+ }
271
+
272
+ export { useAppFields }
@@ -0,0 +1,83 @@
1
+ import { afterEach, describe, expect, test, vi } from "@zeno-lib/vitest"
2
+ import { cleanup, render, screen } from "@zeno-lib/vitest/testing-library"
3
+ import userEvent from "@zeno-lib/vitest/user-event"
4
+ import { z } from "zod"
5
+
6
+ import { Form, FormProvider } from "./form"
7
+ import { useForm } from "./use-form"
8
+
9
+ const DIET_LABEL = /Diet/
10
+ const SAUSAGE_LABEL = /Sausage/
11
+
12
+ afterEach(() => {
13
+ cleanup()
14
+ })
15
+
16
+ describe("Field listeners — cascade pattern", () => {
17
+ test("onChange listener can call form.setFieldValue to update a sibling field", async () => {
18
+ const user = userEvent.setup()
19
+ const schema = z.object({
20
+ diet: z.string().default("none"),
21
+ sausage: z.string().default("yes"),
22
+ })
23
+
24
+ function H() {
25
+ const form = useForm({ onSubmit: vi.fn(), schema })
26
+ const { InputField } = form
27
+ return (
28
+ <FormProvider form={form}>
29
+ <Form>
30
+ <InputField
31
+ label="Diet"
32
+ listeners={{
33
+ onChange: ({ value }: { value: string }) => {
34
+ if (value === "vegan") {
35
+ form.setFieldValue("sausage", "no")
36
+ }
37
+ },
38
+ }}
39
+ name="diet"
40
+ />
41
+ <InputField label="Sausage" name="sausage" />
42
+ </Form>
43
+ </FormProvider>
44
+ )
45
+ }
46
+ render(<H />)
47
+ const diet = screen.getByLabelText(DIET_LABEL) as HTMLInputElement
48
+ const sausage = screen.getByLabelText(SAUSAGE_LABEL) as HTMLInputElement
49
+
50
+ expect(sausage.value).toBe("yes")
51
+ await user.clear(diet)
52
+ await user.type(diet, "vegan")
53
+ expect(sausage.value).toBe("no")
54
+ })
55
+
56
+ test("listener fires per change without triggering the field's own validator", async () => {
57
+ const user = userEvent.setup()
58
+ type Values = { diet: string }
59
+ const listener = vi.fn()
60
+ function H() {
61
+ const form = useForm<Values>({
62
+ defaultValues: { diet: "" },
63
+ onSubmit: vi.fn(),
64
+ })
65
+ const { InputField } = form
66
+ return (
67
+ <FormProvider form={form}>
68
+ <Form>
69
+ <InputField
70
+ label="Diet"
71
+ listeners={{ onChange: listener }}
72
+ name="diet"
73
+ />
74
+ </Form>
75
+ </FormProvider>
76
+ )
77
+ }
78
+ render(<H />)
79
+ await user.type(screen.getByLabelText(DIET_LABEL), "abc")
80
+ // "abc" — three keystrokes → listener fired at least three times.
81
+ expect(listener.mock.calls.length).toBeGreaterThanOrEqual(3)
82
+ })
83
+ })
@@ -0,0 +1,153 @@
1
+ import { expectTypeOf, test } from "@zeno-lib/vitest"
2
+ import { z } from "zod"
3
+
4
+ import { useForm } from "./use-form"
5
+
6
+ const stringSchema = z.object({
7
+ age: z.number(),
8
+ email: z.string(),
9
+ profile: z.object({ nick: z.string() }),
10
+ tags: z.array(z.string()),
11
+ })
12
+
13
+ type Schema = z.infer<typeof stringSchema>
14
+
15
+ test("schema path: defaultValues is PartialFormData (deep partial for plain objects)", () => {
16
+ // Plain object keys are optional…
17
+ useForm({
18
+ defaultValues: { email: "x" },
19
+ onSubmit: () => undefined,
20
+ schema: stringSchema,
21
+ })
22
+ useForm({
23
+ defaultValues: {},
24
+ onSubmit: () => undefined,
25
+ schema: stringSchema,
26
+ })
27
+ // Nested object keys are partial…
28
+ useForm({
29
+ defaultValues: { profile: { nick: "x" } },
30
+ onSubmit: () => undefined,
31
+ schema: stringSchema,
32
+ })
33
+ useForm({
34
+ defaultValues: { profile: {} },
35
+ onSubmit: () => undefined,
36
+ schema: stringSchema,
37
+ })
38
+ // Arrays remain atomic (no per-element relaxation).
39
+ useForm({
40
+ defaultValues: { tags: ["a", "b"] },
41
+ onSubmit: () => undefined,
42
+ schema: stringSchema,
43
+ })
44
+ useForm({
45
+ // @ts-expect-error — array element type still enforced (number ≠ string)
46
+ defaultValues: { tags: [1] },
47
+ onSubmit: () => undefined,
48
+ schema: stringSchema,
49
+ })
50
+ })
51
+
52
+ test("schema path: validationLogic is forbidden (type never)", () => {
53
+ // Error lands on the overall call argument, not on `validationLogic`:
54
+ // `validationLogic: never` makes the whole options object incompatible.
55
+ // @ts-expect-error — validationLogic is `never` on the schema path
56
+ useForm({
57
+ onSubmit: () => undefined,
58
+ schema: stringSchema,
59
+ validationLogic: () => undefined,
60
+ })
61
+ })
62
+
63
+ test("schema path: validators rejects the native object shape", () => {
64
+ // @ts-expect-error — object form is not allowed on the schema path
65
+ useForm({
66
+ onSubmit: () => undefined,
67
+ schema: stringSchema,
68
+ validators: { onChange: () => undefined },
69
+ })
70
+ })
71
+
72
+ test("schema path: validators accepts a ValidationMode string", () => {
73
+ useForm({
74
+ onSubmit: () => undefined,
75
+ schema: stringSchema,
76
+ validators: "change",
77
+ })
78
+ useForm({
79
+ onSubmit: () => undefined,
80
+ schema: stringSchema,
81
+ validators: "blur",
82
+ })
83
+ useForm({
84
+ onSubmit: () => undefined,
85
+ schema: stringSchema,
86
+ validators: "submit",
87
+ })
88
+ useForm({
89
+ onSubmit: () => undefined,
90
+ schema: stringSchema,
91
+ validators: "blur-then-change",
92
+ })
93
+ })
94
+
95
+ test("schema path: validators rejects unknown string values", () => {
96
+ useForm({
97
+ onSubmit: () => undefined,
98
+ schema: stringSchema,
99
+ // @ts-expect-error — must be a ValidationMode
100
+ validators: "bogus",
101
+ })
102
+ })
103
+
104
+ test("schema path: onSubmit `value` is typed from the schema output", () => {
105
+ useForm({
106
+ onSubmit: ({ value }) => {
107
+ expectTypeOf(value).toEqualTypeOf<Schema>()
108
+ },
109
+ schema: stringSchema,
110
+ })
111
+ })
112
+
113
+ test("manual path: defaultValues accepts the full TFormData shape", () => {
114
+ // Note: the wrapper applies `PartialFormData<TFormData>` on both branches
115
+ // via `ZenoFormExtras`, so omitting keys typechecks here too. This test
116
+ // documents that the full shape is always accepted; the *required* shape
117
+ // contract is enforced by TanStack's runtime, not the wrapper's types.
118
+ useForm<{ a: string; b: number }>({
119
+ defaultValues: { a: "x", b: 1 },
120
+ onSubmit: () => undefined,
121
+ })
122
+ })
123
+
124
+ test("manual path: validators string requires the schema path", () => {
125
+ // Supplying a `validators: "change"` string narrows the union to the
126
+ // schema branch, which then demands `schema`. The error lands on the
127
+ // overall call (no `schema` provided), not on the `validators` line.
128
+ // @ts-expect-error — schema becomes required when validators is a string
129
+ useForm({
130
+ defaultValues: { a: "" },
131
+ onSubmit: () => undefined,
132
+ validators: "change",
133
+ })
134
+ })
135
+
136
+ test("manual path: validationLogic is accepted (native function shape)", () => {
137
+ useForm({
138
+ defaultValues: { a: "" },
139
+ onSubmit: () => undefined,
140
+ validationLogic: () => undefined,
141
+ })
142
+ })
143
+
144
+ test("returned form retains the TanStack Form API surface", () => {
145
+ const form = useForm({
146
+ onSubmit: () => undefined,
147
+ schema: stringSchema,
148
+ })
149
+ expectTypeOf(form.handleSubmit).toBeFunction()
150
+ expectTypeOf(form.reset).toBeFunction()
151
+ expectTypeOf(form.setFieldValue).toBeFunction()
152
+ expectTypeOf(form.state).not.toBeNever()
153
+ })