@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
@@ -2,11 +2,41 @@ import type { AnyFormApi } from "@tanstack/react-form"
2
2
 
3
3
  import type { ValidationError } from "./validation-error"
4
4
 
5
- // Write per-field messages from a `ValidationError` onto the form. We target
6
- // `errorMap.onChange` so the form-level schema overwrites the message as
7
- // soon as the user edits the field; the error clears automatically the
8
- // moment the value passes validation. Form-level error message is written
9
- // to the form's overall error map under the `onSubmit` cause.
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: errors[0] },
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,7 @@
1
1
  "use client"
2
2
 
3
- import { cn } from "@zeno-lib/ui/lib/utils"
4
3
  import type { ComponentProps } from "react"
4
+ import { cn } from "@/lib/utils"
5
5
 
6
6
  type RequiredIndicatorProps = ComponentProps<"span">
7
7
 
@@ -1,6 +1,5 @@
1
- import { describe, expect, test } from "@zeno-lib/vitest"
1
+ import { describe, expect, test } from "vitest"
2
2
  import { z } from "zod"
3
-
4
3
  import { deepMergeDefaults, extractZodDefaults } from "./schema-defaults"
5
4
 
6
5
  describe("extractZodDefaults — primitives", () => {
@@ -1,7 +1,11 @@
1
- import { describe, expect, test } from "@zeno-lib/vitest"
1
+ import { describe, expect, test } from "vitest"
2
2
  import { z } from "zod"
3
3
 
4
- import { getRequiredPaths, type StandardSchemaLike } from "./schema-required"
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 top-level key when missing", () => {
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(false)
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
+ })