@zeno-lib/forms 0.0.1 → 0.2.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 (63) hide show
  1. package/package.json +23 -11
  2. package/src/addons/validation-spinner.tsx +3 -4
  3. package/src/create-form.tsx +59 -0
  4. package/src/create-zeno-form.tsx +531 -0
  5. package/src/fields/checkbox-field.tsx +15 -12
  6. package/src/fields/checkbox-group-field.tsx +160 -0
  7. package/src/fields/combobox-field.tsx +13 -14
  8. package/src/fields/date-picker-field.tsx +18 -15
  9. package/src/fields/index.ts +5 -0
  10. package/src/fields/input-field.tsx +12 -13
  11. package/src/fields/money-field.tsx +123 -0
  12. package/src/fields/multi-select-field.tsx +193 -0
  13. package/src/fields/number-field.tsx +1 -2
  14. package/src/fields/otp-field.tsx +15 -12
  15. package/src/fields/percentage-field.tsx +73 -0
  16. package/src/fields/radio-group-field.tsx +12 -13
  17. package/src/fields/reset-button.tsx +2 -3
  18. package/src/fields/select-field.tsx +12 -13
  19. package/src/fields/slider-field.tsx +11 -12
  20. package/src/fields/submit-button.tsx +3 -4
  21. package/src/fields/switch-field.tsx +15 -12
  22. package/src/fields/textarea-field.tsx +12 -13
  23. package/src/fields/year-field.tsx +56 -0
  24. package/src/form-dialog.test.tsx +161 -0
  25. package/src/form-dialog.tsx +253 -0
  26. package/src/form-element.tsx +51 -0
  27. package/src/formatted-number-fields.test.tsx +236 -0
  28. package/src/index.ts +27 -0
  29. package/src/lib/action-result.ts +61 -0
  30. package/src/lib/apply-validation-error.test.ts +16 -6
  31. package/src/lib/apply-validation-error.ts +38 -6
  32. package/src/lib/formatted-number.test.ts +157 -0
  33. package/src/lib/formatted-number.ts +307 -0
  34. package/src/lib/required-indicator.tsx +1 -1
  35. package/src/lib/schema-defaults.test.ts +1 -2
  36. package/src/lib/schema-required.test.ts +98 -6
  37. package/src/lib/schema-required.ts +126 -22
  38. package/src/lib/submit-action.test-d.ts +83 -0
  39. package/src/lib/submit-action.ts +178 -0
  40. package/src/lib/use-form-dialog.ts +184 -0
  41. package/src/lib/use-formatted-number.ts +235 -0
  42. package/src/lib/use-is-invalid.ts +4 -4
  43. package/src/lib/use-rebased-default-values.ts +42 -0
  44. package/src/lib/use-unsaved-changes-warning.ts +3 -3
  45. package/src/lib/validation-error.test.ts +1 -1
  46. package/src/lib/validation-logic.test.ts +1 -1
  47. package/src/lib/validation-modes.test-d.ts +1 -1
  48. package/src/lib/validation-modes.test.ts +12 -2
  49. package/src/lib/validation-modes.ts +3 -1
  50. package/src/multi-value-fields.test.tsx +116 -0
  51. package/src/submit-action.test.tsx +421 -0
  52. package/src/use-app-fields.test-d.ts +8 -25
  53. package/src/use-app-fields.test.tsx +8 -32
  54. package/src/use-form.cascade.test.tsx +4 -6
  55. package/src/use-form.focus-invalid.test.tsx +198 -0
  56. package/src/use-form.reset-defaults.test.tsx +125 -0
  57. package/src/use-form.test-d.ts +2 -3
  58. package/src/use-form.test.tsx +12 -14
  59. package/src/use-form.unsaved-warning.test.tsx +4 -13
  60. package/src/use-form.validation-modes.test.tsx +10 -8
  61. package/src/form.tsx +0 -105
  62. package/src/use-app-fields.tsx +0 -272
  63. package/src/use-form.tsx +0 -450
@@ -0,0 +1,235 @@
1
+ "use client"
2
+
3
+ import {
4
+ type ChangeEvent,
5
+ type ClipboardEvent,
6
+ useLayoutEffect,
7
+ useReducer,
8
+ useRef,
9
+ useState,
10
+ } from "react"
11
+
12
+ import {
13
+ formatNumber,
14
+ mapCaret,
15
+ parseLocaleNumber,
16
+ parseTypedNumber,
17
+ roundTo,
18
+ } from "./formatted-number"
19
+
20
+ type UseFormattedNumberOptions = {
21
+ /** The stored value. `null`/`undefined` renders an empty input. */
22
+ value: number | null | undefined
23
+ /** Called with the parsed value when the user types, pastes, or blurs. */
24
+ onValueChange: (value: number | null) => void
25
+ /** Called after the blur re-format (wire the field's `handleBlur` here). */
26
+ onBlur?: () => void
27
+ /** BCP 47 locale for separators. Defaults to the runtime locale. */
28
+ locale?: string
29
+ /** Digits allowed after the decimal separator. Defaults to `2`. */
30
+ maximumFractionDigits?: number
31
+ /**
32
+ * Pad a non-integer value to `maximumFractionDigits` on blur (`12.5` →
33
+ * `12.50`). Integers stay bare (`12`). Defaults to `false`.
34
+ */
35
+ padFraction?: boolean
36
+ /** Insert thousands separators. Defaults to `true`. */
37
+ useGrouping?: boolean
38
+ /** Accept a leading minus. Defaults to `true`. */
39
+ allowNegative?: boolean
40
+ /** Drop integer digits beyond this count while typing. */
41
+ maxIntegerDigits?: number
42
+ /** Clamp the stored value on blur. */
43
+ min?: number
44
+ /** Clamp the stored value on blur. */
45
+ max?: number
46
+ /**
47
+ * Map the stored value to the number shown in the input, e.g.
48
+ * `(v) => v * 100` to edit a `0–1` fraction as a percentage. Pair with
49
+ * `fromDisplay`.
50
+ */
51
+ toDisplay?: (value: number) => number
52
+ /** Inverse of `toDisplay`. */
53
+ fromDisplay?: (display: number) => number
54
+ }
55
+
56
+ type FormattedNumberInputProps = {
57
+ inputMode: "decimal" | "numeric"
58
+ onBlur: () => void
59
+ onChange: (event: ChangeEvent<HTMLInputElement>) => void
60
+ onPaste: (event: ClipboardEvent<HTMLInputElement>) => void
61
+ type: "text"
62
+ value: string
63
+ }
64
+
65
+ const identity = (value: number) => value
66
+
67
+ function clamp(value: number, min?: number, max?: number): number {
68
+ let next = value
69
+ if (min !== undefined && next < min) {
70
+ next = min
71
+ }
72
+ if (max !== undefined && next > max) {
73
+ next = max
74
+ }
75
+ return next
76
+ }
77
+
78
+ /**
79
+ * Headless, locale-aware formatted number input.
80
+ *
81
+ * Keeps the input's text in local state so the user can type freely
82
+ * (`"1’2"`, `"12."`), re-inserting group separators as they type while
83
+ * holding the caret in place, and parses the text into a `number | null`
84
+ * for the form. Pasted text is parsed leniently (`"CHF 1'234.50"`,
85
+ * `"1.234,50"`). On blur the value is clamped to `min`/`max` and the text
86
+ * re-formatted. External value changes (e.g. `form.reset`) re-sync the text.
87
+ *
88
+ * Returns props to spread on an `<input>`; UI-free, so any input component
89
+ * can use it.
90
+ */
91
+ function useFormattedNumber({
92
+ value,
93
+ onValueChange,
94
+ onBlur,
95
+ locale,
96
+ maximumFractionDigits = 2,
97
+ padFraction = false,
98
+ useGrouping = true,
99
+ allowNegative = true,
100
+ maxIntegerDigits,
101
+ min,
102
+ max,
103
+ toDisplay = identity,
104
+ fromDisplay = identity,
105
+ }: UseFormattedNumberOptions): FormattedNumberInputProps {
106
+ const current = value ?? null
107
+
108
+ const display = (stored: number | null): string => {
109
+ if (stored === null) {
110
+ return ""
111
+ }
112
+ const shown = roundTo(toDisplay(stored), maximumFractionDigits)
113
+ return formatNumber(shown, {
114
+ locale,
115
+ maximumFractionDigits,
116
+ minimumFractionDigits:
117
+ padFraction && !Number.isInteger(shown) ? maximumFractionDigits : 0,
118
+ useGrouping,
119
+ })
120
+ }
121
+
122
+ const [text, setText] = useState(() => display(current))
123
+ // The value the text was last derived from or parsed into. When the prop
124
+ // drifts from it, something outside the input changed the value.
125
+ const [synced, setSynced] = useState<number | null>(current)
126
+ if (!Object.is(current, synced)) {
127
+ setSynced(current)
128
+ setText(display(current))
129
+ }
130
+
131
+ // Re-render even when the re-formatted text is unchanged (a rejected
132
+ // keystroke), so the layout effect can put the caret back after React
133
+ // restores the controlled value.
134
+ const [, forceRender] = useReducer((n: number) => n + 1, 0)
135
+ const pendingCaret = useRef<{ input: HTMLInputElement; at: number } | null>(
136
+ null
137
+ )
138
+ useLayoutEffect(() => {
139
+ const pending = pendingCaret.current
140
+ if (!pending) {
141
+ return
142
+ }
143
+ pendingCaret.current = null
144
+ if (pending.input.ownerDocument.activeElement === pending.input) {
145
+ pending.input.setSelectionRange(pending.at, pending.at)
146
+ }
147
+ })
148
+
149
+ const fromDisplayRounded = (shown: number) =>
150
+ fromDisplay === identity
151
+ ? shown
152
+ : roundTo(fromDisplay(shown), maximumFractionDigits + 4)
153
+
154
+ const commit = (nextText: string, nextValue: number | null) => {
155
+ setText(nextText)
156
+ setSynced(nextValue)
157
+ if (!Object.is(nextValue, current)) {
158
+ onValueChange(nextValue)
159
+ }
160
+ }
161
+
162
+ const onChange = (event: ChangeEvent<HTMLInputElement>) => {
163
+ const input = event.target
164
+ const raw = input.value
165
+ const typed = parseTypedNumber(raw, {
166
+ allowNegative,
167
+ locale,
168
+ maxIntegerDigits,
169
+ maximumFractionDigits,
170
+ useGrouping,
171
+ })
172
+ const caret = input.selectionStart ?? raw.length
173
+ pendingCaret.current = {
174
+ at: mapCaret(raw, caret, typed.text, locale),
175
+ input,
176
+ }
177
+ commit(
178
+ typed.text,
179
+ typed.value === null ? null : fromDisplayRounded(typed.value)
180
+ )
181
+ forceRender()
182
+ }
183
+
184
+ const onPaste = (event: ClipboardEvent<HTMLInputElement>) => {
185
+ const input = event.currentTarget
186
+ const replacesAll =
187
+ input.value === "" ||
188
+ (input.selectionStart === 0 && input.selectionEnd === input.value.length)
189
+ if (!replacesAll) {
190
+ return // partial paste: let the change handler re-format the result
191
+ }
192
+ const parsed = parseLocaleNumber(
193
+ event.clipboardData.getData("text"),
194
+ locale
195
+ )
196
+ if (parsed === null) {
197
+ return
198
+ }
199
+ const shown = roundTo(
200
+ allowNegative ? parsed : Math.abs(parsed),
201
+ maximumFractionDigits
202
+ )
203
+ if (
204
+ maxIntegerDigits !== undefined &&
205
+ Math.abs(Math.trunc(shown)) >= 10 ** maxIntegerDigits
206
+ ) {
207
+ return // too long: the change handler truncates the pasted digits
208
+ }
209
+ event.preventDefault()
210
+ const stored = fromDisplayRounded(shown)
211
+ commit(display(stored), stored)
212
+ }
213
+
214
+ const handleBlur = () => {
215
+ if (current === null) {
216
+ setText("")
217
+ } else {
218
+ const clamped = clamp(current, min, max)
219
+ commit(display(clamped), clamped)
220
+ }
221
+ onBlur?.()
222
+ }
223
+
224
+ return {
225
+ inputMode: maximumFractionDigits > 0 ? "decimal" : "numeric",
226
+ onBlur: handleBlur,
227
+ onChange,
228
+ onPaste,
229
+ type: "text",
230
+ value: text,
231
+ }
232
+ }
233
+
234
+ export type { FormattedNumberInputProps, UseFormattedNumberOptions }
235
+ export { useFormattedNumber }
@@ -1,6 +1,6 @@
1
1
  "use client"
2
2
 
3
- import { type AnyFieldApi, useStore } from "@tanstack/react-form"
3
+ import { type AnyFieldApi, useSelector } from "@tanstack/react-form"
4
4
 
5
5
  import {
6
6
  getFormHideFieldErrors,
@@ -15,7 +15,7 @@ const FIELD_LEVEL_CAUSES = ["onChange", "onBlur", "onSubmit"] as const
15
15
 
16
16
  // Has the user explicitly opted into a `validators={{ onChange | onBlur |
17
17
  // onSubmit }}` cause on this field? If yes, we honour the cause directly —
18
- // the form's `validation` mode does not gate per-field validators (a user
18
+ // the form's `validators` mode does not gate per-field validators (a user
19
19
  // who wrote `onChange` expects live feedback, even if the form is in
20
20
  // `blur-then-change` mode).
21
21
  function hasFieldLevelError(
@@ -46,7 +46,7 @@ function modeAllowsDisplay(
46
46
  }
47
47
 
48
48
  // Returns the "should we display errors?" gate for a field. The gate matches
49
- // the form's `validation` mode (set via `useForm({ validation })`):
49
+ // the form's `validators` mode (set via `useForm({ validators })`):
50
50
  //
51
51
  // - `change` → show as soon as the user has typed (live)
52
52
  // - `blur` → show after first blur
@@ -60,7 +60,7 @@ function modeAllowsDisplay(
60
60
  // `validators={{ onChange }}` shows live (after first keystroke) regardless
61
61
  // of the form's gating, because the user explicitly opted into that cause.
62
62
  function useIsInvalid(field: AnyFieldApi): boolean {
63
- const wasSubmitted = useStore(
63
+ const wasSubmitted = useSelector(
64
64
  field.form.store,
65
65
  (state) => state.submissionAttempts > 0
66
66
  )
@@ -0,0 +1,42 @@
1
+ "use client"
2
+
3
+ import { type AnyFormApi, evaluate } from "@tanstack/react-form"
4
+ import { useCallback, useRef } from "react"
5
+
6
+ // TanStack's `useForm` calls `formApi.update(options)` after every render. While
7
+ // the form is untouched, `update` re-applies `options.defaultValues` whenever it
8
+ // deep-differs from the form's live defaults. `formApi.reset(values)` rebases
9
+ // those live defaults, so a caller that keeps passing the same `defaultValues`
10
+ // literal would see the reset undone on the next render (the classic "reset to
11
+ // the saved record after submit" flow).
12
+ //
13
+ // This hook forwards the caller's defaults only when *they* change (deep
14
+ // compare, the same `evaluate` TanStack's `update` uses). Otherwise it forwards
15
+ // the form's live defaults back, so `update` sees no change and a reset sticks.
16
+ // The live FormApi instance comes from the form-level `listeners.onMount`
17
+ // callback: the object `useForm` returns is a spread snapshot whose `options`
18
+ // never updates.
19
+ function useRebasedDefaultValues<T>(callerDefaults: T): {
20
+ defaultValues: T
21
+ onMount: (props: { formApi: AnyFormApi }) => void
22
+ } {
23
+ const liveForm = useRef<AnyFormApi | null>(null)
24
+ const lastCallerDefaults = useRef(callerDefaults)
25
+
26
+ let defaultValues = callerDefaults
27
+ if (evaluate(lastCallerDefaults.current, callerDefaults)) {
28
+ if (liveForm.current) {
29
+ defaultValues = liveForm.current.options.defaultValues as T
30
+ }
31
+ } else {
32
+ lastCallerDefaults.current = callerDefaults
33
+ }
34
+
35
+ const onMount = useCallback((props: { formApi: AnyFormApi }) => {
36
+ liveForm.current = props.formApi
37
+ }, [])
38
+
39
+ return { defaultValues, onMount }
40
+ }
41
+
42
+ export { useRebasedDefaultValues }
@@ -1,6 +1,6 @@
1
1
  "use client"
2
2
 
3
- import { type AnyFormApi, useStore } from "@tanstack/react-form"
3
+ import { type AnyFormApi, useSelector } from "@tanstack/react-form"
4
4
  import { useEffect } from "react"
5
5
 
6
6
  type UnsavedChangesMode = "if-changed" | "if-touched"
@@ -34,10 +34,10 @@ function useUnsavedChangesWarning(
34
34
  } else {
35
35
  mode = enabled
36
36
  }
37
- const dirty = useStore(form.store, (state) =>
37
+ const dirty = useSelector(form.store, (state) =>
38
38
  mode === "if-touched" ? state.isDirty : !state.isDefaultValue
39
39
  )
40
- const isSubmitting = useStore(form.store, (state) => state.isSubmitting)
40
+ const isSubmitting = useSelector(form.store, (state) => state.isSubmitting)
41
41
  const active = mode !== null && dirty && !isSubmitting
42
42
 
43
43
  useEffect(() => {
@@ -1,4 +1,4 @@
1
- import { describe, expect, test } from "@zeno-lib/vitest"
1
+ import { describe, expect, test } from "vitest"
2
2
 
3
3
  import { ValidationError } from "./validation-error"
4
4
 
@@ -1,5 +1,5 @@
1
1
  import type { AnyFormApi, ValidationLogicFn } from "@tanstack/react-form"
2
- import { describe, expect, test, vi } from "@zeno-lib/vitest"
2
+ import { describe, expect, test, vi } from "vitest"
3
3
 
4
4
  import { blurThenChangeLogic } from "./validation-logic"
5
5
 
@@ -1,4 +1,4 @@
1
- import { expectTypeOf, test } from "@zeno-lib/vitest"
1
+ import { expectTypeOf, test } from "vitest"
2
2
 
3
3
  import type { ValidationMode } from "./validation-modes"
4
4
 
@@ -1,6 +1,5 @@
1
1
  import type { AnyFormApi } from "@tanstack/react-form"
2
- import { describe, expect, test } from "@zeno-lib/vitest"
3
-
2
+ import { describe, expect, test } from "vitest"
4
3
  import {
5
4
  DEFAULT_VALIDATION_MODE,
6
5
  getFormHideFieldErrors,
@@ -50,6 +49,17 @@ describe("validation-modes WeakMap state", () => {
50
49
  expect(isFieldRequired(form, "email")).toBe(false)
51
50
  })
52
51
 
52
+ test("isFieldRequired matches array rows by TanStack field name", () => {
53
+ const form = makeFormStub()
54
+ setFormZenoState(form, {
55
+ requiredFields: new Set(["members[0].name"]),
56
+ requiredIndicator: true,
57
+ })
58
+ expect(isFieldRequired(form, "members[0].name")).toBe(true)
59
+ expect(isFieldRequired(form, "members[4].name")).toBe(true)
60
+ expect(isFieldRequired(form, "members[4].note")).toBe(false)
61
+ })
62
+
53
63
  test("two forms keyed on different store references are independent", () => {
54
64
  const a = makeFormStub()
55
65
  const b = makeFormStub()
@@ -2,6 +2,8 @@
2
2
 
3
3
  import type { AnyFormApi } from "@tanstack/react-form"
4
4
 
5
+ import { toRequiredPathKey } from "./schema-required"
6
+
5
7
  type ValidationMode = "change" | "blur" | "submit" | "blur-then-change"
6
8
 
7
9
  const DEFAULT_VALIDATION_MODE: ValidationMode = "blur-then-change"
@@ -60,7 +62,7 @@ function isFieldRequired(form: AnyFormApi, name: string): boolean {
60
62
  if (!state.requiredIndicator) {
61
63
  return false
62
64
  }
63
- return state.requiredFields.has(name)
65
+ return state.requiredFields.has(toRequiredPathKey(name))
64
66
  }
65
67
 
66
68
  export {
@@ -0,0 +1,116 @@
1
+ import { cleanup, render, screen } from "@zeno-lib/test/testing-library"
2
+ import userEvent from "@zeno-lib/test/user-event"
3
+ import { afterEach, describe, expect, test, vi } from "vitest"
4
+ import { Form, FormProvider, useForm } from "./create-form"
5
+
6
+ const TAGS = /Tags/
7
+ const CLEAR = /Clear/
8
+
9
+ afterEach(() => {
10
+ cleanup()
11
+ })
12
+
13
+ type Box<T> = { current: T | undefined }
14
+
15
+ describe("MultiSelectField", () => {
16
+ const ITEMS = [
17
+ { label: "One", value: 1 },
18
+ { label: "Two", value: 2 },
19
+ { label: "Three", value: 3 },
20
+ ]
21
+ const DEFAULTS = { ids: [3] as number[] }
22
+
23
+ function Harness({ captured }: { captured: Box<number[]> }) {
24
+ const form = useForm({ defaultValues: DEFAULTS, onSubmit: vi.fn() })
25
+ const { MultiSelectField, Subscribe } = form
26
+ return (
27
+ <FormProvider form={form}>
28
+ <Form>
29
+ <MultiSelectField
30
+ items={ITEMS}
31
+ label="Tags"
32
+ name="ids"
33
+ placeholder="Pick"
34
+ />
35
+ <Subscribe selector={(state) => state.values.ids}>
36
+ {(ids) => {
37
+ captured.current = ids
38
+ return null
39
+ }}
40
+ </Subscribe>
41
+ </Form>
42
+ </FormProvider>
43
+ )
44
+ }
45
+
46
+ test("renders selected values as chips and appends picks as numbers", async () => {
47
+ const user = userEvent.setup()
48
+ const captured: Box<number[]> = { current: undefined }
49
+ const { container } = render(<Harness captured={captured} />)
50
+ expect(container.querySelector("[data-field=ids]")?.textContent).toContain(
51
+ "Three"
52
+ )
53
+
54
+ await user.click(screen.getByLabelText(TAGS))
55
+ await user.click(await screen.findByRole("option", { name: "One" }))
56
+ expect(captured.current).toEqual([3, 1])
57
+ })
58
+
59
+ test("the clear button empties the selection", async () => {
60
+ const user = userEvent.setup()
61
+ const captured: Box<number[]> = { current: undefined }
62
+ render(<Harness captured={captured} />)
63
+ await user.click(screen.getByRole("button", { name: CLEAR }))
64
+ expect(captured.current).toEqual([])
65
+ })
66
+ })
67
+
68
+ describe("CheckboxGroupField", () => {
69
+ const DEFAULTS = { days: [] as string[] }
70
+
71
+ function Harness({ captured }: { captured: Box<string[]> }) {
72
+ const form = useForm({ defaultValues: DEFAULTS, onSubmit: vi.fn() })
73
+ const { CheckboxGroupField, Subscribe } = form
74
+ return (
75
+ <FormProvider form={form}>
76
+ <Form>
77
+ <CheckboxGroupField
78
+ items={[
79
+ "mon",
80
+ "tue",
81
+ { disabled: true, label: "Wednesday", value: "wed" },
82
+ ]}
83
+ label="Days"
84
+ name="days"
85
+ />
86
+ <Subscribe selector={(state) => state.values.days}>
87
+ {(days) => {
88
+ captured.current = days
89
+ return null
90
+ }}
91
+ </Subscribe>
92
+ </Form>
93
+ </FormProvider>
94
+ )
95
+ }
96
+
97
+ test("toggles values and keeps the order of items", async () => {
98
+ const user = userEvent.setup()
99
+ const captured: Box<string[]> = { current: undefined }
100
+ render(<Harness captured={captured} />)
101
+ await user.click(screen.getByRole("checkbox", { name: "tue" }))
102
+ await user.click(screen.getByRole("checkbox", { name: "mon" }))
103
+ expect(captured.current).toEqual(["mon", "tue"])
104
+ await user.click(screen.getByRole("checkbox", { name: "tue" }))
105
+ expect(captured.current).toEqual(["mon"])
106
+ })
107
+
108
+ test("renders a labelled group and honours per-option disabled", () => {
109
+ render(<Harness captured={{ current: undefined }} />)
110
+ expect(screen.getByRole("group", { name: "Days" })).toBeTruthy()
111
+ const wed = screen.getByRole("checkbox", { name: "Wednesday" })
112
+ expect(
113
+ wed.getAttribute("aria-disabled") ?? wed.getAttribute("data-disabled")
114
+ ).not.toBeNull()
115
+ })
116
+ })