@eifi1/ui-kit 0.20.1 → 0.22.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 (286) hide show
  1. package/dist/chart.d.ts +10 -8
  2. package/dist/components/account-chips.d.ts +10 -3
  3. package/dist/components/account-chips.js +5 -2
  4. package/dist/components/account-chips.js.map +1 -1
  5. package/dist/components/account-settings.d.ts +33 -2
  6. package/dist/components/account-settings.js +43 -21
  7. package/dist/components/account-settings.js.map +1 -1
  8. package/dist/components/amount-input.d.ts +27 -8
  9. package/dist/components/amount-input.js +15 -28
  10. package/dist/components/amount-input.js.map +1 -1
  11. package/dist/components/autocomplete.d.ts +8 -0
  12. package/dist/components/autocomplete.js +9 -4
  13. package/dist/components/autocomplete.js.map +1 -1
  14. package/dist/components/button-group.d.ts +10 -8
  15. package/dist/components/calculator.d.ts +10 -8
  16. package/dist/components/checkbox-group.d.ts +104 -0
  17. package/dist/components/checkbox-group.js +111 -0
  18. package/dist/components/checkbox-group.js.map +1 -0
  19. package/dist/components/checkbox.d.ts +19 -0
  20. package/dist/components/checkbox.js +30 -7
  21. package/dist/components/checkbox.js.map +1 -1
  22. package/dist/components/chip.d.ts +18 -0
  23. package/dist/components/chip.js +47 -10
  24. package/dist/components/chip.js.map +1 -1
  25. package/dist/components/combobox.d.ts +13 -2
  26. package/dist/components/combobox.js +17 -8
  27. package/dist/components/combobox.js.map +1 -1
  28. package/dist/components/confirm-dialog.d.ts +66 -161
  29. package/dist/components/copy-button.d.ts +10 -8
  30. package/dist/components/country-select.d.ts +124 -0
  31. package/dist/components/country-select.js +237 -0
  32. package/dist/components/country-select.js.map +1 -0
  33. package/dist/components/currency-select.d.ts +17 -1
  34. package/dist/components/currency-select.js +56 -23
  35. package/dist/components/currency-select.js.map +1 -1
  36. package/dist/components/danger-confirm.d.ts +66 -166
  37. package/dist/components/danger-confirm.js +133 -49
  38. package/dist/components/danger-confirm.js.map +1 -1
  39. package/dist/components/data-table-cells.d.ts +1 -2
  40. package/dist/components/data-table-filter-popover.d.ts +1 -2
  41. package/dist/components/data-table-filters.d.ts +1 -2
  42. package/dist/components/data-table-labels.d.ts +4 -97
  43. package/dist/components/data-table-labels.js +9 -1
  44. package/dist/components/data-table-labels.js.map +1 -1
  45. package/dist/components/data-table-pagination.d.ts +3 -1
  46. package/dist/components/data-table.d.ts +1 -2
  47. package/dist/components/data-table.js +98 -3
  48. package/dist/components/data-table.js.map +1 -1
  49. package/dist/components/date-picker.d.ts +25 -5
  50. package/dist/components/date-picker.js +110 -59
  51. package/dist/components/date-picker.js.map +1 -1
  52. package/dist/components/entity-combobox.d.ts +8 -1
  53. package/dist/components/entity-combobox.js +9 -4
  54. package/dist/components/entity-combobox.js.map +1 -1
  55. package/dist/components/facing-pair.d.ts +10 -8
  56. package/dist/components/field-anatomy.d.ts +95 -0
  57. package/dist/components/field-anatomy.js +84 -0
  58. package/dist/components/field-anatomy.js.map +1 -0
  59. package/dist/components/field-parts.d.ts +114 -0
  60. package/dist/components/field-parts.js +72 -0
  61. package/dist/components/field-parts.js.map +1 -0
  62. package/dist/components/file-button.d.ts +10 -8
  63. package/dist/components/file-button.js +13 -3
  64. package/dist/components/file-button.js.map +1 -1
  65. package/dist/components/file-dropzone.d.ts +32 -9
  66. package/dist/components/file-dropzone.js +57 -34
  67. package/dist/components/file-dropzone.js.map +1 -1
  68. package/dist/components/form-actions.d.ts +10 -8
  69. package/dist/components/form-actions.js +61 -3
  70. package/dist/components/form-actions.js.map +1 -1
  71. package/dist/components/hover-menu.d.ts +3 -0
  72. package/dist/components/hover-menu.js +2 -2
  73. package/dist/components/hover-menu.js.map +1 -1
  74. package/dist/components/iban-input.d.ts +66 -0
  75. package/dist/components/iban-input.js +125 -0
  76. package/dist/components/iban-input.js.map +1 -0
  77. package/dist/components/icon-picker.d.ts +12 -2
  78. package/dist/components/icon-picker.js +9 -2
  79. package/dist/components/icon-picker.js.map +1 -1
  80. package/dist/components/inline-edit-field.d.ts +167 -0
  81. package/dist/components/inline-edit-field.js +293 -0
  82. package/dist/components/inline-edit-field.js.map +1 -0
  83. package/dist/components/language-select.d.ts +127 -0
  84. package/dist/components/language-select.js +18 -0
  85. package/dist/components/language-select.js.map +1 -0
  86. package/dist/components/line-items.d.ts +25 -3
  87. package/dist/components/line-items.js +55 -46
  88. package/dist/components/line-items.js.map +1 -1
  89. package/dist/components/list.d.ts +25 -0
  90. package/dist/components/list.js +16 -5
  91. package/dist/components/list.js.map +1 -1
  92. package/dist/components/money-field.d.ts +20 -8
  93. package/dist/components/money-field.js.map +1 -1
  94. package/dist/components/month-picker.d.ts +52 -4
  95. package/dist/components/month-picker.js +290 -86
  96. package/dist/components/month-picker.js.map +1 -1
  97. package/dist/components/multi-select.d.ts +8 -1
  98. package/dist/components/multi-select.js +9 -4
  99. package/dist/components/multi-select.js.map +1 -1
  100. package/dist/components/number-field.d.ts +25 -13
  101. package/dist/components/number-field.js +6 -20
  102. package/dist/components/number-field.js.map +1 -1
  103. package/dist/components/number-input.d.ts +70 -13
  104. package/dist/components/number-input.js +90 -38
  105. package/dist/components/number-input.js.map +1 -1
  106. package/dist/components/numpad-sheet.d.ts +10 -8
  107. package/dist/components/one-time-code-input.d.ts +100 -0
  108. package/dist/components/one-time-code-input.js +137 -0
  109. package/dist/components/one-time-code-input.js.map +1 -0
  110. package/dist/components/phone-input.d.ts +66 -0
  111. package/dist/components/phone-input.js +171 -0
  112. package/dist/components/phone-input.js.map +1 -0
  113. package/dist/components/series-chart.d.ts +10 -8
  114. package/dist/components/settings-fields.d.ts +10 -8
  115. package/dist/components/share-card.d.ts +8 -6
  116. package/dist/components/sign-chip.d.ts +66 -0
  117. package/dist/components/sign-chip.js +39 -0
  118. package/dist/components/sign-chip.js.map +1 -0
  119. package/dist/components/swatch-picker.d.ts +27 -2
  120. package/dist/components/swatch-picker.js +73 -31
  121. package/dist/components/swatch-picker.js.map +1 -1
  122. package/dist/components/switch.d.ts +19 -0
  123. package/dist/components/switch.js +24 -6
  124. package/dist/components/switch.js.map +1 -1
  125. package/dist/components/text-link.d.ts +10 -8
  126. package/dist/components/tile-radio.d.ts +68 -13
  127. package/dist/components/tile-radio.js +3 -3
  128. package/dist/components/tile-radio.js.map +1 -1
  129. package/dist/components/time-input.d.ts +15 -9
  130. package/dist/components/time-input.js.map +1 -1
  131. package/dist/components/toggle-group.d.ts +47 -1
  132. package/dist/components/toggle-group.js +110 -69
  133. package/dist/components/toggle-group.js.map +1 -1
  134. package/dist/components/ui.d.ts +10 -8
  135. package/dist/components/ui.js +280 -78
  136. package/dist/components/ui.js.map +1 -1
  137. package/dist/components/use-table-state.d.ts +1 -2
  138. package/dist/{data-table-3mi2R3so.d.ts → data-table-labels-B7OdnM0S.d.ts} +140 -3
  139. package/dist/data-table.d.ts +1 -2
  140. package/dist/data-table.js +2 -1
  141. package/dist/data-table.js.map +1 -1
  142. package/dist/feedback/feedback-attachment.d.ts +1 -1
  143. package/dist/feedback/feedback-attachment.js +210 -3
  144. package/dist/feedback/feedback-attachment.js.map +1 -1
  145. package/dist/feedback/feedback-dialog.d.ts +1 -1
  146. package/dist/feedback/feedback-dialog.js.map +1 -1
  147. package/dist/feedback/feedback-inbox.d.ts +1 -1
  148. package/dist/feedback/feedback-thread.d.ts +37 -3
  149. package/dist/feedback/feedback-thread.js +38 -9
  150. package/dist/feedback/feedback-thread.js.map +1 -1
  151. package/dist/{feedback-attachment-WFiXJ8We.d.ts → feedback-attachment-NhUm7Zga.d.ts} +70 -5
  152. package/dist/feedback.d.ts +2 -2
  153. package/dist/hooks/use-file-drop.d.ts +10 -8
  154. package/dist/i18n/defaults.d.ts +10 -8
  155. package/dist/i18n/defaults.js +17 -2
  156. package/dist/i18n/defaults.js.map +1 -1
  157. package/dist/i18n/german.d.ts +10 -8
  158. package/dist/i18n/german.js +50 -5
  159. package/dist/i18n/german.js.map +1 -1
  160. package/dist/i18n/kit-labels.d.ts +10 -8
  161. package/dist/i18n/kit-labels.js +8 -2
  162. package/dist/i18n/kit-labels.js.map +1 -1
  163. package/dist/i18n/languages.d.ts +10 -8
  164. package/dist/i18n/locales/de-CH.d.ts +10 -8
  165. package/dist/i18n/locales/en.d.ts +10 -8
  166. package/dist/i18n/locales/en.js +9 -0
  167. package/dist/i18n/locales/en.js.map +1 -1
  168. package/dist/i18n/locales/es.d.ts +10 -8
  169. package/dist/i18n/locales/es.js +48 -5
  170. package/dist/i18n/locales/es.js.map +1 -1
  171. package/dist/i18n/locales/fr.d.ts +10 -8
  172. package/dist/i18n/locales/fr.js +48 -5
  173. package/dist/i18n/locales/fr.js.map +1 -1
  174. package/dist/i18n/locales/hu.d.ts +10 -8
  175. package/dist/i18n/locales/hu.js +49 -5
  176. package/dist/i18n/locales/hu.js.map +1 -1
  177. package/dist/i18n/locales/it.d.ts +10 -8
  178. package/dist/i18n/locales/it.js +49 -5
  179. package/dist/i18n/locales/it.js.map +1 -1
  180. package/dist/i18n/locales/zh.d.ts +10 -8
  181. package/dist/i18n/locales/zh.js +48 -5
  182. package/dist/i18n/locales/zh.js.map +1 -1
  183. package/dist/i18n/review.d.ts +10 -8
  184. package/dist/i18n/review.js +8 -1
  185. package/dist/i18n/review.js.map +1 -1
  186. package/dist/index.d.ts +17 -10
  187. package/dist/index.js +57 -2
  188. package/dist/index.js.map +1 -1
  189. package/dist/{kit-labels-D1J2_jyD.d.ts → kit-labels-R8mIAc9N.d.ts} +811 -8
  190. package/dist/lib/countries.d.ts +43 -0
  191. package/dist/lib/countries.js +39 -0
  192. package/dist/lib/countries.js.map +1 -0
  193. package/dist/lib/decimal-marks.d.ts +46 -0
  194. package/dist/lib/decimal-marks.js +40 -0
  195. package/dist/lib/decimal-marks.js.map +1 -0
  196. package/dist/lib/iban.d.ts +98 -0
  197. package/dist/lib/iban.js +154 -0
  198. package/dist/lib/iban.js.map +1 -0
  199. package/dist/lib/isin.d.ts +30 -0
  200. package/dist/lib/isin.js +36 -0
  201. package/dist/lib/isin.js.map +1 -0
  202. package/dist/lib/phone.d.ts +69 -0
  203. package/dist/lib/phone.js +121 -0
  204. package/dist/lib/phone.js.map +1 -0
  205. package/dist/rhf/fields.d.ts +148 -11
  206. package/dist/rhf/fields.js +332 -3
  207. package/dist/rhf/fields.js.map +1 -1
  208. package/dist/rhf/form.d.ts +10 -8
  209. package/dist/rhf.d.ts +13 -9
  210. package/dist/rhf.js.map +1 -1
  211. package/dist/shell/app-shell.d.ts +10 -8
  212. package/dist/shell/top-bar-brand.d.ts +10 -8
  213. package/dist/shell/topbar-action-menu.d.ts +13 -1
  214. package/dist/shell/topbar-action-menu.js +31 -28
  215. package/dist/shell/topbar-action-menu.js.map +1 -1
  216. package/dist/shell.d.ts +10 -8
  217. package/dist/tour/tour.js +20 -24
  218. package/dist/tour/tour.js.map +1 -1
  219. package/dist/wizard/stepper-nav.d.ts +10 -8
  220. package/dist/wizard.d.ts +10 -8
  221. package/package.json +1 -1
  222. package/src/components/account-chips.tsx +17 -3
  223. package/src/components/account-settings.tsx +92 -26
  224. package/src/components/amount-input.tsx +49 -56
  225. package/src/components/autocomplete.tsx +133 -110
  226. package/src/components/checkbox-group.tsx +229 -0
  227. package/src/components/checkbox.tsx +72 -8
  228. package/src/components/chip.tsx +80 -10
  229. package/src/components/combobox.tsx +292 -253
  230. package/src/components/country-select.tsx +443 -0
  231. package/src/components/currency-select.tsx +109 -33
  232. package/src/components/danger-confirm.tsx +345 -77
  233. package/src/components/data-table-labels.ts +16 -1
  234. package/src/components/data-table.tsx +175 -6
  235. package/src/components/date-picker.tsx +173 -62
  236. package/src/components/entity-combobox.tsx +107 -87
  237. package/src/components/field-anatomy.tsx +190 -0
  238. package/src/components/field-parts.tsx +184 -0
  239. package/src/components/file-button.tsx +36 -3
  240. package/src/components/file-dropzone.tsx +106 -38
  241. package/src/components/form-actions.tsx +115 -2
  242. package/src/components/hover-menu.tsx +7 -4
  243. package/src/components/iban-input.tsx +231 -0
  244. package/src/components/icon-picker.tsx +26 -4
  245. package/src/components/inline-edit-field.tsx +533 -0
  246. package/src/components/language-select.tsx +78 -0
  247. package/src/components/line-items.tsx +35 -2
  248. package/src/components/list.tsx +55 -6
  249. package/src/components/money-field.tsx +10 -0
  250. package/src/components/month-picker.tsx +399 -63
  251. package/src/components/multi-select.tsx +80 -58
  252. package/src/components/number-field.tsx +28 -46
  253. package/src/components/number-input.tsx +210 -43
  254. package/src/components/one-time-code-input.tsx +288 -0
  255. package/src/components/phone-input.tsx +299 -0
  256. package/src/components/sign-chip.tsx +103 -0
  257. package/src/components/swatch-picker.tsx +118 -30
  258. package/src/components/switch.tsx +58 -7
  259. package/src/components/tile-radio.tsx +71 -16
  260. package/src/components/time-input.tsx +8 -1
  261. package/src/components/toggle-group.tsx +206 -73
  262. package/src/components/ui.tsx +472 -105
  263. package/src/data-table.ts +2 -1
  264. package/src/feedback/feedback-attachment.tsx +313 -9
  265. package/src/feedback/feedback-dialog.tsx +2 -0
  266. package/src/feedback/feedback-thread.tsx +66 -5
  267. package/src/i18n/defaults.ts +16 -1
  268. package/src/i18n/german.ts +47 -0
  269. package/src/i18n/kit-labels.tsx +78 -2
  270. package/src/i18n/locales/en.ts +10 -0
  271. package/src/i18n/locales/es.ts +46 -0
  272. package/src/i18n/locales/fr.ts +45 -0
  273. package/src/i18n/locales/hu.ts +46 -0
  274. package/src/i18n/locales/it.ts +47 -0
  275. package/src/i18n/locales/zh.ts +44 -0
  276. package/src/i18n/review.ts +7 -0
  277. package/src/index.ts +53 -3
  278. package/src/lib/countries.ts +91 -0
  279. package/src/lib/decimal-marks.ts +99 -0
  280. package/src/lib/iban.ts +171 -0
  281. package/src/lib/isin.ts +62 -0
  282. package/src/lib/phone.ts +211 -0
  283. package/src/rhf/fields.tsx +606 -9
  284. package/src/rhf.ts +2 -1
  285. package/src/shell/topbar-action-menu.tsx +53 -29
  286. package/src/tour/tour.tsx +66 -28
@@ -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-anatomy";
8
9
 
9
10
  export interface IconPickerLabels {
10
11
  /** The "no icon" tile's name, when `allowNone` is set. */
@@ -76,6 +77,16 @@ export interface IconPickerProps<T extends string>
76
77
  tileClassName?: string;
77
78
  disabled?: boolean;
78
79
  labels?: Partial<IconPickerLabels>;
80
+ /**
81
+ * An 11px static label in a strip over the picker, naming the grid — keksdose K2,
82
+ * {@link SwatchPickerProps.label}'s twin: a symbol picker standing in a form row
83
+ * beside labelled fields, with its label on their line. With `searchable` the strip
84
+ * is over the search box too, which keeps its own name. With a label, `className`
85
+ * styles the wrapper.
86
+ */
87
+ label?: ReactNode;
88
+ /** A {@link FieldHint} beside the label. Only with `label`. */
89
+ hint?: ReactNode;
79
90
  }
80
91
 
81
92
  /** Case- and accent-insensitive, so "cafe" finds "Café". */
@@ -105,6 +116,8 @@ export function IconPicker<T extends string>({
105
116
  tileClassName,
106
117
  disabled = false,
107
118
  labels,
119
+ label,
120
+ hint,
108
121
  className,
109
122
  "aria-label": ariaLabel,
110
123
  "aria-labelledby": ariaLabelledBy,
@@ -115,6 +128,8 @@ export function IconPicker<T extends string>({
115
128
  const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
116
129
  const locale = useKitLocale();
117
130
  const mixedId = useId();
131
+ const labelId = useId();
132
+ const labelled = hasContent(label);
118
133
  const [query, setQuery] = useState("");
119
134
  const needle = fold(query.trim(), locale);
120
135
 
@@ -135,8 +150,8 @@ export function IconPicker<T extends string>({
135
150
  ...visible.map((o) => ({ key: o.value, label: o.label, note: o.note, disabled: o.disabled })),
136
151
  ];
137
152
 
138
- return (
139
- <div {...rest} className={cn("space-y-2", className)}>
153
+ const picker = (
154
+ <div {...rest} className={cn("space-y-2", !labelled && className)}>
140
155
  {searchable && (
141
156
  <SearchField
142
157
  value={query}
@@ -149,7 +164,8 @@ export function IconPicker<T extends string>({
149
164
  <div
150
165
  role="radiogroup"
151
166
  aria-label={ariaLabel}
152
- aria-labelledby={ariaLabelledBy}
167
+ // The strip's label names the GRID, unless the caller named it already.
168
+ aria-labelledby={labelled && ariaLabel === undefined && ariaLabelledBy === undefined ? labelId : ariaLabelledBy}
153
169
  aria-describedby={[ariaDescribedBy, mixed && mixedId].filter(Boolean).join(" ") || undefined}
154
170
  aria-disabled={disabled || undefined}
155
171
  className="flex flex-wrap items-center gap-1.5"
@@ -191,4 +207,10 @@ export function IconPicker<T extends string>({
191
207
  )}
192
208
  </div>
193
209
  );
210
+ if (!labelled) return picker;
211
+ return (
212
+ <LabelStrip labelId={labelId} label={label} hint={hint} disabled={disabled} pad="tiles" className={className}>
213
+ {picker}
214
+ </LabelStrip>
215
+ );
194
216
  }