@eifi1/ui-kit 0.21.0 → 0.23.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 (320) hide show
  1. package/README.md +42 -25
  2. package/dist/chart.d.ts +19 -14
  3. package/dist/components/account-chips.d.ts +10 -3
  4. package/dist/components/account-chips.js +5 -2
  5. package/dist/components/account-chips.js.map +1 -1
  6. package/dist/components/account-settings.d.ts +33 -2
  7. package/dist/components/account-settings.js +43 -21
  8. package/dist/components/account-settings.js.map +1 -1
  9. package/dist/components/amount-input.d.ts +53 -14
  10. package/dist/components/amount-input.js +17 -28
  11. package/dist/components/amount-input.js.map +1 -1
  12. package/dist/components/autocomplete.d.ts +29 -0
  13. package/dist/components/autocomplete.js +134 -104
  14. package/dist/components/autocomplete.js.map +1 -1
  15. package/dist/components/button-group.d.ts +19 -14
  16. package/dist/components/calculator.d.ts +19 -14
  17. package/dist/components/checkbox-group.d.ts +104 -0
  18. package/dist/components/checkbox-group.js +111 -0
  19. package/dist/components/checkbox-group.js.map +1 -0
  20. package/dist/components/checkbox.d.ts +19 -0
  21. package/dist/components/checkbox.js +30 -7
  22. package/dist/components/checkbox.js.map +1 -1
  23. package/dist/components/chip.d.ts +18 -0
  24. package/dist/components/chip.js +47 -10
  25. package/dist/components/chip.js.map +1 -1
  26. package/dist/components/column-mapper.d.ts +182 -0
  27. package/dist/components/column-mapper.js +375 -0
  28. package/dist/components/column-mapper.js.map +1 -0
  29. package/dist/components/combobox-core.d.ts +39 -2
  30. package/dist/components/combobox-core.js +29 -6
  31. package/dist/components/combobox-core.js.map +1 -1
  32. package/dist/components/combobox.d.ts +91 -2
  33. package/dist/components/combobox.js +454 -308
  34. package/dist/components/combobox.js.map +1 -1
  35. package/dist/components/confirm-dialog.d.ts +69 -161
  36. package/dist/components/confirm-dialog.js +7 -8
  37. package/dist/components/confirm-dialog.js.map +1 -1
  38. package/dist/components/copy-button.d.ts +16 -11
  39. package/dist/components/country-select.d.ts +167 -0
  40. package/dist/components/country-select.js +293 -0
  41. package/dist/components/country-select.js.map +1 -0
  42. package/dist/components/currency-select.d.ts +17 -1
  43. package/dist/components/currency-select.js +56 -23
  44. package/dist/components/currency-select.js.map +1 -1
  45. package/dist/components/danger-confirm.d.ts +69 -166
  46. package/dist/components/danger-confirm.js +144 -50
  47. package/dist/components/danger-confirm.js.map +1 -1
  48. package/dist/components/data-table-cells.d.ts +1 -2
  49. package/dist/components/data-table-filter-popover.d.ts +1 -2
  50. package/dist/components/data-table-filters.d.ts +1 -2
  51. package/dist/components/data-table-labels.d.ts +4 -97
  52. package/dist/components/data-table-labels.js +9 -1
  53. package/dist/components/data-table-labels.js.map +1 -1
  54. package/dist/components/data-table-pagination.d.ts +3 -1
  55. package/dist/components/data-table.d.ts +1 -2
  56. package/dist/components/data-table.js +98 -3
  57. package/dist/components/data-table.js.map +1 -1
  58. package/dist/components/date-picker.d.ts +66 -5
  59. package/dist/components/date-picker.js +378 -66
  60. package/dist/components/date-picker.js.map +1 -1
  61. package/dist/components/entity-combobox.d.ts +42 -1
  62. package/dist/components/entity-combobox.js +154 -113
  63. package/dist/components/entity-combobox.js.map +1 -1
  64. package/dist/components/facing-pair.d.ts +19 -14
  65. package/dist/components/field-parts.d.ts +224 -0
  66. package/dist/components/field-parts.js +156 -0
  67. package/dist/components/field-parts.js.map +1 -0
  68. package/dist/components/field-strip.d.ts +105 -0
  69. package/dist/components/field-strip.js +46 -0
  70. package/dist/components/field-strip.js.map +1 -0
  71. package/dist/components/file-button.d.ts +19 -14
  72. package/dist/components/file-button.js +13 -3
  73. package/dist/components/file-button.js.map +1 -1
  74. package/dist/components/file-dropzone.d.ts +41 -15
  75. package/dist/components/file-dropzone.js +57 -34
  76. package/dist/components/file-dropzone.js.map +1 -1
  77. package/dist/components/form-actions.d.ts +18 -13
  78. package/dist/components/form-actions.js +69 -5
  79. package/dist/components/form-actions.js.map +1 -1
  80. package/dist/components/iban-input.d.ts +69 -0
  81. package/dist/components/iban-input.js +125 -0
  82. package/dist/components/iban-input.js.map +1 -0
  83. package/dist/components/icon-picker.d.ts +21 -2
  84. package/dist/components/icon-picker.js +19 -4
  85. package/dist/components/icon-picker.js.map +1 -1
  86. package/dist/components/inline-edit-field.d.ts +167 -0
  87. package/dist/components/inline-edit-field.js +293 -0
  88. package/dist/components/inline-edit-field.js.map +1 -0
  89. package/dist/components/language-select.d.ts +130 -0
  90. package/dist/components/language-select.js +18 -0
  91. package/dist/components/language-select.js.map +1 -0
  92. package/dist/components/line-items.d.ts +25 -3
  93. package/dist/components/line-items.js +55 -46
  94. package/dist/components/line-items.js.map +1 -1
  95. package/dist/components/list.d.ts +25 -0
  96. package/dist/components/list.js +16 -5
  97. package/dist/components/list.js.map +1 -1
  98. package/dist/components/money-field.d.ts +32 -14
  99. package/dist/components/money-field.js.map +1 -1
  100. package/dist/components/month-picker.d.ts +52 -4
  101. package/dist/components/month-picker.js +291 -86
  102. package/dist/components/month-picker.js.map +1 -1
  103. package/dist/components/multi-entity-combobox.d.ts +26 -1
  104. package/dist/components/multi-entity-combobox.js +143 -102
  105. package/dist/components/multi-entity-combobox.js.map +1 -1
  106. package/dist/components/multi-select.d.ts +8 -1
  107. package/dist/components/multi-select.js +9 -4
  108. package/dist/components/multi-select.js.map +1 -1
  109. package/dist/components/number-field.d.ts +34 -19
  110. package/dist/components/number-field.js +6 -20
  111. package/dist/components/number-field.js.map +1 -1
  112. package/dist/components/number-input.d.ts +79 -19
  113. package/dist/components/number-input.js +90 -38
  114. package/dist/components/number-input.js.map +1 -1
  115. package/dist/components/numpad-sheet.d.ts +19 -14
  116. package/dist/components/one-time-code-input.d.ts +100 -0
  117. package/dist/components/one-time-code-input.js +137 -0
  118. package/dist/components/one-time-code-input.js.map +1 -0
  119. package/dist/components/phone-input.d.ts +69 -0
  120. package/dist/components/phone-input.js +171 -0
  121. package/dist/components/phone-input.js.map +1 -0
  122. package/dist/components/reauth-dialog.d.ts +2 -2
  123. package/dist/components/reauth-dialog.js +6 -8
  124. package/dist/components/reauth-dialog.js.map +1 -1
  125. package/dist/components/series-chart.d.ts +19 -14
  126. package/dist/components/settings-fields.d.ts +19 -14
  127. package/dist/components/share-card.d.ts +16 -11
  128. package/dist/components/sign-chip.d.ts +66 -0
  129. package/dist/components/sign-chip.js +39 -0
  130. package/dist/components/sign-chip.js.map +1 -0
  131. package/dist/components/swatch-picker.d.ts +43 -2
  132. package/dist/components/swatch-picker.js +80 -32
  133. package/dist/components/swatch-picker.js.map +1 -1
  134. package/dist/components/switch.d.ts +19 -0
  135. package/dist/components/switch.js +24 -6
  136. package/dist/components/switch.js.map +1 -1
  137. package/dist/components/text-link.d.ts +18 -13
  138. package/dist/components/tile-radio.d.ts +88 -13
  139. package/dist/components/tile-radio.js +28 -11
  140. package/dist/components/tile-radio.js.map +1 -1
  141. package/dist/components/time-input.d.ts +24 -15
  142. package/dist/components/time-input.js.map +1 -1
  143. package/dist/components/toggle-group.d.ts +47 -1
  144. package/dist/components/toggle-group.js +110 -69
  145. package/dist/components/toggle-group.js.map +1 -1
  146. package/dist/components/trigger-aria.d.ts +3 -0
  147. package/dist/components/trigger-aria.js +3 -1
  148. package/dist/components/trigger-aria.js.map +1 -1
  149. package/dist/components/ui.d.ts +16 -11
  150. package/dist/components/ui.js +284 -88
  151. package/dist/components/ui.js.map +1 -1
  152. package/dist/components/use-table-state.d.ts +1 -2
  153. package/dist/{data-table-3mi2R3so.d.ts → data-table-labels-B7OdnM0S.d.ts} +140 -3
  154. package/dist/data-table.d.ts +1 -2
  155. package/dist/data-table.js +2 -1
  156. package/dist/data-table.js.map +1 -1
  157. package/dist/feedback/feedback-attachment.d.ts +1 -1
  158. package/dist/feedback/feedback-attachment.js +348 -41
  159. package/dist/feedback/feedback-attachment.js.map +1 -1
  160. package/dist/feedback/feedback-dialog.d.ts +1 -1
  161. package/dist/feedback/feedback-dialog.js.map +1 -1
  162. package/dist/feedback/feedback-inbox.d.ts +3 -2
  163. package/dist/feedback/feedback-inbox.js.map +1 -1
  164. package/dist/feedback/feedback-thread.d.ts +68 -253
  165. package/dist/feedback/feedback-thread.js +46 -10
  166. package/dist/feedback/feedback-thread.js.map +1 -1
  167. package/dist/{kit-labels-D1J2_jyD.d.ts → feedback-BxeQVzwq.d.ts} +1206 -20
  168. package/dist/{feedback-attachment-WFiXJ8We.d.ts → feedback-attachment-fGAzZPf0.d.ts} +178 -11
  169. package/dist/feedback.d.ts +66 -2
  170. package/dist/hooks/use-file-drop.d.ts +19 -14
  171. package/dist/i18n/defaults.d.ts +19 -14
  172. package/dist/i18n/defaults.js +19 -2
  173. package/dist/i18n/defaults.js.map +1 -1
  174. package/dist/i18n/german.d.ts +19 -14
  175. package/dist/i18n/german.js +84 -5
  176. package/dist/i18n/german.js.map +1 -1
  177. package/dist/i18n/kit-labels.d.ts +15 -10
  178. package/dist/i18n/kit-labels.js +8 -2
  179. package/dist/i18n/kit-labels.js.map +1 -1
  180. package/dist/i18n/languages.d.ts +19 -14
  181. package/dist/i18n/locales/de-CH.d.ts +19 -14
  182. package/dist/i18n/locales/en.d.ts +19 -14
  183. package/dist/i18n/locales/en.js +16 -0
  184. package/dist/i18n/locales/en.js.map +1 -1
  185. package/dist/i18n/locales/es.d.ts +19 -14
  186. package/dist/i18n/locales/es.js +83 -5
  187. package/dist/i18n/locales/es.js.map +1 -1
  188. package/dist/i18n/locales/fr.d.ts +19 -14
  189. package/dist/i18n/locales/fr.js +82 -5
  190. package/dist/i18n/locales/fr.js.map +1 -1
  191. package/dist/i18n/locales/hu.d.ts +19 -14
  192. package/dist/i18n/locales/hu.js +85 -5
  193. package/dist/i18n/locales/hu.js.map +1 -1
  194. package/dist/i18n/locales/it.d.ts +19 -14
  195. package/dist/i18n/locales/it.js +84 -5
  196. package/dist/i18n/locales/it.js.map +1 -1
  197. package/dist/i18n/locales/zh.d.ts +19 -14
  198. package/dist/i18n/locales/zh.js +80 -5
  199. package/dist/i18n/locales/zh.js.map +1 -1
  200. package/dist/i18n/review.d.ts +19 -14
  201. package/dist/i18n/review.js +27 -1
  202. package/dist/i18n/review.js.map +1 -1
  203. package/dist/index.d.ts +21 -10
  204. package/dist/index.js +73 -2
  205. package/dist/index.js.map +1 -1
  206. package/dist/lib/column-mapping.d.ts +81 -0
  207. package/dist/lib/column-mapping.js +108 -0
  208. package/dist/lib/column-mapping.js.map +1 -0
  209. package/dist/lib/countries.d.ts +43 -0
  210. package/dist/lib/countries.js +39 -0
  211. package/dist/lib/countries.js.map +1 -0
  212. package/dist/lib/decimal-marks.d.ts +46 -0
  213. package/dist/lib/decimal-marks.js +40 -0
  214. package/dist/lib/decimal-marks.js.map +1 -0
  215. package/dist/lib/iban.d.ts +98 -0
  216. package/dist/lib/iban.js +154 -0
  217. package/dist/lib/iban.js.map +1 -0
  218. package/dist/lib/isin.d.ts +30 -0
  219. package/dist/lib/isin.js +36 -0
  220. package/dist/lib/isin.js.map +1 -0
  221. package/dist/lib/phone.d.ts +69 -0
  222. package/dist/lib/phone.js +121 -0
  223. package/dist/lib/phone.js.map +1 -0
  224. package/dist/lib/table-text.d.ts +109 -1
  225. package/dist/lib/table-text.js +122 -1
  226. package/dist/lib/table-text.js.map +1 -1
  227. package/dist/rhf/fields.d.ts +233 -18
  228. package/dist/rhf/fields.js +456 -3
  229. package/dist/rhf/fields.js.map +1 -1
  230. package/dist/rhf/form.d.ts +19 -14
  231. package/dist/rhf.d.ts +22 -15
  232. package/dist/rhf.js.map +1 -1
  233. package/dist/shell/app-shell.d.ts +18 -13
  234. package/dist/shell/top-bar-brand.d.ts +19 -14
  235. package/dist/shell/topbar-action-menu.d.ts +36 -3
  236. package/dist/shell/topbar-action-menu.js +74 -33
  237. package/dist/shell/topbar-action-menu.js.map +1 -1
  238. package/dist/shell.d.ts +18 -13
  239. package/dist/table-text.d.ts +1 -1
  240. package/dist/tour/tour.js +20 -24
  241. package/dist/tour/tour.js.map +1 -1
  242. package/dist/wizard/stepper-nav.d.ts +49 -16
  243. package/dist/wizard/stepper-nav.js +4 -0
  244. package/dist/wizard/stepper-nav.js.map +1 -1
  245. package/dist/wizard.d.ts +19 -14
  246. package/package.json +1 -1
  247. package/src/components/account-chips.tsx +17 -3
  248. package/src/components/account-settings.tsx +92 -26
  249. package/src/components/amount-input.tsx +68 -56
  250. package/src/components/autocomplete.tsx +198 -114
  251. package/src/components/checkbox-group.tsx +229 -0
  252. package/src/components/checkbox.tsx +72 -8
  253. package/src/components/chip.tsx +80 -10
  254. package/src/components/column-mapper.tsx +666 -0
  255. package/src/components/combobox-core.tsx +82 -9
  256. package/src/components/combobox.tsx +661 -340
  257. package/src/components/confirm-dialog.tsx +12 -8
  258. package/src/components/country-select.tsx +556 -0
  259. package/src/components/currency-select.tsx +109 -33
  260. package/src/components/danger-confirm.tsx +429 -82
  261. package/src/components/data-table-labels.ts +16 -1
  262. package/src/components/data-table.tsx +175 -6
  263. package/src/components/date-picker.tsx +595 -71
  264. package/src/components/entity-combobox.tsx +235 -124
  265. package/src/components/field-parts.tsx +403 -0
  266. package/src/components/field-strip.tsx +149 -0
  267. package/src/components/file-button.tsx +36 -3
  268. package/src/components/file-dropzone.tsx +106 -38
  269. package/src/components/form-actions.tsx +147 -6
  270. package/src/components/iban-input.tsx +231 -0
  271. package/src/components/icon-picker.tsx +47 -6
  272. package/src/components/inline-edit-field.tsx +533 -0
  273. package/src/components/language-select.tsx +78 -0
  274. package/src/components/line-items.tsx +35 -2
  275. package/src/components/list.tsx +55 -6
  276. package/src/components/money-field.tsx +13 -0
  277. package/src/components/month-picker.tsx +400 -63
  278. package/src/components/multi-entity-combobox.tsx +207 -120
  279. package/src/components/multi-select.tsx +80 -58
  280. package/src/components/number-field.tsx +28 -46
  281. package/src/components/number-input.tsx +210 -43
  282. package/src/components/one-time-code-input.tsx +288 -0
  283. package/src/components/phone-input.tsx +299 -0
  284. package/src/components/reauth-dialog.tsx +17 -18
  285. package/src/components/sign-chip.tsx +103 -0
  286. package/src/components/swatch-picker.tsx +143 -31
  287. package/src/components/switch.tsx +58 -7
  288. package/src/components/tile-radio.tsx +145 -29
  289. package/src/components/time-input.tsx +8 -1
  290. package/src/components/toggle-group.tsx +206 -73
  291. package/src/components/trigger-aria.ts +5 -0
  292. package/src/components/ui.tsx +479 -138
  293. package/src/data-table.ts +2 -1
  294. package/src/feedback/feedback-attachment.tsx +597 -46
  295. package/src/feedback/feedback-dialog.tsx +9 -3
  296. package/src/feedback/feedback-inbox.tsx +3 -2
  297. package/src/feedback/feedback-thread.tsx +113 -9
  298. package/src/i18n/defaults.ts +18 -1
  299. package/src/i18n/german.ts +89 -0
  300. package/src/i18n/kit-labels.tsx +82 -2
  301. package/src/i18n/locales/en.ts +36 -4
  302. package/src/i18n/locales/es.ts +87 -0
  303. package/src/i18n/locales/fr.ts +87 -0
  304. package/src/i18n/locales/hu.ts +83 -0
  305. package/src/i18n/locales/it.ts +88 -0
  306. package/src/i18n/locales/zh.ts +76 -0
  307. package/src/i18n/review.ts +26 -0
  308. package/src/index.ts +71 -3
  309. package/src/lib/column-mapping.ts +234 -0
  310. package/src/lib/countries.ts +91 -0
  311. package/src/lib/decimal-marks.ts +99 -0
  312. package/src/lib/iban.ts +171 -0
  313. package/src/lib/isin.ts +62 -0
  314. package/src/lib/phone.ts +211 -0
  315. package/src/lib/table-text.ts +271 -0
  316. package/src/rhf/fields.tsx +860 -9
  317. package/src/rhf.ts +3 -1
  318. package/src/shell/topbar-action-menu.tsx +134 -38
  319. package/src/tour/tour.tsx +66 -28
  320. package/src/wizard/stepper-nav.tsx +35 -1
@@ -2,12 +2,13 @@ import { useId, useState } from "react";
2
2
  import type { ComponentProps, ReactNode } from "react";
3
3
  import { NumberInput } from "./number-input";
4
4
  import { evaluateExpression, formatResult } from "../lib/calc";
5
+ import { decimalMark } from "../lib/decimal-marks";
5
6
  import { useKitLocale } from "../i18n/kit-labels";
6
7
 
7
8
  type NumberInputProps = ComponentProps<typeof NumberInput>;
8
9
 
9
10
  export interface NumberFieldProps
10
- extends Omit<NumberInputProps, "value" | "onChange" | "onCommit" | "suffix" | "invalid"> {
11
+ extends Omit<NumberInputProps, "value" | "onChange" | "onCommit" | "suffix" | "invalid" | "error" | "locale"> {
11
12
  /** The committed number. `null` is "no value" — shown as an empty field. */
12
13
  value: number | null;
13
14
  /**
@@ -72,20 +73,6 @@ export interface NumberFieldProps
72
73
  "aria-required"?: boolean | "true" | "false";
73
74
  }
74
75
 
75
- /** The locale's decimal mark, as far as this field can type it: "," or ".". A mark
76
- * outside those two (Arabic's "٫") is not something {@link sanitizeLive} lets
77
- * through, so those locales keep the dot rather than get a field that eats keys. */
78
- function decimalMark(locale: string | undefined): "," | "." {
79
- try {
80
- const part = new Intl.NumberFormat(locale).formatToParts(1.5).find((p) => p.type === "decimal");
81
- return part?.value === "," ? "," : ".";
82
- } catch {
83
- // An invalid tag throws a RangeError; a typo in a locale prop must not take the
84
- // form down with it.
85
- return ".";
86
- }
87
- }
88
-
89
76
  /** What the draft reads as while it is being typed — see `onValueChange`. `undefined`
90
77
  * is "not a number yet", distinct from `null` (empty). */
91
78
  function parseLive(text: string): number | null | undefined {
@@ -109,10 +96,6 @@ function roundTo(n: number, digits: number | undefined): number {
109
96
  return Number.isFinite(rounded) ? rounded : n;
110
97
  }
111
98
 
112
- // The error line under a field — the same type as `ui.tsx`'s (module-private) one,
113
- // so a NumberField's message is indistinguishable from an Input's.
114
- const FIELD_ERROR_CLASS = "mt-1 text-[11px] leading-tight text-[var(--danger)]";
115
-
116
99
  /**
117
100
  * A number field whose value IS a number: {@link NumberInput}'s text, calculator and
118
101
  * numpad, with the parse/commit/revert loop every consumer was writing on top of it.
@@ -149,10 +132,20 @@ const FIELD_ERROR_CLASS = "mt-1 text-[11px] leading-tight text-[var(--danger)]";
149
132
  *
150
133
  * LOCALE. The decimal mark is the locale's: a German user sees and types "1,5".
151
134
  * NumberInput normalises every keystroke to a dot (so its evaluator reads one
152
- * alphabet); this field maps the dot back before the draft is shown, which also
153
- * means the numpad's "." key types the mark the field is displaying. There is no
154
- * digit grouping — "1.000" would read as a thousand in German and as one in English,
155
- * and a field that parses its own display back must not have that ambiguity.
135
+ * alphabet); this field maps the dot back before the draft is shown. The field never
136
+ * WRITES digit grouping — "1.000" would read as a thousand in German and as one in
137
+ * English, and a field that parses its own display back must not have that ambiguity —
138
+ * but since 0.22 it READS it, through NumberInput (keksdose K5): "1.234,56" typed by a
139
+ * German user commits 1234.56 rather than 1.23456, and "1,234.56" by an English one
140
+ * likewise. While a figure is typed the typist's own text stays on screen ("1.234,5",
141
+ * or a dot typed as the decimal in a comma locale); it is re-spelt in the locale's mark
142
+ * when it commits.
143
+ *
144
+ * NOT FOR MONEY. `digits={2}` rounds, but a currency is more than two decimals: JPY has
145
+ * none, a unit price has four, and the chip, the minor-unit default and `min`/`max` on
146
+ * the SIGNED figure are {@link MoneyField}'s (number value) and {@link AmountInput}'s
147
+ * (string value). A NumberField whose `unit` is a currency says so on the console in
148
+ * development — see NumberInput's money guard.
156
149
  */
157
150
  export function NumberField(props: NumberFieldProps) {
158
151
  const {
@@ -255,10 +248,6 @@ export function NumberField(props: NumberFieldProps) {
255
248
 
256
249
  const generatedId = useId();
257
250
  const fieldId = id ?? generatedId;
258
- const errorId = useId();
259
- // `null`/`false`/`""` are what `touched && errors.x` evaluates to on the happy path.
260
- const hasError = error !== undefined && error !== null && error !== false && error !== "";
261
- const describedBy = [ariaDescribedBy, hasError ? errorId : undefined].filter(Boolean).join(" ");
262
251
 
263
252
  // Straight onto NumberInput's <input>, which declares both now. They used to be set
264
253
  // on the DOM node from an effect, because NumberInput had no props for them.
@@ -277,11 +266,11 @@ export function NumberField(props: NumberFieldProps) {
277
266
  )
278
267
  : label;
279
268
 
280
- const field = (
269
+ return (
281
270
  <NumberInput
282
271
  {...rest}
283
272
  id={fieldId}
284
- aria-describedby={describedBy || undefined}
273
+ aria-describedby={ariaDescribedBy}
285
274
  aria-required={required || undefined}
286
275
  label={labelWithUnit}
287
276
  value={draft}
@@ -289,26 +278,19 @@ export function NumberField(props: NumberFieldProps) {
289
278
  onCommit={commit}
290
279
  min={min}
291
280
  max={max}
281
+ // The mark this field spells its draft in, so the two can never disagree when
282
+ // this field's `locale` is not the provider's.
283
+ locale={locale}
292
284
  suffix={unitPlacement === "suffix" ? unit : undefined}
293
- invalid={invalid || hasError || ariaInvalid === true || ariaInvalid === "true"}
285
+ invalid={invalid || ariaInvalid === true || ariaInvalid === "true"}
286
+ // NumberInput's `error` — the message, the description, the paint, and Input's
287
+ // steady box: no wrapper for a field that never takes `error`, and the SAME one
288
+ // for a field that does, message or not, so the message coming or going never
289
+ // remounts the <input> mid-keystroke. Only forwarded when it was passed, since
290
+ // passing the key is what asks for the box.
291
+ {...("error" in props ? { error } : {})}
294
292
  />
295
293
  );
296
- // Input's FieldGroup, restated: no wrapper for a field that never takes `error`, so
297
- // the prop changed nothing about the DOM a flex row lays out — and a STEADY one for a
298
- // field that does, message or not. Wrapping only while a message showed swapped the
299
- // element above the <input>, which remounted it: focus, caret and the half-typed
300
- // draft's keystrokes were lost the moment the message came or went.
301
- if (!hasError && !("error" in props)) return field;
302
- return (
303
- <div>
304
- {field}
305
- {hasError && (
306
- <p id={errorId} className={FIELD_ERROR_CLASS}>
307
- {error}
308
- </p>
309
- )}
310
- </div>
311
- );
312
294
  }
313
295
 
314
296
  /**
@@ -1,16 +1,25 @@
1
- import { useId, useLayoutEffect, useRef, useState } from "react";
1
+ import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
2
2
  import type { KeyboardEvent, ReactNode } from "react";
3
3
  import { CalculatorButton, type CalculatorButtonLabels } from "./calculator";
4
4
  import { NumberPadSheet, type NumberPadSheetLabels } from "./numpad-sheet";
5
5
  import { FIELD_BASE, FIELD_DISPLAY, FIELD_INVALID, FLOATING_INPUT_CLASS, FloatingField, PHONE_QUERY } from "./ui";
6
+ import { CURRENCIES } from "./currency-select";
7
+ import { hasMessage, mergeDescribedBy } from "./choice-parts";
6
8
  import { cn } from "../lib/cn";
7
- import { commitExpression, formatResult, sanitizeLive } from "../lib/calc";
9
+ import { commitExpression, formatResult } from "../lib/calc";
10
+ import { decimalMark, localizeMark, readTyped, showsTyped } from "../lib/decimal-marks";
8
11
  import { useMediaQuery } from "../hooks/use-media-query";
12
+ import { useKitLocale } from "../i18n/kit-labels";
9
13
 
10
14
  interface NumberInputProps {
15
+ /** The text, DOT-decimal ("1234.5", or a calculation being typed: "12+5"), as
16
+ * `onChange` reports it — so a caller's `Number(value)` reads it. The field SHOWS it
17
+ * in the locale's mark (see `locale`). A comma here is read as the decimal mark too,
18
+ * never as grouping. */
11
19
  value: string;
12
- /** Fired on every keystroke with the raw text (comma → dot, operators kept so
13
- * a typed expression survives). */
20
+ /** Fired on every keystroke with the text as a number reads it: grouping marks read
21
+ * off ("1.234,56" → "1234.56" for a German typist, keksdose K5), the decimal mark
22
+ * folded to a dot, operators kept so a typed expression survives. */
14
23
  onChange: (value: string) => void;
15
24
  /** Fired on blur/Enter with the *evaluated* value — use this for save-on-blur
16
25
  * fields so the save always sees the resolved number, never "10+5". */
@@ -60,6 +69,29 @@ interface NumberInputProps {
60
69
  hint?: ReactNode;
61
70
  /** Required and unanswered — {@link FIELD_INVALID}. See {@link Input}'s `invalid`. */
62
71
  invalid?: boolean;
72
+ /**
73
+ * What is wrong with the value, in the caller's own words — {@link Input}'s `error`,
74
+ * the same anatomy (keksdose K5/K4: of the kit's fields only `Input` and
75
+ * `NumberField` had one, so a goal target or a rate field wrote its message as a
76
+ * loose `<p>` that nothing pointed at). Rendered under the field, after a text
77
+ * `hint`; merged into `aria-describedby` after the caller's ids and the hint's; and
78
+ * it implies `invalid`, so the field paints as well as announces. `null`, `false`
79
+ * and `""` — a `touched && errors.x` on the happy path — are no message.
80
+ *
81
+ * Passing the key at all (even `undefined`) keeps the field in a steady wrapper,
82
+ * message or not, so the message coming and going never remounts the `<input>` and
83
+ * takes the caret with it — see `Input`'s `FieldGroup`. A field that never passes
84
+ * `error` renders exactly the DOM it did before the prop existed.
85
+ */
86
+ error?: ReactNode;
87
+ /**
88
+ * BCP 47 tag that decides the decimal mark the field SHOWS; default the
89
+ * `<UiKitProvider locale>`, else the runtime's (keksdose K5). A French or Italian
90
+ * reader sees "3,5", a German one too, de-CH and en-US see "3.5" — `Intl`
91
+ * decides, not a guess about a country. Display only: `value` and `onChange` stay
92
+ * dot-decimal. {@link NumberField} passes its own `locale` through here.
93
+ */
94
+ locale?: string;
63
95
  /**
64
96
  * Set on the `<input>`, with `id` above. Declared so `Field`'s render-prop spread —
65
97
  * `{(ids) => <NumberInput {...ids} … />}` — is typed and reaches the input: the hint
@@ -145,45 +177,128 @@ export function stepNumber(
145
177
  return clamp(Number((origin + k * step).toFixed(places)));
146
178
  }
147
179
 
180
+ // The error line under a field — the type of `ui.tsx`'s (module-private) one, so a
181
+ // NumberInput's message is indistinguishable from an Input's.
182
+ const FIELD_ERROR_CLASS = "mt-1 text-[11px] leading-tight text-[var(--danger)]";
183
+
184
+ // ── The money guard (kastlan 5, Kurvenschmiede) ─────────────────────────────────
185
+
186
+ // The codes the kit's own currency pickers offer, as whole words: "Amount (CHF)",
187
+ // "EUR", "Kosten CHF". Not every ISO 4217 code — "ALL", "TOP" and "PEN" are codes too,
188
+ // and an upper-case label would trip on them.
189
+ const CURRENCY_CODE = new RegExp(`(?:^|[^A-Za-z])(?:${CURRENCIES.map((c) => c.code).join("|")})(?![A-Za-z])`);
190
+ // Symbols no unit of measure is written with. Not "kr", "R" or "Fr": those are words
191
+ // and units as often as they are money.
192
+ const CURRENCY_SYMBOL = /[€$£¥₹₺₽₪₩₫฿₴₦₱]/;
193
+
194
+ /** Text that names a currency and no unit to go with it. A slash makes it a rate or
195
+ * a unit price ("CHF/m²", "€/kWh") — a figure whose decimals are its column's
196
+ * business, not the currency's — and that is not what the guard is for. */
197
+ function namesMoney(node: ReactNode): boolean {
198
+ if (typeof node !== "string" && typeof node !== "number") return false;
199
+ const text = String(node);
200
+ return (CURRENCY_CODE.test(text) || CURRENCY_SYMBOL.test(text)) && !text.includes("/") && !text.includes("%");
201
+ }
202
+
203
+ /**
204
+ * Is this field being used for MONEY? A currency in the unit (`suffix="CHF"`), or in
205
+ * the label, `ariaLabel` or placeholder of a field with no unit at all ("Estimated
206
+ * cost (CHF)"). Any other suffix is a unit — "%", "mm", "N·m" — and says what the
207
+ * digits are, whatever the label says. `digits` plays no part: NumberInput has none,
208
+ * and Kurvenschmiede's engineering fields at 2 and 3 decimals are exactly what this
209
+ * must stay quiet about.
210
+ */
211
+ function looksLikeMoney(suffix: ReactNode, texts: ReactNode[]): boolean {
212
+ if (suffix !== undefined && suffix !== null && suffix !== "") return namesMoney(suffix);
213
+ return texts.some(namesMoney);
214
+ }
215
+
216
+ /**
217
+ * The kit's dev switch, as `lib/logger.ts` reads it: a development build, never a
218
+ * production one — the consumer's bundler replaces `import.meta.env.DEV` with `false`
219
+ * there, and this branch is dead code — and never under vitest, where every consumer
220
+ * suite would print it once per render of every field it has not migrated yet. Read
221
+ * per call rather than once per module, so a test can switch it on with `vi.stubEnv`.
222
+ * Optional-chained: `import.meta.env` is a Vite injection, absent under plain Node.
223
+ */
224
+ function devWarnings(): boolean {
225
+ return Boolean(import.meta.env?.DEV) && !import.meta.env?.VITEST;
226
+ }
227
+
148
228
  /**
149
229
  * A numeric text field with a built-in calculator: type a calculation straight
150
230
  * in (e.g. "12+5", evaluated on blur/Enter) or click the trailing calculator
151
231
  * icon for a keypad. String `value`/`onChange` contract, mirroring
152
- * {@link AmountInput} — the currency-free counterpart for budgets, goals,
153
- * invoice totals and split lines.
232
+ * {@link AmountInput} — the currency-free counterpart for quantities, rates,
233
+ * percentages and measurements.
234
+ *
235
+ * NOT FOR MONEY. This field never rounds: "12.345", or a calculator's "100/3", is
236
+ * reported exactly as typed. kastlan's journal lines, a payment allocation and a
237
+ * defect's estimated cost were NumberInputs, and a 3-decimal amount reached a
238
+ * 2-decimal Money column and failed the save with a 422 — in one wizard after the
239
+ * record before it had already been created (kastlan 5). Money goes in
240
+ * {@link AmountInput} (string value, like this one) or {@link MoneyField} (number
241
+ * value): both settle to the currency's minor unit on blur, Enter and every calculator
242
+ * result, and take `digits` for a unit price at a finer scale. In development a
243
+ * NumberInput that looks like money — a currency as its unit, or a currency in the
244
+ * label of a field with no unit — says so on the console, once per field. A unit of
245
+ * measure ("mm", "N·m", "%") keeps it quiet, as does any number of decimals.
246
+ *
247
+ * THE LOCALE'S MARK (keksdose K5). The field shows the `locale`'s decimal mark — "3,5"
248
+ * to a French, Italian or German reader — and reads what is typed the way
249
+ * {@link AmountInput} does (keksdose G1): "1.234,56" from a German typist is 1234.56,
250
+ * "1,234.56" from an English one too, and a lone mark that cannot be grouping is the
251
+ * decimal, whichever key typed it. Before, it always showed "3.5" and folded
252
+ * "1.234,56" into 1.23456. `value` and `onChange` stay dot-decimal, which is the
253
+ * contract every caller's `Number(value)` reads.
254
+ *
255
+ * While a figure is being typed the field shows the typist's OWN text — "1.234,5"
256
+ * stays exactly that — rather than the value re-spelt: re-spelling turned a grouping
257
+ * "." into a "," under their fingers, after which no rule could tell the two apart, and
258
+ * a text that changes length under the caret moves it to the end. It is re-spelt in the
259
+ * locale's mark, without grouping, once the figure settles (blur, Enter, a step or a
260
+ * calculator result) or when the value changes from outside.
154
261
  */
155
- export function NumberInput({
156
- value,
157
- onChange,
158
- onCommit,
159
- label,
160
- ariaLabel,
161
- labels,
162
- placeholder,
163
- disabled,
164
- autoFocus,
165
- className,
166
- inputClassName,
167
- id,
168
- calculator = true,
169
- variant = "field",
170
- hint,
171
- invalid: invalidProp,
172
- "aria-describedby": ariaDescribedBy,
173
- "aria-invalid": ariaInvalid,
174
- "aria-required": ariaRequired,
175
- suffix,
176
- step,
177
- min,
178
- max,
179
- }: NumberInputProps) {
262
+ export function NumberInput(props: NumberInputProps) {
263
+ const {
264
+ value,
265
+ onChange,
266
+ onCommit,
267
+ label,
268
+ ariaLabel,
269
+ labels,
270
+ placeholder,
271
+ disabled,
272
+ autoFocus,
273
+ className,
274
+ inputClassName,
275
+ id,
276
+ calculator = true,
277
+ variant = "field",
278
+ hint,
279
+ invalid: invalidProp,
280
+ error,
281
+ locale: localeProp,
282
+ "aria-describedby": ariaDescribedBy,
283
+ "aria-invalid": ariaInvalid,
284
+ "aria-required": ariaRequired,
285
+ suffix,
286
+ step,
287
+ min,
288
+ max,
289
+ } = props;
180
290
  const generatedId = useId();
181
291
  const fieldId = id ?? generatedId;
182
292
  const hintId = useId();
293
+ const errorId = useId();
183
294
  // Text is a caption under the field; a FieldHint rides the label line. See `hint`.
184
295
  const textHint = (typeof hint === "string" && hint !== "") || typeof hint === "number";
185
- const describedBy = textHint ? (ariaDescribedBy ? `${ariaDescribedBy} ${hintId}` : hintId) : ariaDescribedBy;
186
- const invalid = Boolean(invalidProp) || ariaInvalid === true || ariaInvalid === "true";
296
+ const hasError = hasMessage(error);
297
+ // Passed at all — even as `undefined` — the field keeps its box; see `error`.
298
+ const reserve = "error" in props || "hint" in props;
299
+ // The standing advice first, the news second: the caller's ids, the caption, the error.
300
+ const describedBy = mergeDescribedBy(ariaDescribedBy, textHint && hintId, hasError && errorId);
301
+ const invalid = Boolean(invalidProp) || hasError || ariaInvalid === true || ariaInvalid === "true";
187
302
  const labelled = label !== undefined;
188
303
  // On phones we suppress the OS keyboard (inputMode="none" below) for our own
189
304
  // calculator numpad, so the desktop popover trigger is hidden (feedback #334).
@@ -219,13 +334,52 @@ export function NumberInput({
219
334
  const showNumpad = isMobile && !disabled && focused;
220
335
  const inputRef = useRef<HTMLInputElement>(null);
221
336
 
337
+ // The mark is the locale's; see the component's note. `derived` is the value in that
338
+ // mark — one character for one, no grouping — and `draft` the typist's own text,
339
+ // shown only while it still says what the value says (see `showsTyped`).
340
+ const mark = decimalMark(useKitLocale(localeProp));
341
+ const [draft, setDraft] = useState<string | null>(null);
342
+ const derived = localizeMark(value, mark);
343
+ const display = draft !== null && showsTyped(draft, mark, value) ? draft : derived;
344
+
345
+ // A keystroke, a paste or a numpad key: the typist's text is kept, the value is what
346
+ // a number reads it as.
347
+ const typed = (raw: string) => {
348
+ setDraft(raw);
349
+ onChange(readTyped(raw, mark));
350
+ };
351
+ // A RESULT — the calculator's, the numpad's "=" — is dot-decimal already. It does not
352
+ // go through `typed`: "1.234" out of `100/81.03…` is never a thousand.
353
+ const result = (next: string) => {
354
+ setDraft(null);
355
+ onChange(next);
356
+ };
357
+
358
+ // The money guard: once per field, development only. See `looksLikeMoney`.
359
+ const warnedMoney = useRef(false);
360
+ useEffect(() => {
361
+ if (warnedMoney.current || !devWarnings()) return;
362
+ if (!looksLikeMoney(suffix, [label, ariaLabel, placeholder])) return;
363
+ warnedMoney.current = true;
364
+ const name = [label, ariaLabel, suffix].find((t) => typeof t === "string" || typeof t === "number");
365
+ console.warn(
366
+ `[ui-kit] NumberInput${name === undefined ? "" : ` "${String(name)}"`} looks like a money field: a ` +
367
+ `currency, and no unit. NumberInput never rounds, so "12.345" or a calculator's "100/3" is sent with ` +
368
+ `three or more decimals (kastlan 5: a 422 from a 2-decimal Money column). Use AmountInput (string ` +
369
+ `value) or MoneyField (number value) — both settle to the currency's minor unit. ` +
370
+ `(Development builds only; once per field.)`,
371
+ );
372
+ }, [suffix, label, ariaLabel, placeholder]);
373
+
222
374
  const commit = () => {
375
+ setDraft(null);
223
376
  const next = commitExpression(value);
224
377
  if (next !== value) onChange(next);
225
378
  onCommit?.(next);
226
379
  };
227
380
 
228
381
  const stepBy = (count: number) => {
382
+ setDraft(null);
229
383
  // The step starts from the text as a commit would read it, so "12+5" steps from 17.
230
384
  const resolved = commitExpression(value).trim();
231
385
  const n = resolved === "" ? NaN : Number(resolved);
@@ -269,8 +423,8 @@ export function NumberInput({
269
423
  // and an empty borderless figure shows nothing — so it keeps whatever
270
424
  // placeholder the caller gave, falling back to a zero to aim at.
271
425
  placeholder={asDisplay ? (placeholder ?? "0") : labelled ? " " : placeholder}
272
- value={value}
273
- onChange={(e) => onChange(sanitizeLive(e.target.value))}
426
+ value={display}
427
+ onChange={(e) => typed(e.target.value)}
274
428
  onFocus={() => setFocused(true)}
275
429
  onBlur={() => {
276
430
  commit();
@@ -314,8 +468,10 @@ export function NumberInput({
314
468
  <div ref={endRef} className="absolute inset-y-0 end-0 flex items-center">
315
469
  {showCalc && (
316
470
  <CalculatorButton
317
- value={value}
318
- onChange={onChange}
471
+ // Seeded with the value in the field's mark but WITHOUT the typist's
472
+ // grouping: the evaluator reads either mark, but "1.234,5" is two of them.
473
+ value={derived}
474
+ onChange={result}
319
475
  className="self-stretch px-2.5"
320
476
  ariaLabel={labels?.calculatorTrigger}
321
477
  labels={labels?.calculator}
@@ -335,25 +491,36 @@ export function NumberInput({
335
491
  )}
336
492
  {showNumpad && (
337
493
  <NumberPadSheet
338
- value={value}
339
- onChange={onChange}
494
+ // Localised like the field it mirrors, as AmountInput's is: its keys are
495
+ // typing, read by the locale's marks like the keyboard's; "=" is a result.
496
+ value={display}
497
+ onChange={typed}
498
+ onResult={result}
340
499
  onDone={() => inputRef.current?.blur()}
341
500
  label={label}
342
501
  labels={labels?.pad}
502
+ decimalMark={mark}
343
503
  />
344
504
  )}
345
505
  </FloatingField>
346
506
  );
347
- if (!textHint) return field;
507
+ if (!textHint && !hasError && !reserve) return field;
348
508
  // Outside the field's own `relative` box, as Select's caption is: the calculator
349
509
  // and the unit are `inset-y-0` in it and would stretch down over a second line.
350
510
  // `className` stays on the field, so adding a caption cannot change what it styles.
351
511
  return (
352
512
  <div>
353
513
  {field}
354
- <p id={hintId} className="mt-1 text-[11px] leading-tight text-[var(--text-muted)]">
355
- {hint}
356
- </p>
514
+ {textHint && (
515
+ <p id={hintId} className="mt-1 text-[11px] leading-tight text-[var(--text-muted)]">
516
+ {hint}
517
+ </p>
518
+ )}
519
+ {hasError && (
520
+ <p id={errorId} className={FIELD_ERROR_CLASS}>
521
+ {error}
522
+ </p>
523
+ )}
357
524
  </div>
358
525
  );
359
526
  }