@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.
- package/package.json +23 -11
- package/src/addons/validation-spinner.tsx +3 -4
- package/src/create-form.tsx +59 -0
- package/src/create-zeno-form.tsx +531 -0
- package/src/fields/checkbox-field.tsx +15 -12
- package/src/fields/checkbox-group-field.tsx +160 -0
- package/src/fields/combobox-field.tsx +13 -14
- package/src/fields/date-picker-field.tsx +18 -15
- package/src/fields/index.ts +5 -0
- package/src/fields/input-field.tsx +12 -13
- package/src/fields/money-field.tsx +123 -0
- package/src/fields/multi-select-field.tsx +193 -0
- package/src/fields/number-field.tsx +1 -2
- package/src/fields/otp-field.tsx +15 -12
- package/src/fields/percentage-field.tsx +73 -0
- package/src/fields/radio-group-field.tsx +12 -13
- package/src/fields/reset-button.tsx +2 -3
- package/src/fields/select-field.tsx +12 -13
- package/src/fields/slider-field.tsx +11 -12
- package/src/fields/submit-button.tsx +3 -4
- package/src/fields/switch-field.tsx +15 -12
- package/src/fields/textarea-field.tsx +12 -13
- 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/form-element.tsx +51 -0
- package/src/formatted-number-fields.test.tsx +236 -0
- package/src/index.ts +27 -0
- package/src/lib/action-result.ts +61 -0
- package/src/lib/apply-validation-error.test.ts +16 -6
- 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/required-indicator.tsx +1 -1
- package/src/lib/schema-defaults.test.ts +1 -2
- package/src/lib/schema-required.test.ts +98 -6
- package/src/lib/schema-required.ts +126 -22
- 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/lib/use-is-invalid.ts +4 -4
- package/src/lib/use-rebased-default-values.ts +42 -0
- package/src/lib/use-unsaved-changes-warning.ts +3 -3
- package/src/lib/validation-error.test.ts +1 -1
- package/src/lib/validation-logic.test.ts +1 -1
- package/src/lib/validation-modes.test-d.ts +1 -1
- package/src/lib/validation-modes.test.ts +12 -2
- package/src/lib/validation-modes.ts +3 -1
- package/src/multi-value-fields.test.tsx +116 -0
- package/src/submit-action.test.tsx +421 -0
- package/src/use-app-fields.test-d.ts +8 -25
- package/src/use-app-fields.test.tsx +8 -32
- package/src/use-form.cascade.test.tsx +4 -6
- package/src/use-form.focus-invalid.test.tsx +198 -0
- package/src/use-form.reset-defaults.test.tsx +125 -0
- package/src/use-form.test-d.ts +2 -3
- package/src/use-form.test.tsx +12 -14
- package/src/use-form.unsaved-warning.test.tsx +4 -13
- package/src/use-form.validation-modes.test.tsx +10 -8
- package/src/form.tsx +0 -105
- package/src/use-app-fields.tsx +0 -272
- package/src/use-form.tsx +0 -450
|
@@ -2,11 +2,41 @@ import type { AnyFormApi } from "@tanstack/react-form"
|
|
|
2
2
|
|
|
3
3
|
import type { ValidationError } from "./validation-error"
|
|
4
4
|
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
5
|
+
// Clear a server message the first time the field's value moves off the value
|
|
6
|
+
// it was rejected with. TanStack Form does not do this on its own: a field
|
|
7
|
+
// with no validators of its own runs nothing on change, and the form-level
|
|
8
|
+
// pass keeps an error it did not write, so the message would otherwise stick,
|
|
9
|
+
// and keep `canSubmit` false, until the form is reset. Only that field is
|
|
10
|
+
// cleared, and only while the entry is still ours: a schema error written over
|
|
11
|
+
// it in the meantime is left alone.
|
|
12
|
+
function clearWhenEdited(
|
|
13
|
+
formApi: AnyFormApi,
|
|
14
|
+
name: string,
|
|
15
|
+
entry: unknown
|
|
16
|
+
): void {
|
|
17
|
+
const rejected = formApi.getFieldValue(name)
|
|
18
|
+
const subscription = formApi.store.subscribe(() => {
|
|
19
|
+
if (formApi.getFieldMeta(name)?.errorMap?.onChange !== entry) {
|
|
20
|
+
subscription.unsubscribe()
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
if (Object.is(formApi.getFieldValue(name), rejected)) {
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
subscription.unsubscribe()
|
|
27
|
+
formApi.setFieldMeta(name, (prev) => ({
|
|
28
|
+
...prev,
|
|
29
|
+
errorMap: { ...prev.errorMap, onChange: undefined },
|
|
30
|
+
}))
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Write per-field messages from a `ValidationError` onto the form. They go in
|
|
35
|
+
// `errorMap.onChange` (an array when there are several, which TanStack
|
|
36
|
+
// flattens into `meta.errors`) and clear as soon as the user edits that
|
|
37
|
+
// field; see `clearWhenEdited`. The form-level message is written to the
|
|
38
|
+
// form's error map under the `onSubmit` cause, which TanStack clears on the
|
|
39
|
+
// next change that validates cleanly.
|
|
10
40
|
function applyValidationError(
|
|
11
41
|
formApi: AnyFormApi,
|
|
12
42
|
error: ValidationError
|
|
@@ -14,12 +44,14 @@ function applyValidationError(
|
|
|
14
44
|
for (const [name, message] of Object.entries(error.fields)) {
|
|
15
45
|
const messages = Array.isArray(message) ? message : [message]
|
|
16
46
|
const errors = messages.map((m) => ({ message: m }))
|
|
47
|
+
const entry = errors.length === 1 ? errors[0] : errors
|
|
17
48
|
formApi.setFieldMeta(name, (prev) => ({
|
|
18
49
|
...prev,
|
|
19
|
-
errorMap: { ...prev.errorMap, onChange:
|
|
50
|
+
errorMap: { ...prev.errorMap, onChange: entry },
|
|
20
51
|
errors,
|
|
21
52
|
isValid: false,
|
|
22
53
|
}))
|
|
54
|
+
clearWhenEdited(formApi, name, entry)
|
|
23
55
|
}
|
|
24
56
|
if (error.formError) {
|
|
25
57
|
formApi.setErrorMap({
|
|
@@ -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
|
+
}
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import { describe, expect, test } from "
|
|
1
|
+
import { describe, expect, test } from "vitest"
|
|
2
2
|
import { z } from "zod"
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
getRequiredPaths,
|
|
6
|
+
type StandardSchemaLike,
|
|
7
|
+
toRequiredPathKey,
|
|
8
|
+
} from "./schema-required"
|
|
5
9
|
|
|
6
10
|
describe("getRequiredPaths", () => {
|
|
7
11
|
test("plain required fields appear in the set", () => {
|
|
@@ -34,17 +38,52 @@ describe("getRequiredPaths", () => {
|
|
|
34
38
|
expect(required.has("role")).toBe(false)
|
|
35
39
|
})
|
|
36
40
|
|
|
37
|
-
test("nested z.object reports the
|
|
38
|
-
// Probing with `{}` only reveals the top-level required key; Zod never
|
|
39
|
-
// recurses past a missing parent.
|
|
41
|
+
test("nested z.object reports the parent and its required children", () => {
|
|
40
42
|
const schema = z.object({
|
|
41
43
|
profile: z.object({
|
|
44
|
+
bio: z.string().optional(),
|
|
42
45
|
email: z.email(),
|
|
43
46
|
}),
|
|
44
47
|
})
|
|
45
48
|
const required = getRequiredPaths(schema)
|
|
46
49
|
expect(required.has("profile")).toBe(true)
|
|
47
|
-
expect(required.has("profile.email")).toBe(
|
|
50
|
+
expect(required.has("profile.email")).toBe(true)
|
|
51
|
+
expect(required.has("profile.bio")).toBe(false)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test("optional nested object is not descended into", () => {
|
|
55
|
+
const schema = z.object({
|
|
56
|
+
profile: z.object({ email: z.email() }).optional(),
|
|
57
|
+
})
|
|
58
|
+
expect(getRequiredPaths(schema).size).toBe(0)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test("array rows are reported with TanStack bracket syntax", () => {
|
|
62
|
+
const schema = z.object({
|
|
63
|
+
members: z.array(
|
|
64
|
+
z.object({
|
|
65
|
+
name: z.string().min(1),
|
|
66
|
+
note: z.string().optional(),
|
|
67
|
+
})
|
|
68
|
+
),
|
|
69
|
+
tags: z.array(z.string()),
|
|
70
|
+
})
|
|
71
|
+
const required = getRequiredPaths(schema)
|
|
72
|
+
expect(required.has("members")).toBe(true)
|
|
73
|
+
expect(required.has("members[0].name")).toBe(true)
|
|
74
|
+
expect(required.has("members.0.name")).toBe(false)
|
|
75
|
+
expect(required.has("members[0].note")).toBe(false)
|
|
76
|
+
expect(required.has("tags[0]")).toBe(true)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test("nested arrays descend through every level", () => {
|
|
80
|
+
const schema = z.object({
|
|
81
|
+
groups: z.array(
|
|
82
|
+
z.object({ members: z.array(z.object({ name: z.string() })) })
|
|
83
|
+
),
|
|
84
|
+
})
|
|
85
|
+
const required = getRequiredPaths(schema)
|
|
86
|
+
expect(required.has("groups[0].members[0].name")).toBe(true)
|
|
48
87
|
})
|
|
49
88
|
|
|
50
89
|
test("dot-joined path is built when issues carry multi-segment paths", () => {
|
|
@@ -105,6 +144,47 @@ describe("getRequiredPaths", () => {
|
|
|
105
144
|
expect(getRequiredPaths(empty).size).toBe(0)
|
|
106
145
|
})
|
|
107
146
|
|
|
147
|
+
test("numeric path segments render as bracket indices, normalised to 0", () => {
|
|
148
|
+
const schema: StandardSchemaLike = {
|
|
149
|
+
"~standard": {
|
|
150
|
+
validate: () => ({
|
|
151
|
+
issues: [
|
|
152
|
+
{ path: ["members", 2, "name"] },
|
|
153
|
+
{ path: ["rows", "1", "value"] },
|
|
154
|
+
{ path: [{ key: "items" }, { key: 3 }] },
|
|
155
|
+
],
|
|
156
|
+
}),
|
|
157
|
+
},
|
|
158
|
+
}
|
|
159
|
+
const required = getRequiredPaths(schema)
|
|
160
|
+
expect(required.has("members[0].name")).toBe(true)
|
|
161
|
+
expect(required.has("rows[0].value")).toBe(true)
|
|
162
|
+
expect(required.has("items[0]")).toBe(true)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
test("Valibot-style capitalised `expected` hints are descended", () => {
|
|
166
|
+
const schema: StandardSchemaLike = {
|
|
167
|
+
"~standard": {
|
|
168
|
+
validate: (value) => {
|
|
169
|
+
const profile = (value as { profile?: unknown }).profile
|
|
170
|
+
return profile === undefined
|
|
171
|
+
? { issues: [{ expected: "Object", path: [{ key: "profile" }] }] }
|
|
172
|
+
: {
|
|
173
|
+
issues: [
|
|
174
|
+
{
|
|
175
|
+
expected: "string",
|
|
176
|
+
path: [{ key: "profile" }, { key: "email" }],
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
}
|
|
183
|
+
const required = getRequiredPaths(schema)
|
|
184
|
+
expect(required.has("profile")).toBe(true)
|
|
185
|
+
expect(required.has("profile.email")).toBe(true)
|
|
186
|
+
})
|
|
187
|
+
|
|
108
188
|
test("path entries shaped like { key } (Valibot) are normalised", () => {
|
|
109
189
|
const valibotStyle: StandardSchemaLike = {
|
|
110
190
|
"~standard": {
|
|
@@ -130,3 +210,15 @@ describe("getRequiredPaths", () => {
|
|
|
130
210
|
expect(getRequiredPaths(formLevelOnly).size).toBe(0)
|
|
131
211
|
})
|
|
132
212
|
})
|
|
213
|
+
|
|
214
|
+
describe("toRequiredPathKey", () => {
|
|
215
|
+
test("normalises bracket and dotted indices to [0]", () => {
|
|
216
|
+
expect(toRequiredPathKey("members[3].name")).toBe("members[0].name")
|
|
217
|
+
expect(toRequiredPathKey("members.3.name")).toBe("members[0].name")
|
|
218
|
+
expect(toRequiredPathKey("groups[1].members[12]")).toBe(
|
|
219
|
+
"groups[0].members[0]"
|
|
220
|
+
)
|
|
221
|
+
expect(toRequiredPathKey("email")).toBe("email")
|
|
222
|
+
expect(toRequiredPathKey("line2")).toBe("line2")
|
|
223
|
+
})
|
|
224
|
+
})
|