@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.
- package/dist/create-zeno-form.d.mts +118 -0
- package/dist/create-zeno-form.mjs +121 -0
- package/dist/form-element.d.mts +13 -0
- package/dist/form-element.mjs +31 -0
- package/dist/index.d.mts +11 -0
- package/dist/index.mjs +10 -0
- package/dist/lib/action-result.d.mts +22 -0
- package/dist/lib/action-result.mjs +32 -0
- package/dist/lib/apply-validation-error.d.mts +6 -0
- package/dist/lib/apply-validation-error.mjs +41 -0
- package/dist/lib/aria.d.mts +4 -0
- package/dist/lib/aria.mjs +7 -0
- package/dist/lib/contexts.d.mts +5 -0
- package/dist/lib/contexts.mjs +7 -0
- package/dist/lib/formatted-number.d.mts +56 -0
- package/dist/lib/formatted-number.mjs +138 -0
- package/dist/lib/schema-defaults.d.mts +17 -0
- package/dist/lib/schema-defaults.mjs +75 -0
- package/dist/lib/schema-required.d.mts +25 -0
- package/dist/lib/schema-required.mjs +72 -0
- package/dist/lib/submit-action.d.mts +64 -0
- package/dist/lib/submit-action.mjs +43 -0
- package/dist/lib/use-form-dialog.d.mts +73 -0
- package/dist/lib/use-form-dialog.mjs +90 -0
- package/dist/lib/use-formatted-number.d.mts +61 -0
- package/dist/lib/use-formatted-number.mjs +104 -0
- package/dist/lib/use-is-invalid.d.mts +7 -0
- package/dist/lib/use-is-invalid.mjs +31 -0
- package/dist/lib/use-rebased-default-values.d.mts +10 -0
- package/dist/lib/use-rebased-default-values.mjs +21 -0
- package/dist/lib/use-unsaved-changes-warning.d.mts +6 -0
- package/dist/lib/use-unsaved-changes-warning.mjs +24 -0
- package/dist/lib/validation-error.d.mts +12 -0
- package/dist/lib/validation-error.mjs +11 -0
- package/dist/lib/validation-logic.d.mts +5 -0
- package/dist/lib/validation-logic.mjs +114 -0
- package/dist/lib/validation-modes.d.mts +16 -0
- package/dist/lib/validation-modes.mjs +38 -0
- package/dist/tanstack.d.mts +1 -0
- package/dist/tanstack.mjs +2 -0
- package/package.json +25 -7
- package/src/create-form.tsx +10 -0
- package/src/fields/checkbox-group-field.tsx +160 -0
- package/src/fields/index.ts +5 -0
- package/src/fields/money-field.tsx +123 -0
- package/src/fields/multi-select-field.tsx +193 -0
- package/src/fields/percentage-field.tsx +73 -0
- package/src/fields/year-field.tsx +56 -0
- package/src/form-dialog.test.tsx +161 -0
- package/src/form-dialog.tsx +253 -0
- package/src/formatted-number-fields.test.tsx +236 -0
- package/src/index.ts +13 -0
- package/src/lib/action-result.ts +61 -0
- package/src/lib/apply-validation-error.test.ts +15 -4
- package/src/lib/apply-validation-error.ts +38 -6
- package/src/lib/formatted-number.test.ts +157 -0
- package/src/lib/formatted-number.ts +307 -0
- package/src/lib/submit-action.test-d.ts +83 -0
- package/src/lib/submit-action.ts +178 -0
- package/src/lib/use-form-dialog.ts +184 -0
- package/src/lib/use-formatted-number.ts +235 -0
- package/src/multi-value-fields.test.tsx +116 -0
- 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
|
+
})
|