@zeno-lib/forms 0.1.0 → 0.2.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 (63) hide show
  1. package/dist/create-zeno-form.d.mts +118 -0
  2. package/dist/create-zeno-form.mjs +121 -0
  3. package/dist/form-element.d.mts +13 -0
  4. package/dist/form-element.mjs +31 -0
  5. package/dist/index.d.mts +11 -0
  6. package/dist/index.mjs +10 -0
  7. package/dist/lib/action-result.d.mts +22 -0
  8. package/dist/lib/action-result.mjs +32 -0
  9. package/dist/lib/apply-validation-error.d.mts +6 -0
  10. package/dist/lib/apply-validation-error.mjs +41 -0
  11. package/dist/lib/aria.d.mts +4 -0
  12. package/dist/lib/aria.mjs +7 -0
  13. package/dist/lib/contexts.d.mts +5 -0
  14. package/dist/lib/contexts.mjs +7 -0
  15. package/dist/lib/formatted-number.d.mts +56 -0
  16. package/dist/lib/formatted-number.mjs +138 -0
  17. package/dist/lib/schema-defaults.d.mts +17 -0
  18. package/dist/lib/schema-defaults.mjs +75 -0
  19. package/dist/lib/schema-required.d.mts +25 -0
  20. package/dist/lib/schema-required.mjs +72 -0
  21. package/dist/lib/submit-action.d.mts +64 -0
  22. package/dist/lib/submit-action.mjs +43 -0
  23. package/dist/lib/use-form-dialog.d.mts +73 -0
  24. package/dist/lib/use-form-dialog.mjs +90 -0
  25. package/dist/lib/use-formatted-number.d.mts +61 -0
  26. package/dist/lib/use-formatted-number.mjs +104 -0
  27. package/dist/lib/use-is-invalid.d.mts +7 -0
  28. package/dist/lib/use-is-invalid.mjs +31 -0
  29. package/dist/lib/use-rebased-default-values.d.mts +10 -0
  30. package/dist/lib/use-rebased-default-values.mjs +21 -0
  31. package/dist/lib/use-unsaved-changes-warning.d.mts +6 -0
  32. package/dist/lib/use-unsaved-changes-warning.mjs +24 -0
  33. package/dist/lib/validation-error.d.mts +12 -0
  34. package/dist/lib/validation-error.mjs +11 -0
  35. package/dist/lib/validation-logic.d.mts +5 -0
  36. package/dist/lib/validation-logic.mjs +114 -0
  37. package/dist/lib/validation-modes.d.mts +16 -0
  38. package/dist/lib/validation-modes.mjs +38 -0
  39. package/dist/tanstack.d.mts +1 -0
  40. package/dist/tanstack.mjs +2 -0
  41. package/package.json +25 -7
  42. package/src/create-form.tsx +10 -0
  43. package/src/fields/checkbox-group-field.tsx +160 -0
  44. package/src/fields/index.ts +5 -0
  45. package/src/fields/money-field.tsx +123 -0
  46. package/src/fields/multi-select-field.tsx +193 -0
  47. package/src/fields/percentage-field.tsx +73 -0
  48. package/src/fields/year-field.tsx +56 -0
  49. package/src/form-dialog.test.tsx +161 -0
  50. package/src/form-dialog.tsx +253 -0
  51. package/src/formatted-number-fields.test.tsx +236 -0
  52. package/src/index.ts +13 -0
  53. package/src/lib/action-result.ts +61 -0
  54. package/src/lib/apply-validation-error.test.ts +15 -4
  55. package/src/lib/apply-validation-error.ts +38 -6
  56. package/src/lib/formatted-number.test.ts +157 -0
  57. package/src/lib/formatted-number.ts +307 -0
  58. package/src/lib/submit-action.test-d.ts +83 -0
  59. package/src/lib/submit-action.ts +178 -0
  60. package/src/lib/use-form-dialog.ts +184 -0
  61. package/src/lib/use-formatted-number.ts +235 -0
  62. package/src/multi-value-fields.test.tsx +116 -0
  63. package/src/submit-action.test.tsx +421 -0
@@ -0,0 +1,157 @@
1
+ import { describe, expect, test } from "vitest"
2
+ import {
3
+ formatNumber,
4
+ getNumberSeparators,
5
+ mapCaret,
6
+ parseLocaleNumber,
7
+ parseTypedNumber,
8
+ roundTo,
9
+ } from "./formatted-number"
10
+
11
+ // Swiss grouping is an apostrophe, but which one (’ or ') depends on the
12
+ // runtime's CLDR data, so derive it instead of hard-coding it.
13
+ const CH = getNumberSeparators("de-CH").group
14
+ const ch = (text: string) => text.replaceAll("’", CH)
15
+
16
+ describe("getNumberSeparators", () => {
17
+ test("reads the locale's separators", () => {
18
+ expect(getNumberSeparators("de-CH").decimal).toBe(".")
19
+ expect(["’", "'"]).toContain(CH)
20
+ expect(getNumberSeparators("de-DE")).toEqual({ decimal: ",", group: "." })
21
+ expect(getNumberSeparators("en-US")).toEqual({ decimal: ".", group: "," })
22
+ })
23
+ })
24
+
25
+ describe("formatNumber", () => {
26
+ test("groups thousands per locale", () => {
27
+ const options = { maximumFractionDigits: 2 }
28
+ expect(formatNumber(1_234_567.5, { ...options, locale: "de-CH" })).toBe(
29
+ ch("1’234’567.5")
30
+ )
31
+ expect(formatNumber(1_234_567.5, { ...options, locale: "de-DE" })).toBe(
32
+ "1.234.567,5"
33
+ )
34
+ })
35
+
36
+ test("pads with minimumFractionDigits, uses an ASCII minus, empties null", () => {
37
+ expect(
38
+ formatNumber(-12.5, {
39
+ locale: "de-CH",
40
+ maximumFractionDigits: 2,
41
+ minimumFractionDigits: 2,
42
+ })
43
+ ).toBe("-12.50")
44
+ expect(formatNumber(null, { maximumFractionDigits: 2 })).toBe("")
45
+ expect(formatNumber(Number.NaN, { maximumFractionDigits: 2 })).toBe("")
46
+ })
47
+
48
+ test("can skip grouping", () => {
49
+ expect(
50
+ formatNumber(2024, {
51
+ locale: "de-CH",
52
+ maximumFractionDigits: 0,
53
+ useGrouping: false,
54
+ })
55
+ ).toBe("2024")
56
+ })
57
+ })
58
+
59
+ describe("parseTypedNumber", () => {
60
+ const chf = { locale: "de-CH", maximumFractionDigits: 2 }
61
+
62
+ test("re-inserts group separators while typing", () => {
63
+ expect(parseTypedNumber("1234567", chf)).toEqual({
64
+ text: ch("1’234’567"),
65
+ value: 1_234_567,
66
+ })
67
+ expect(parseTypedNumber(ch("1’2345"), chf)).toEqual({
68
+ text: ch("12’345"),
69
+ value: 12_345,
70
+ })
71
+ })
72
+
73
+ test("keeps a trailing decimal separator and truncates extra fraction digits", () => {
74
+ expect(parseTypedNumber("12.", chf)).toEqual({ text: "12.", value: 12 })
75
+ expect(parseTypedNumber("12.345", chf)).toEqual({
76
+ text: "12.34",
77
+ value: 12.34,
78
+ })
79
+ expect(parseTypedNumber(".5", chf)).toEqual({ text: "0.5", value: 0.5 })
80
+ })
81
+
82
+ test("uses the locale's decimal separator only", () => {
83
+ const eur = { locale: "de-DE", maximumFractionDigits: 2 }
84
+ expect(parseTypedNumber("1.2345", eur)).toEqual({
85
+ text: "12.345",
86
+ value: 12_345,
87
+ })
88
+ expect(parseTypedNumber("12,5", eur)).toEqual({ text: "12,5", value: 12.5 })
89
+ })
90
+
91
+ test("handles empties, minus and leading zeros", () => {
92
+ expect(parseTypedNumber("", chf)).toEqual({ text: "", value: null })
93
+ expect(parseTypedNumber("abc", chf)).toEqual({ text: "", value: null })
94
+ expect(parseTypedNumber("-", chf)).toEqual({ text: "-", value: null })
95
+ expect(parseTypedNumber("-12", chf)).toEqual({ text: "-12", value: -12 })
96
+ expect(parseTypedNumber("-12", { ...chf, allowNegative: false })).toEqual({
97
+ text: "12",
98
+ value: 12,
99
+ })
100
+ expect(parseTypedNumber("007", chf)).toEqual({ text: "7", value: 7 })
101
+ })
102
+
103
+ test("integers only and a digit cap", () => {
104
+ expect(
105
+ parseTypedNumber("20245", {
106
+ maxIntegerDigits: 4,
107
+ maximumFractionDigits: 0,
108
+ useGrouping: false,
109
+ })
110
+ ).toEqual({ text: "2024", value: 2024 })
111
+ expect(
112
+ parseTypedNumber("12.5", { locale: "de-CH", maximumFractionDigits: 0 })
113
+ ).toEqual({ text: "125", value: 125 })
114
+ })
115
+ })
116
+
117
+ describe("parseLocaleNumber", () => {
118
+ test.each([
119
+ ["CHF 1'234.50", "de-CH", 1234.5],
120
+ ["1’234’567", "de-CH", 1_234_567],
121
+ ["1.234,50 €", "de-CH", 1234.5],
122
+ ["1,234.50", "de-DE", 1234.5],
123
+ ["1,234", "de-CH", 1234],
124
+ ["1,5", "de-CH", 1.5],
125
+ ["1.234", "de-DE", 1234],
126
+ ["1.5", "de-DE", 1.5],
127
+ ["1 234 567,89", "fr-FR", 1_234_567.89],
128
+ ["−42", "en-US", -42],
129
+ ["(42.10)", "en-US", -42.1],
130
+ ["1.234.567", "en-US", 1_234_567],
131
+ ])("%s (%s) → %d", (text, locale, expected) => {
132
+ expect(parseLocaleNumber(text, locale)).toBe(expected)
133
+ })
134
+
135
+ test("returns null without digits", () => {
136
+ expect(parseLocaleNumber("CHF", "de-CH")).toBeNull()
137
+ expect(parseLocaleNumber("", "de-CH")).toBeNull()
138
+ })
139
+ })
140
+
141
+ describe("mapCaret", () => {
142
+ test("keeps the caret after the same digit once separators move", () => {
143
+ // Typed "5" after "1234" with the caret at the end: "12345" → "12’345".
144
+ expect(mapCaret("12345", 5, ch("12’345"), "de-CH")).toBe(6)
145
+ // Caret after the "2" of "1’2|34": same digit in "1’234" is index 3.
146
+ expect(mapCaret(ch("1’234"), 3, ch("1’234"), "de-CH")).toBe(3)
147
+ expect(mapCaret("", 0, "", "de-CH")).toBe(0)
148
+ })
149
+ })
150
+
151
+ describe("roundTo", () => {
152
+ test("rounds half away from zero without float drift", () => {
153
+ expect(roundTo(1.005, 2)).toBe(1.01)
154
+ expect(roundTo(-1.005, 2)).toBe(-1.01)
155
+ expect(roundTo(0.07 * 100, 2)).toBe(7)
156
+ })
157
+ })
@@ -0,0 +1,307 @@
1
+ // Pure, locale-aware helpers behind `useFormattedNumber` (and therefore the
2
+ // money / percentage / year fields). UI-free so they ship on npm and can be
3
+ // unit-tested without a DOM.
4
+ //
5
+ // Display strings always use ASCII digits and an ASCII `-` for negatives, with
6
+ // the locale's own group and decimal separators (e.g. `de-CH` → `1’234.5`,
7
+ // `de-DE` → `1.234,5`, `fr-FR` → `1 234,5`).
8
+
9
+ type NumberSeparators = {
10
+ /** Thousands separator, e.g. `’` for `de-CH`, `,` for `en-US`. */
11
+ group: string
12
+ /** Decimal separator, e.g. `.` for `de-CH`, `,` for `de-DE`. */
13
+ decimal: string
14
+ }
15
+
16
+ type FormatNumberOptions = {
17
+ locale?: string
18
+ /** Maximum digits after the decimal separator. `0` means integers only. */
19
+ maximumFractionDigits: number
20
+ /** Minimum digits after the decimal separator (pads with zeros). */
21
+ minimumFractionDigits?: number
22
+ /** Insert group (thousands) separators. Defaults to `true`. */
23
+ useGrouping?: boolean
24
+ }
25
+
26
+ type TypedNumber = {
27
+ /** The re-formatted text to show in the input. */
28
+ text: string
29
+ /** The parsed number, or `null` when the text holds no digits. */
30
+ value: number | null
31
+ }
32
+
33
+ type ParseTypedOptions = {
34
+ locale?: string
35
+ maximumFractionDigits: number
36
+ useGrouping?: boolean
37
+ allowNegative?: boolean
38
+ /** Drop integer digits beyond this count (e.g. `4` for a year). */
39
+ maxIntegerDigits?: number
40
+ }
41
+
42
+ const separatorCache = new Map<string, NumberSeparators>()
43
+
44
+ // Minus signs a user may type or paste: ASCII hyphen-minus, U+2212 (what Intl
45
+ // emits for some locales), and the en dash.
46
+ const MINUS_SIGNS = /[-−–]/
47
+ const LEADING_MINUS = /^[^\d]*[-−–]/
48
+ const NON_DIGIT = /\D/g
49
+ const LEADING_ZEROS = /^0+(?=\d)/
50
+ const DIGIT = /\d/
51
+ const DIGITS = /\d/g
52
+ const PARENTHESIZED = /^\s*\(.*\)\s*$/
53
+ const NOT_NUMERIC = /[^\d.,]/g
54
+ const UNICODE_MINUS = /\u2212/g
55
+
56
+ function getNumberSeparators(locale?: string): NumberSeparators {
57
+ const key = locale ?? ""
58
+ const cached = separatorCache.get(key)
59
+ if (cached) {
60
+ return cached
61
+ }
62
+ const parts = new Intl.NumberFormat(locale, {
63
+ numberingSystem: "latn",
64
+ }).formatToParts(1_234_567.5)
65
+ const separators = {
66
+ decimal: parts.find((p) => p.type === "decimal")?.value ?? ".",
67
+ group: parts.find((p) => p.type === "group")?.value ?? ",",
68
+ }
69
+ separatorCache.set(key, separators)
70
+ return separators
71
+ }
72
+
73
+ function normalizeMinus(text: string): string {
74
+ return text.replace(UNICODE_MINUS, "-")
75
+ }
76
+
77
+ /** Format a number for display (on blur, on reset, on first render). */
78
+ function formatNumber(
79
+ value: number | null | undefined,
80
+ {
81
+ locale,
82
+ maximumFractionDigits,
83
+ minimumFractionDigits = 0,
84
+ useGrouping = true,
85
+ }: FormatNumberOptions
86
+ ): string {
87
+ if (value == null || !Number.isFinite(value)) {
88
+ return ""
89
+ }
90
+ return normalizeMinus(
91
+ new Intl.NumberFormat(locale, {
92
+ maximumFractionDigits,
93
+ minimumFractionDigits: Math.min(
94
+ minimumFractionDigits,
95
+ maximumFractionDigits
96
+ ),
97
+ numberingSystem: "latn",
98
+ useGrouping,
99
+ }).format(value)
100
+ )
101
+ }
102
+
103
+ function groupIntegerDigits(
104
+ digits: string,
105
+ locale: string | undefined,
106
+ useGrouping: boolean
107
+ ): string {
108
+ if (!useGrouping || digits.length <= 3) {
109
+ return digits
110
+ }
111
+ // BigInt keeps long digit strings exact while Intl inserts the separators.
112
+ return new Intl.NumberFormat(locale, {
113
+ numberingSystem: "latn",
114
+ useGrouping: true,
115
+ }).format(BigInt(digits))
116
+ }
117
+
118
+ function toNumber(
119
+ negative: boolean,
120
+ intDigits: string,
121
+ fracDigits: string
122
+ ): number | null {
123
+ if (intDigits === "" && fracDigits === "") {
124
+ return null
125
+ }
126
+ const value = Number(`${intDigits || "0"}.${fracDigits || "0"}`)
127
+ return negative && value !== 0 ? -value : value
128
+ }
129
+
130
+ /**
131
+ * Re-format what the user is typing. Only the locale's decimal separator
132
+ * starts the fraction; every other non-digit is dropped and group separators
133
+ * are re-inserted. Keeps a trailing decimal separator (`"12."`) so typing
134
+ * isn't fought, and truncates fraction digits beyond `maximumFractionDigits`.
135
+ */
136
+ function parseTypedNumber(
137
+ raw: string,
138
+ {
139
+ locale,
140
+ maximumFractionDigits,
141
+ useGrouping = true,
142
+ allowNegative = true,
143
+ maxIntegerDigits,
144
+ }: ParseTypedOptions
145
+ ): TypedNumber {
146
+ const { decimal } = getNumberSeparators(locale)
147
+ const negative = allowNegative && LEADING_MINUS.test(raw)
148
+ const decimalIndex = maximumFractionDigits > 0 ? raw.indexOf(decimal) : -1
149
+ const intRaw = decimalIndex === -1 ? raw : raw.slice(0, decimalIndex)
150
+ const fracRaw = decimalIndex === -1 ? "" : raw.slice(decimalIndex + 1)
151
+
152
+ let intDigits = intRaw.replace(NON_DIGIT, "").replace(LEADING_ZEROS, "")
153
+ if (maxIntegerDigits !== undefined) {
154
+ intDigits = intDigits.slice(0, maxIntegerDigits)
155
+ }
156
+ const fracDigits = fracRaw
157
+ .replace(NON_DIGIT, "")
158
+ .slice(0, maximumFractionDigits)
159
+ const hasDecimal = decimalIndex !== -1
160
+
161
+ const sign = negative ? "-" : ""
162
+ const intText =
163
+ intDigits === "" && hasDecimal
164
+ ? "0"
165
+ : groupIntegerDigits(intDigits, locale, useGrouping)
166
+ const text = `${sign}${intText}${hasDecimal ? `${decimal}${fracDigits}` : ""}`
167
+
168
+ return { text, value: toNumber(negative, intDigits, fracDigits) }
169
+ }
170
+
171
+ // Decide which of `.` / `,` is the decimal separator in pasted text, which may
172
+ // come from another locale than the input's (a spreadsheet, a bank statement).
173
+ function detectDecimalSeparator(
174
+ text: string,
175
+ localeDecimal: string
176
+ ): string | null {
177
+ const lastDot = text.lastIndexOf(".")
178
+ const lastComma = text.lastIndexOf(",")
179
+ if (lastDot !== -1 && lastComma !== -1) {
180
+ // Both present: whichever comes last is the decimal separator.
181
+ return lastDot > lastComma ? "." : ","
182
+ }
183
+ let candidate: string | null = null
184
+ if (lastDot !== -1) {
185
+ candidate = "."
186
+ } else if (lastComma !== -1) {
187
+ candidate = ","
188
+ }
189
+ if (candidate === null) {
190
+ return null
191
+ }
192
+ const occurrences = text.split(candidate).length - 1
193
+ if (occurrences > 1) {
194
+ return null // repeated → a group separator
195
+ }
196
+ if (candidate === localeDecimal) {
197
+ return candidate
198
+ }
199
+ // A lone foreign separator followed by exactly three digits reads as a
200
+ // thousands group (`1,234` in a `de-CH` input); otherwise it's a decimal.
201
+ const after = text.slice(text.lastIndexOf(candidate) + 1)
202
+ const digitsAfter = after.match(DIGITS)?.length ?? 0
203
+ return digitsAfter === 3 ? null : candidate
204
+ }
205
+
206
+ /**
207
+ * Parse free-form text (pasted, or a value from another system) into a
208
+ * number. Tolerates currency codes and symbols, any whitespace or apostrophe
209
+ * as grouping, `.` or `,` as the decimal separator, and `−`/`-` for negatives.
210
+ * Returns `null` when no digits are found.
211
+ */
212
+ function parseLocaleNumber(text: string, locale?: string): number | null {
213
+ if (!DIGIT.test(text)) {
214
+ return null
215
+ }
216
+ const { decimal } = getNumberSeparators(locale)
217
+ const negative = LEADING_MINUS.test(text) || PARENTHESIZED.test(text)
218
+ const kept = text.replace(NOT_NUMERIC, "")
219
+ const decimalSeparator = detectDecimalSeparator(kept, decimal)
220
+ let intPart = kept
221
+ let fracPart = ""
222
+ if (decimalSeparator) {
223
+ const index = kept.lastIndexOf(decimalSeparator)
224
+ intPart = kept.slice(0, index)
225
+ fracPart = kept.slice(index + 1)
226
+ }
227
+ return toNumber(
228
+ negative,
229
+ intPart.replace(NON_DIGIT, ""),
230
+ fracPart.replace(NON_DIGIT, "")
231
+ )
232
+ }
233
+
234
+ // Characters that survive re-formatting: digits, the decimal separator and a
235
+ // leading minus. Counting them before the caret lets us put the caret back in
236
+ // the same logical place after separators were inserted or removed.
237
+ function countSignificant(text: string, end: number, decimal: string): number {
238
+ let count = 0
239
+ for (let i = 0; i < end && i < text.length; i++) {
240
+ const char = text[i] as string
241
+ if (DIGIT.test(char) || char === decimal || MINUS_SIGNS.test(char)) {
242
+ count++
243
+ }
244
+ }
245
+ return count
246
+ }
247
+
248
+ function caretForSignificant(
249
+ text: string,
250
+ significant: number,
251
+ decimal: string
252
+ ): number {
253
+ if (significant <= 0) {
254
+ return 0
255
+ }
256
+ let count = 0
257
+ for (let i = 0; i < text.length; i++) {
258
+ const char = text[i] as string
259
+ if (DIGIT.test(char) || char === decimal || char === "-") {
260
+ count++
261
+ if (count === significant) {
262
+ return i + 1
263
+ }
264
+ }
265
+ }
266
+ return text.length
267
+ }
268
+
269
+ /**
270
+ * Where the caret should land in `next` after the user edited `raw` with the
271
+ * caret at `caret`.
272
+ */
273
+ function mapCaret(
274
+ raw: string,
275
+ caret: number,
276
+ next: string,
277
+ locale?: string
278
+ ): number {
279
+ const { decimal } = getNumberSeparators(locale)
280
+ return caretForSignificant(
281
+ next,
282
+ countSignificant(raw, caret, decimal),
283
+ decimal
284
+ )
285
+ }
286
+
287
+ /** Round to `digits` decimals without the `1.005 → 1.00` float surprise. */
288
+ function roundTo(value: number, digits: number): number {
289
+ const factor = 10 ** digits
290
+ const rounded = Math.round((Math.abs(value) + Number.EPSILON) * factor)
291
+ return (Math.sign(value) * rounded) / factor
292
+ }
293
+
294
+ export type {
295
+ FormatNumberOptions,
296
+ NumberSeparators,
297
+ ParseTypedOptions,
298
+ TypedNumber,
299
+ }
300
+ export {
301
+ formatNumber,
302
+ getNumberSeparators,
303
+ mapCaret,
304
+ parseLocaleNumber,
305
+ parseTypedNumber,
306
+ roundTo,
307
+ }
@@ -0,0 +1,83 @@
1
+ import { expectTypeOf, test } from "vitest"
2
+ import { z } from "zod"
3
+ import { useForm } from "../create-form"
4
+ import type { ActionResult } from "./action-result"
5
+ import { submitAction } from "./submit-action"
6
+
7
+ const schema = z.object({
8
+ name: z.string(),
9
+ percentage: z.string().transform(Number),
10
+ })
11
+ type Values = z.input<typeof schema>
12
+
13
+ // What `defineFormAction` in `@zeno-lib/db/next` produces, restated here so
14
+ // the structural contract is pinned without depending on that package.
15
+ type ServerActionError = {
16
+ readonly fieldErrors: Record<string, string[]>
17
+ readonly formErrors: string[]
18
+ }
19
+ type ServerActionResult<T> =
20
+ | { readonly ok: true; readonly data: T }
21
+ | { readonly ok: false; readonly error: ServerActionError }
22
+
23
+ declare function saveShare(input: {
24
+ name: string
25
+ percentage: number
26
+ }): Promise<ServerActionResult<{ id: number }>>
27
+ declare function saveRaw(input: Values): Promise<ServerActionResult<Values>>
28
+
29
+ test("infers the data type from the action, with no explicit generics", () => {
30
+ useForm({
31
+ defaultValues: { name: "", percentage: "" },
32
+ onSubmit: async (submit) => {
33
+ const result = await submitAction(submit, saveShare, { schema })
34
+ expectTypeOf(result).toEqualTypeOf<ActionResult<{ id: number }>>()
35
+ if (result.ok) {
36
+ expectTypeOf(result.data).toEqualTypeOf<{ id: number }>()
37
+ }
38
+ },
39
+ })
40
+ })
41
+
42
+ test("without a schema the action must accept the form values", () => {
43
+ useForm({
44
+ defaultValues: { name: "", percentage: "" },
45
+ onSubmit: (submit) => submitAction(submit, saveRaw, { reset: true }),
46
+ })
47
+ useForm({
48
+ defaultValues: { name: "", percentage: "" },
49
+ // @ts-expect-error: the action wants `percentage: number`; pass `schema` to transform.
50
+ onSubmit: (submit) => submitAction(submit, saveShare),
51
+ })
52
+ })
53
+
54
+ test("with a schema the action receives the schema output", () => {
55
+ useForm({
56
+ defaultValues: { name: "", percentage: "" },
57
+ onSubmit: (submit) =>
58
+ // @ts-expect-error: the schema outputs `percentage: number`, the action wants a string.
59
+ submitAction(submit, saveRaw, { schema }),
60
+ })
61
+ })
62
+
63
+ test("reset: true is only offered when the data has the form's shape", () => {
64
+ useForm({
65
+ defaultValues: { name: "", percentage: "" },
66
+ onSubmit: (submit) =>
67
+ // @ts-expect-error: `{ id: number }` is not the form's values.
68
+ submitAction(submit, saveShare, { reset: true, schema }),
69
+ })
70
+ useForm({
71
+ defaultValues: { name: "", percentage: "" },
72
+ onSubmit: (submit) =>
73
+ submitAction(submit, saveShare, { reset: "values", schema }),
74
+ })
75
+ useForm({
76
+ defaultValues: { name: "", percentage: "" },
77
+ onSubmit: (submit) =>
78
+ submitAction(submit, saveShare, {
79
+ reset: (data) => ({ name: String(data.id), percentage: "" }),
80
+ schema,
81
+ }),
82
+ })
83
+ })