@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
@@ -0,0 +1,231 @@
1
+ import { forwardRef, useCallback, useLayoutEffect, useRef, useState } from "react";
2
+ import type { ChangeEvent, FocusEvent, ReactNode } from "react";
3
+ import { useKitLabels } from "../i18n/kit-labels";
4
+ import { cn } from "../lib/cn";
5
+ import { IBAN_LENGTHS, compactIban, formatIban, ibanProblem } from "../lib/iban";
6
+ import type { IbanKind, IbanProblem } from "../lib/iban";
7
+ import { assignRef, hasMessage } from "./choice-parts";
8
+ import { Input, type InputProps } from "./ui";
9
+
10
+ // ── Labels ────────────────────────────────────────────────────────────────────
11
+
12
+ /** The messages {@link IbanInput} shows under itself, one per {@link IbanProblem}. */
13
+ export interface IbanInputLabels {
14
+ /** The value does not start with a two-letter country code and two check digits. */
15
+ format: string;
16
+ /** The first two letters are no country in the IBAN registry. `code` is those two
17
+ * letters, as typed (upper case). */
18
+ country: (code: string) => string;
19
+ /** The wrong length for the country: `actual` characters typed, `expected` for that
20
+ * country (spaces not counted in either). */
21
+ length: (actual: number, expected: number) => string;
22
+ /** The mod-97 check digits do not match — a mistyped or swapped character. */
23
+ checksum: string;
24
+ /** `kind="qr"`: a valid IBAN, but not a QR-IBAN. */
25
+ qrRequired: string;
26
+ /** `kind="plain"`: a QR-IBAN, which cannot receive an ordinary transfer. */
27
+ qrNotAllowed: string;
28
+ }
29
+
30
+ export const DEFAULT_IBAN_INPUT_LABELS: IbanInputLabels = {
31
+ format: "An IBAN starts with a two-letter country code and two check digits.",
32
+ country: (code) => `“${code}” is not the country code of an IBAN.`,
33
+ length: (actual, expected) =>
34
+ `An IBAN from this country has ${expected} characters — this one has ${actual}.`,
35
+ checksum: "The check digits don’t match — a character is probably mistyped.",
36
+ qrRequired: "This is a regular IBAN. A QR-bill needs the account’s QR-IBAN.",
37
+ qrNotAllowed: "This is a QR-IBAN, which only receives QR-bill payments. Enter the account’s regular IBAN.",
38
+ };
39
+
40
+ /**
41
+ * The `ibanInput` namespace: prop > `<UiKitProvider labels>` > English (0.22.0).
42
+ */
43
+ function useIbanInputLabels(prop: Partial<IbanInputLabels> | undefined): IbanInputLabels {
44
+ const labels: IbanInputLabels = useKitLabels("ibanInput", DEFAULT_IBAN_INPUT_LABELS, prop);
45
+ return labels;
46
+ }
47
+
48
+ // ── Helpers ───────────────────────────────────────────────────────────────────
49
+
50
+ const alnumCount = (text: string) => text.replace(/[^0-9A-Za-z]/g, "").length;
51
+
52
+ /** The index just after the `count`-th letter or digit of `text` — where the caret
53
+ * goes once the grouping has moved the characters it was between. */
54
+ function indexAfter(text: string, count: number): number {
55
+ if (count <= 0) return 0;
56
+ let seen = 0;
57
+ for (let i = 0; i < text.length; i++) {
58
+ if (/[0-9A-Za-z]/.test(text[i])) seen += 1;
59
+ if (seen === count) return i + 1;
60
+ }
61
+ return text.length;
62
+ }
63
+
64
+ /**
65
+ * The problem worth SHOWING right now. Settled (not focused): whatever is wrong. While
66
+ * typing: only what more typing cannot fix — a broken start or an unknown country from
67
+ * the fourth character, and the rest once the country's full length is there. A
68
+ * half-typed IBAN is not wrong, and a field that says "wrong length" from the first
69
+ * keystroke is a field people learn to ignore.
70
+ */
71
+ function visibleProblem(compact: string, kind: IbanKind, focused: boolean): IbanProblem | null {
72
+ if (compact === "") return null;
73
+ const problem = ibanProblem(compact, kind);
74
+ if (problem === null || !focused) return problem;
75
+ if (compact.length < 4) return null;
76
+ if (problem === "format" || problem === "country") return problem;
77
+ return compact.length >= IBAN_LENGTHS[compact.slice(0, 2)]! ? problem : null;
78
+ }
79
+
80
+ function messageFor(problem: IbanProblem, compact: string, labels: IbanInputLabels): string {
81
+ switch (problem) {
82
+ case "format":
83
+ return labels.format;
84
+ case "country":
85
+ return labels.country(compact.slice(0, 2));
86
+ case "length":
87
+ return labels.length(compact.length, IBAN_LENGTHS[compact.slice(0, 2)]!);
88
+ case "checksum":
89
+ return labels.checksum;
90
+ case "qrRequired":
91
+ return labels.qrRequired;
92
+ case "qrNotAllowed":
93
+ return labels.qrNotAllowed;
94
+ }
95
+ }
96
+
97
+ // ── Component ─────────────────────────────────────────────────────────────────
98
+
99
+ export interface IbanInputProps extends Omit<InputProps, "type" | "value" | "defaultValue"> {
100
+ /** The IBAN, compact or not — it is shown in groups of four either way. `""` is
101
+ * "no IBAN". */
102
+ value: string;
103
+ /** The compact, upper-case IBAN (the electronic format, what to store) on every
104
+ * change — valid or not: a half-typed IBAN is still the field's value. Plain
105
+ * `onChange` still fires alongside it with the raw event, whose text is the GROUPED
106
+ * one, so bind this rather than `onChange` to a form. */
107
+ onValueChange?: (value: string) => void;
108
+ /** Which IBANs the field accepts. Default `"any"`. `"qr"` / `"plain"`: see
109
+ * {@link IbanKind} — and why the difference matters, {@link isQrIban}. */
110
+ kind?: IbanKind;
111
+ /** The messages, per field. See {@link IbanInputLabels}. */
112
+ labels?: Partial<IbanInputLabels>;
113
+ /** The caller's own message, shown INSTEAD of the built-in one while it is set (a
114
+ * server's "this account is already on file"). `null`, `false` and `""` are no
115
+ * message, so `touched && errors.iban` can be passed as it is. */
116
+ error?: ReactNode;
117
+ }
118
+
119
+ /**
120
+ * An IBAN field (kastlan, keksdose): typed in any shape, shown in groups of four,
121
+ * reported compact.
122
+ *
123
+ * Both apps had a text field and a regex. What that left out is everything between
124
+ * the keyboard and the stored value: a pasted "ch09 0000-0000…" stayed lower case
125
+ * with its hyphens, the grouping that makes 21 characters checkable by eye was
126
+ * missing, and a wrong IBAN was found out by the bank. Here:
127
+ *
128
+ * - **Grouped as you type.** The field shows the paper format ({@link formatIban});
129
+ * spaces, hyphens and lower case in what is typed or pasted are folded away, and
130
+ * the caret stays between the same two characters while the groups move around it.
131
+ * - **Compact out.** `onValueChange` gets the electronic format — upper case, no
132
+ * spaces — which is the one to store and to compare.
133
+ * - **Checked, never blocked.** A wrong country, length or checksum shows a message
134
+ * under the field, through the kit's `error` (so it is the field's
135
+ * `aria-describedby` and paints it), but every keystroke still lands: a field that
136
+ * refuses characters cannot be corrected one character at a time. While the field
137
+ * has focus only what more typing cannot fix is shown; the rest waits for the full
138
+ * length or for blur. Whether the field is REQUIRED stays the form's rule — an empty
139
+ * field says nothing.
140
+ * - **QR-IBAN or not** (kastlan): `kind="qr"` refuses a regular IBAN and
141
+ * `kind="plain"` refuses a QR-IBAN — a QR-bill can only be paid to a QR-IBAN, and a
142
+ * QR-IBAN can receive nothing else. See {@link isQrIban}.
143
+ *
144
+ * It is {@link Input} underneath, so `label`, `hint`, `error`, `invalid` and the rest
145
+ * are Input's own and the field reads like every other. A form that must not submit
146
+ * an invalid IBAN checks {@link isValidIban} / {@link ibanProblem} itself — the field
147
+ * shows the problem but does not own the submit.
148
+ */
149
+ export const IbanInput = forwardRef<HTMLInputElement, IbanInputProps>(function IbanInput(
150
+ { value, onValueChange, onChange, onFocus, onBlur, kind = "any", labels, error, inputClassName, ...rest },
151
+ ref,
152
+ ) {
153
+ const text = useIbanInputLabels(labels);
154
+ const compact = compactIban(value);
155
+ const shown = formatIban(compact);
156
+ const [focused, setFocused] = useState(false);
157
+ const innerRef = useRef<HTMLInputElement | null>(null);
158
+ // Letters and digits before the caret, kept across the re-render that regroups.
159
+ const caretRef = useRef<number | null>(null);
160
+ const setRefs = useCallback(
161
+ (el: HTMLInputElement | null) => {
162
+ innerRef.current = el;
163
+ assignRef(ref, el);
164
+ },
165
+ [ref],
166
+ );
167
+
168
+ // Every render: when a change moved the groups, put the caret back after the same
169
+ // character it followed. Only while focused — a value set from outside moves nothing.
170
+ useLayoutEffect(() => {
171
+ const el = innerRef.current;
172
+ const count = caretRef.current;
173
+ if (el === null || count === null) return;
174
+ caretRef.current = null;
175
+ if (el.ownerDocument.activeElement !== el) return;
176
+ const at = indexAfter(el.value, count);
177
+ el.setSelectionRange(at, at);
178
+ });
179
+
180
+ const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
181
+ onChange?.(e);
182
+ const el = e.target;
183
+ const raw = el.value;
184
+ const next = compactIban(raw);
185
+ // Characters compactIban dropped from the front (a pasted "IBAN " label) are not
186
+ // in the value, so they are not counted before the caret either.
187
+ const dropped = alnumCount(raw) - next.length;
188
+ const count = Math.max(0, alnumCount(raw.slice(0, el.selectionStart ?? raw.length)) - dropped);
189
+ if (next === compact) {
190
+ // Nothing the value holds changed — a space typed, a group's space deleted — so
191
+ // no re-render will come to regroup it. Regroup now, and keep the caret: after
192
+ // a Backspace on a group's space, that is just before it.
193
+ el.value = shown;
194
+ const at = indexAfter(shown, count);
195
+ el.setSelectionRange(at, at);
196
+ } else {
197
+ caretRef.current = count;
198
+ }
199
+ onValueChange?.(next);
200
+ };
201
+
202
+ const problem = visibleProblem(compact, kind, focused);
203
+ return (
204
+ <Input
205
+ ref={setRefs}
206
+ {...rest}
207
+ type="text"
208
+ value={shown}
209
+ onChange={handleChange}
210
+ onFocus={(e: FocusEvent<HTMLInputElement>) => {
211
+ setFocused(true);
212
+ onFocus?.(e);
213
+ }}
214
+ onBlur={(e: FocusEvent<HTMLInputElement>) => {
215
+ setFocused(false);
216
+ onBlur?.(e);
217
+ }}
218
+ autoComplete={rest.autoComplete ?? "off"}
219
+ autoCapitalize={rest.autoCapitalize ?? "characters"}
220
+ autoCorrect={rest.autoCorrect ?? "off"}
221
+ spellCheck={rest.spellCheck ?? false}
222
+ // ALWAYS passed, even while there is no message: a field that takes `error`
223
+ // keeps its box, so the message coming and going under the caret never remounts
224
+ // the input (see Input's FieldGroup) — this one comes and goes as you type.
225
+ error={hasMessage(error) ? error : problem ? messageFor(problem, compact, text) : undefined}
226
+ // Groups of four line up only when every digit is the same width.
227
+ inputClassName={cn("tabular-nums", inputClassName)}
228
+ />
229
+ );
230
+ });
231
+ IbanInput.displayName = "IbanInput";
@@ -1,10 +1,11 @@
1
1
  import { useId, useMemo, useState } from "react";
2
- import type { ComponentPropsWithoutRef, ComponentType } from "react";
2
+ import type { ComponentPropsWithoutRef, ComponentType, ReactNode } from "react";
3
3
  import { cn } from "../lib/cn";
4
4
  import { DEFAULT_COMMON_LABELS, useKitLabels, useKitLocale } from "../i18n/kit-labels";
5
5
  import { SearchField } from "./search-field";
6
6
  import { TILE_SIZE, TileRadioGroup } from "./tile-radio";
7
7
  import type { TileItem, TileSize } from "./tile-radio";
8
+ import { hasContent, LabelStrip } from "./field-parts";
8
9
 
9
10
  export interface IconPickerLabels {
10
11
  /** The "no icon" tile's name, when `allowNone` is set. */
@@ -40,7 +41,16 @@ export interface IconOption<T extends string> {
40
41
  /** A remark read after the name and shown under it in the bubble ("used by
41
42
  * Leisure"). The tile wears a dot while it has one. */
42
43
  note?: string;
44
+ /** Out of the choice and out of reach — skipped by the arrow keys and Tab. With a
45
+ * `disabledReason` the tile stays reachable instead and says why. */
43
46
  disabled?: boolean;
47
+ /**
48
+ * Why THIS symbol cannot be chosen right now — {@link SwatchOption.disabledReason}'s
49
+ * twin: the tile is `aria-disabled` but reachable by Tab and the arrow keys, the reason
50
+ * shows in its bubble under its name and describes it, and choosing it does nothing.
51
+ * Wins over `disabled`. Kept through a search like any other tile.
52
+ */
53
+ disabledReason?: ReactNode;
44
54
  }
45
55
 
46
56
  export interface IconPickerProps<T extends string>
@@ -76,6 +86,16 @@ export interface IconPickerProps<T extends string>
76
86
  tileClassName?: string;
77
87
  disabled?: boolean;
78
88
  labels?: Partial<IconPickerLabels>;
89
+ /**
90
+ * An 11px static label in a strip over the picker, naming the grid — keksdose K2,
91
+ * {@link SwatchPickerProps.label}'s twin: a symbol picker standing in a form row
92
+ * beside labelled fields, with its label on their line. With `searchable` the strip
93
+ * is over the search box too, which keeps its own name. With a label, `className`
94
+ * styles the wrapper.
95
+ */
96
+ label?: ReactNode;
97
+ /** A {@link FieldHint} beside the label. Only with `label`. */
98
+ hint?: ReactNode;
79
99
  }
80
100
 
81
101
  /** Case- and accent-insensitive, so "cafe" finds "Café". */
@@ -105,6 +125,8 @@ export function IconPicker<T extends string>({
105
125
  tileClassName,
106
126
  disabled = false,
107
127
  labels,
128
+ label,
129
+ hint,
108
130
  className,
109
131
  "aria-label": ariaLabel,
110
132
  "aria-labelledby": ariaLabelledBy,
@@ -115,6 +137,8 @@ export function IconPicker<T extends string>({
115
137
  const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
116
138
  const locale = useKitLocale();
117
139
  const mixedId = useId();
140
+ const labelId = useId();
141
+ const labelled = hasContent(label);
118
142
  const [query, setQuery] = useState("");
119
143
  const needle = fold(query.trim(), locale);
120
144
 
@@ -132,11 +156,17 @@ export function IconPicker<T extends string>({
132
156
  // "None" stays through a search: it is the way back to no icon, and it matches
133
157
  // no word anybody would type for it.
134
158
  ...(allowNone ? [{ key: null, label: text.none }] : []),
135
- ...visible.map((o) => ({ key: o.value, label: o.label, note: o.note, disabled: o.disabled })),
159
+ ...visible.map((o) => ({
160
+ key: o.value,
161
+ label: o.label,
162
+ note: o.note,
163
+ disabled: o.disabled,
164
+ disabledReason: o.disabledReason,
165
+ })),
136
166
  ];
137
167
 
138
- return (
139
- <div {...rest} className={cn("space-y-2", className)}>
168
+ const picker = (
169
+ <div {...rest} className={cn("space-y-2", !labelled && className)}>
140
170
  {searchable && (
141
171
  <SearchField
142
172
  value={query}
@@ -149,7 +179,8 @@ export function IconPicker<T extends string>({
149
179
  <div
150
180
  role="radiogroup"
151
181
  aria-label={ariaLabel}
152
- aria-labelledby={ariaLabelledBy}
182
+ // The strip's label names the GRID, unless the caller named it already.
183
+ aria-labelledby={labelled && ariaLabel === undefined && ariaLabelledBy === undefined ? labelId : ariaLabelledBy}
153
184
  aria-describedby={[ariaDescribedBy, mixed && mixedId].filter(Boolean).join(" ") || undefined}
154
185
  aria-disabled={disabled || undefined}
155
186
  className="flex flex-wrap items-center gap-1.5"
@@ -157,7 +188,11 @@ export function IconPicker<T extends string>({
157
188
  <TileRadioGroup
158
189
  items={items}
159
190
  checked={mixed ? undefined : value}
160
- onSelect={onChange}
191
+ // A locked tile is refused by the tiles themselves; asked again here so a
192
+ // reason can never become a change, whatever the tiles do.
193
+ onSelect={(next) => {
194
+ if (!hasContent(next === null ? undefined : byValue.get(next)?.disabledReason)) onChange(next);
195
+ }}
161
196
  activation={activation}
162
197
  disabled={disabled}
163
198
  size={size}
@@ -191,4 +226,10 @@ export function IconPicker<T extends string>({
191
226
  )}
192
227
  </div>
193
228
  );
229
+ if (!labelled) return picker;
230
+ return (
231
+ <LabelStrip labelId={labelId} label={label} hint={hint} disabled={disabled} pad="clear" className={className}>
232
+ {picker}
233
+ </LabelStrip>
234
+ );
194
235
  }