@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,443 @@
1
+ import { useId, useMemo } from "react";
2
+ import type { ComponentPropsWithoutRef, ReactNode } from "react";
3
+ import { cn } from "../lib/cn";
4
+ import { COUNTRY_CODES, countryName, normalizeCountryCode } from "../lib/countries";
5
+ import { FieldChevron, FIELD_FLOATING_PAD, FIELD_INVALID, FIELD_TRIGGER, FloatingField } from "./ui";
6
+ import { Tooltip } from "./tooltip";
7
+ import { CurrencyFlag } from "./currency-select";
8
+ import {
9
+ ComboboxPanel,
10
+ useComboboxCore,
11
+ useComboboxFieldError,
12
+ type ComboOption,
13
+ } from "./combobox-core";
14
+ import { hasMessage, mergeDescribedBy } from "./choice-parts";
15
+ import { useCommitReason } from "./write-lock";
16
+ import { DEFAULT_COMMON_LABELS, useKitLabels, useKitLocale } from "../i18n/kit-labels";
17
+
18
+ // ── Labels ────────────────────────────────────────────────────────────────────
19
+
20
+ /** The words {@link CountrySelect} renders on its own behalf. The list's "no results",
21
+ * "loading" and result-count announcements are the shared `combobox` namespace's,
22
+ * as on every other searchable picker. */
23
+ export interface CountrySelectLabels {
24
+ /** What the field is, when the caller gives no `label`: the first half of the
25
+ * trigger's accessible name, the phone sheet's title, and the empty trigger's
26
+ * text. */
27
+ country: string;
28
+ /** The search box in the open list. */
29
+ search: string;
30
+ /** The heading between the `preferred` countries and all the others. Shown only
31
+ * when `preferred` puts something above it. */
32
+ others: string;
33
+ }
34
+
35
+ export const DEFAULT_COUNTRY_SELECT_LABELS: CountrySelectLabels = {
36
+ country: "Country",
37
+ search: "Search country",
38
+ others: "Other countries",
39
+ };
40
+
41
+ // ── Props ─────────────────────────────────────────────────────────────────────
42
+
43
+ /**
44
+ * `onChange` is the kit's — "a country was picked", carrying its code — rather than
45
+ * the div's form event. `id` and the `aria-*` wiring below go to the TRIGGER, which is
46
+ * the control a reader meets; everything else in `rest` dresses the wrapper.
47
+ */
48
+ export interface CountrySelectProps
49
+ extends Omit<ComponentPropsWithoutRef<"div">, "onChange" | "defaultValue"> {
50
+ /** The chosen country as an ISO 3166-1 alpha-2 code ("CH"), in either case;
51
+ * `null`, `undefined` or `""` when none is. A code outside the list still shows
52
+ * its name — the list restricts what can be PICKED, not what can be shown. */
53
+ value: string | null | undefined;
54
+ /** A country was picked: its alpha-2 code, always upper-case. */
55
+ onChange: (code: string) => void;
56
+ /**
57
+ * Offer only these codes (any case; duplicates and malformed entries dropped).
58
+ * Default: every ISO 3166-1 country, {@link COUNTRY_CODES}. Sorted by name either
59
+ * way — the order given is not kept, because the reader looks a country up by its
60
+ * name in their own language, and any other order is one they cannot predict.
61
+ *
62
+ * keksdose's bank picker offers the fifteen countries its banks are in
63
+ * (DE AT CH FR IT ES NL BE PT PL SE DK FI GB IE). A code that is not in ISO's list
64
+ * is allowed here on purpose: `XK` (Kosovo) is the app's decision, not the kit's.
65
+ */
66
+ countries?: readonly string[];
67
+ /**
68
+ * A short group shown FIRST, in the order given, above the alphabetical rest —
69
+ * kastlan's CH, LI, DE, AT, FR, IT, the six countries most of its customers live
70
+ * in, out of a list of 249. Each code appears once: a preferred country is not
71
+ * repeated in the rest. The rest gets a heading (`labels.others`) so the boundary
72
+ * is visible. Search keeps the split: matching preferred countries stay on top.
73
+ * Codes not in `countries` are ignored — `countries` decides what can be picked.
74
+ */
75
+ preferred?: readonly string[];
76
+ /**
77
+ * Draw each country's flag as a flag-icons span (`fi fi-ch`), in the rows and on
78
+ * the trigger. Default `false`, because the kit ships no flag stylesheet — the same
79
+ * convention as `LanguageMenu`'s `country` and `CurrencySelect`: the host imports
80
+ * `flag-icons/css/flag-icons.min.css`, and without it a flag is an empty box.
81
+ */
82
+ flags?: boolean;
83
+ /** The floating label, as on every field. A string also names the trigger
84
+ * ("Country: Switzerland"). */
85
+ label?: ReactNode;
86
+ /** As {@link Select}'s `hint`: plain TEXT is a caption under the field, attached
87
+ * through `aria-describedby`; a {@link FieldHint} rides the label line (or the
88
+ * field's end, when there is no label). */
89
+ hint?: ReactNode;
90
+ /** As {@link Input}'s `error`: rendered under the field, on the trigger's
91
+ * `aria-describedby`, and implies `invalid`. */
92
+ error?: ReactNode;
93
+ /** Required and unanswered — {@link FIELD_INVALID}, as on every field. */
94
+ invalid?: boolean;
95
+ /** The empty trigger's text. Default: nothing when the field has a `label` (the
96
+ * label already says what goes here), else `labels.country`. */
97
+ placeholder?: string;
98
+ /** As on the other pickers: no focus, no list, the settled look. */
99
+ disabled?: boolean;
100
+ /**
101
+ * Why the country cannot be changed — {@link Button}'s `disabledReason`, for a field
102
+ * that saves itself. The trigger stays focusable and `aria-disabled`, the list does
103
+ * not open and `onChange` never fires; the reason is in the kit {@link Tooltip} and
104
+ * on the trigger's `aria-describedby`. Wins over `disabled`.
105
+ */
106
+ disabledReason?: ReactNode;
107
+ /**
108
+ * This select SAVES on change (a settings row with no Save button). Under a locked
109
+ * {@link WriteLockProvider} it is locked the `disabledReason` way, with the lock's
110
+ * reason. A country field inside a form that has its own Save stays editable under
111
+ * the lock — leave this off there, and put `commit` on the Save.
112
+ */
113
+ commit?: boolean;
114
+ /** The language the names are in. Default: the `<UiKitProvider locale>`, else
115
+ * English (see {@link countryName}). */
116
+ locale?: string;
117
+ /** Per-instance overrides of {@link CountrySelectLabels}, over the provider's
118
+ * `countrySelect` namespace, over English. */
119
+ labels?: Partial<CountrySelectLabels>;
120
+ }
121
+
122
+ /** Fold a name for search: no case, no accents — "osterreich" finds Österreich, "cote"
123
+ * finds Côte d'Ivoire. A keyboard without the letter must still reach the country. */
124
+ function fold(s: string): string {
125
+ return s.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase();
126
+ }
127
+
128
+ interface Entry {
129
+ code: string;
130
+ name: string;
131
+ folded: string;
132
+ }
133
+
134
+ /** How well `e` answers `q` (already folded): the code itself (a two-letter query
135
+ * "ch" means Switzerland before it means Chad), then the start of the name, then
136
+ * anywhere in it. `-1` for no match. */
137
+ function rank(e: Entry, q: string): number {
138
+ if (e.code.toLowerCase() === q) return 0;
139
+ if (e.folded.startsWith(q)) return 1;
140
+ return e.folded.includes(q) ? 2 : -1;
141
+ }
142
+
143
+ /** Best rank first; a stable sort, so equal ranks keep the collated (or preferred)
144
+ * order they came in. */
145
+ function search(entries: Entry[], q: string): Entry[] {
146
+ return entries
147
+ .map((e) => ({ e, r: rank(e, q) }))
148
+ .filter((x) => x.r >= 0)
149
+ .sort((a, b) => a.r - b.r)
150
+ .map((x) => x.e);
151
+ }
152
+
153
+ function toOption(e: Entry, flags: boolean, group?: string): ComboOption<string> {
154
+ return {
155
+ value: e.code,
156
+ label: e.name,
157
+ icon: flags ? <CurrencyFlag country={e.code.toLowerCase()} /> : undefined,
158
+ group,
159
+ };
160
+ }
161
+
162
+ function collator(locale: string): Intl.Collator {
163
+ try {
164
+ return new Intl.Collator(locale);
165
+ } catch {
166
+ return new Intl.Collator();
167
+ }
168
+ }
169
+
170
+ /**
171
+ * A country picker: the value is the ISO 3166-1 alpha-2 code ("CH"), the names are the
172
+ * runtime's own (`Intl.DisplayNames`) in the provider's locale, sorted by that
173
+ * locale's collation. Requested by kastlan (customer and handover addresses, with its
174
+ * six neighbours on top) and keksdose (the bank picker's fifteen countries) — both
175
+ * apps carried a hand-written list of names in two languages.
176
+ *
177
+ * ## Built on the kit's searchable combobox
178
+ *
179
+ * 249 countries is not a menu, it is a lookup, and the list has to work on a phone. So
180
+ * this is the entity pickers' machinery — `useComboboxCore` and `ComboboxPanel`, which
181
+ * {@link EntityCombobox} is also made of — not a native `<select>`: the anchored panel
182
+ * with a search box on a pointer device, the full-screen sheet with its own search box
183
+ * on a phone (live #200), the same keyboard (APG listbox), the same announcements, the
184
+ * same group headings. Only the trigger is this component's own, because the trigger
185
+ * is where the write lock lives: `EntityCombobox`'s is a native `disabled` button,
186
+ * and a locked field has to stay focusable to say why.
187
+ *
188
+ * The search is this component's own too: it folds accents and case ("osterreich"
189
+ * finds Österreich), matches the code ("ch" ranks Switzerland first), and ranks
190
+ * name-starts above name-contains. The panel is handed those rows in place of the
191
+ * core's plain substring filter.
192
+ *
193
+ * Field anatomy as on every field: floating `label`, `hint`, `error`, `invalid`; the
194
+ * trigger is named "label: value" as the other trigger pickers are.
195
+ */
196
+ export function CountrySelect({
197
+ value,
198
+ onChange,
199
+ countries,
200
+ preferred,
201
+ flags = false,
202
+ label,
203
+ hint,
204
+ error,
205
+ invalid,
206
+ placeholder,
207
+ disabled,
208
+ disabledReason: ownReason,
209
+ commit,
210
+ locale: localeProp,
211
+ labels: labelsProp,
212
+ className,
213
+ id,
214
+ "aria-label": ariaLabel,
215
+ "aria-describedby": ariaDescribedBy,
216
+ "aria-invalid": ariaInvalid,
217
+ "aria-required": ariaRequired,
218
+ ...rest
219
+ }: CountrySelectProps) {
220
+ const text = useKitLabels("countrySelect", DEFAULT_COUNTRY_SELECT_LABELS, labelsProp);
221
+ const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
222
+ // English without a locale — see `countryName`.
223
+ const locale = useKitLocale(localeProp) ?? "en";
224
+
225
+ // ── The list ──────────────────────────────────────────────────────────────
226
+ // Keyed on the arrays' CONTENTS: `countries={["DE", "AT"]}` written inline is a new
227
+ // array every render, and resorting 249 names on every keystroke elsewhere in the
228
+ // form is work for nothing.
229
+ const countriesKey = countries?.join(",");
230
+ const preferredKey = preferred?.join(",");
231
+ const { top, rest: others } = useMemo(() => {
232
+ const seen = new Set<string>();
233
+ const pool: string[] = [];
234
+ for (const raw of countriesKey !== undefined ? countriesKey.split(",") : COUNTRY_CODES) {
235
+ const code = normalizeCountryCode(raw);
236
+ if (code && !seen.has(code)) {
237
+ seen.add(code);
238
+ pool.push(code);
239
+ }
240
+ }
241
+ const entry = (code: string): Entry => {
242
+ const name = countryName(code, locale);
243
+ return { code, name, folded: fold(name) };
244
+ };
245
+ const firsts: Entry[] = [];
246
+ const picked = new Set<string>();
247
+ for (const raw of preferredKey ? preferredKey.split(",") : []) {
248
+ const code = normalizeCountryCode(raw);
249
+ if (code && seen.has(code) && !picked.has(code)) {
250
+ picked.add(code);
251
+ firsts.push(entry(code));
252
+ }
253
+ }
254
+ const compare = collator(locale).compare;
255
+ const sorted = pool
256
+ .filter((c) => !picked.has(c))
257
+ .map(entry)
258
+ .sort((a, b) => compare(a.name, b.name));
259
+ return { top: firsts, rest: sorted };
260
+ }, [countriesKey, preferredKey, locale]);
261
+
262
+ // `filter: false` — the panel is handed the rows `search` ranks below instead of
263
+ // the core's substring filter; the core still owns open, query, the highlighted row,
264
+ // anchoring and dismissal.
265
+ const allOptions = useMemo(
266
+ () => [...top, ...others].map((e) => toOption(e, flags)),
267
+ [top, others, flags],
268
+ );
269
+ const core = useComboboxCore<string>({ options: allOptions, filter: false });
270
+ const { open, setOpen, query, triggerRef, closeToTrigger } = core;
271
+
272
+ const q = fold(query.trim());
273
+ const results = useMemo(() => {
274
+ const firsts = q ? search(top, q) : top;
275
+ const below = q ? search(others, q) : others;
276
+ // The heading only where it separates something: with no preferred country in
277
+ // view it would head the whole list and divide nothing.
278
+ const heading = firsts.length > 0 ? text.others : undefined;
279
+ return [...firsts.map((e) => toOption(e, flags)), ...below.map((e) => toOption(e, flags, heading))];
280
+ }, [top, others, q, text.others, flags]);
281
+
282
+ // ── The lock ──────────────────────────────────────────────────────────────
283
+ const reason = useCommitReason(commit, ownReason);
284
+ const locked = hasMessage(reason);
285
+ const inert = locked || Boolean(disabled);
286
+ // A lock (or `disabled`) arriving while the list is up closes it — adjusted while
287
+ // rendering, as the core adjusts its own query, so no frame shows a list that can
288
+ // no longer be used.
289
+ if (inert && open) setOpen(false);
290
+
291
+ // ── The field ─────────────────────────────────────────────────────────────
292
+ const generated = useId();
293
+ const triggerId = id ?? generated;
294
+ const listboxId = `${generated}-listbox`;
295
+ const hintId = useId();
296
+ const reasonId = useId();
297
+ // Text is a caption under the field; a FieldHint rides the label line. See `hint`.
298
+ const textHint = (typeof hint === "string" && hint !== "") || typeof hint === "number";
299
+ const field = useComboboxFieldError(
300
+ error,
301
+ invalid || ariaInvalid === true || ariaInvalid === "true",
302
+ // The caller's, then the standing advice, then (inside) the error — the order every
303
+ // field keeps — and the lock's reason last, while there is one.
304
+ mergeDescribedBy(ariaDescribedBy, textHint && hintId),
305
+ );
306
+
307
+ const code = normalizeCountryCode(value);
308
+ const selectedName = code ? countryName(code, locale) : undefined;
309
+ const emptyText = placeholder ?? (label === undefined ? text.country : "");
310
+ const fieldName = typeof label === "string" ? label : text.country;
311
+ const name =
312
+ ariaLabel ??
313
+ (selectedName !== undefined
314
+ ? common.fieldValue(fieldName, selectedName)
315
+ : placeholder
316
+ ? common.fieldValue(fieldName, placeholder)
317
+ : fieldName);
318
+
319
+ const choose = (o: ComboOption<string>) => {
320
+ if (inert) return;
321
+ onChange(o.value);
322
+ // Back to the trigger: the panel holding focus is about to unmount, and a keyboard
323
+ // user who has answered this field should stand on it, ready to Tab on.
324
+ closeToTrigger();
325
+ };
326
+
327
+ const trigger = (
328
+ <button
329
+ ref={triggerRef}
330
+ id={triggerId}
331
+ type="button"
332
+ // A closed choice that expands into the list below — the role every trigger
333
+ // picker in the kit wears (see EntityCombobox), and the one that supports
334
+ // `aria-invalid`.
335
+ role="combobox"
336
+ aria-haspopup="listbox"
337
+ aria-controls={listboxId}
338
+ aria-expanded={open}
339
+ aria-label={name}
340
+ aria-invalid={field.isInvalid || undefined}
341
+ aria-required={ariaRequired}
342
+ aria-describedby={mergeDescribedBy(field.describedBy, locked && reasonId)}
343
+ // The focusable kind of disabled while locked (Button's rule): native `disabled`
344
+ // would take the field out of the tab order, and the reason with it.
345
+ disabled={locked ? undefined : disabled}
346
+ aria-disabled={locked || undefined}
347
+ onClick={(e) => {
348
+ if (inert) {
349
+ e.preventDefault();
350
+ return;
351
+ }
352
+ setOpen((o) => !o);
353
+ }}
354
+ // Down/Up opens the list from the closed trigger, per the APG; Enter and Space
355
+ // already do through the button's own click.
356
+ onKeyDown={(e) => {
357
+ if (inert) return;
358
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
359
+ e.preventDefault();
360
+ setOpen(true);
361
+ }
362
+ }}
363
+ className={cn(
364
+ FIELD_TRIGGER,
365
+ // `peer` dims the floating label with a natively disabled trigger, as Select's.
366
+ "peer pe-9",
367
+ label !== undefined && FIELD_FLOATING_PAD,
368
+ inert && "cursor-not-allowed opacity-50",
369
+ field.isInvalid && FIELD_INVALID,
370
+ )}
371
+ >
372
+ <span className="flex min-w-0 items-center gap-2">
373
+ {flags && code && <CurrencyFlag country={code.toLowerCase()} />}
374
+ {/* A long name ("Saint Vincent and the Grenadines") truncates rather than
375
+ widening the field: the kit is checked at 390px. */}
376
+ <span className={cn("truncate", selectedName === undefined && "text-[var(--text-placeholder)]")}>
377
+ {selectedName ?? emptyText}
378
+ </span>
379
+ </span>
380
+ <FieldChevron />
381
+ </button>
382
+ );
383
+
384
+ const control = locked ? (
385
+ // In a FRAGMENT, as Button does, so the Tooltip does not clone its bubble into the
386
+ // description as well — the hidden copy is what `aria-describedby` points at.
387
+ <Tooltip label={reason} className="flex w-full">
388
+ <>
389
+ {trigger}
390
+ <span id={reasonId} hidden>
391
+ {reason}
392
+ </span>
393
+ </>
394
+ </Tooltip>
395
+ ) : (
396
+ trigger
397
+ );
398
+
399
+ const labelHint = textHint ? undefined : hasMessage(hint) ? hint : undefined;
400
+ const box =
401
+ label !== undefined ? (
402
+ <FloatingField htmlFor={triggerId} label={label} staticLabel hint={labelHint}>
403
+ {control}
404
+ </FloatingField>
405
+ ) : labelHint !== undefined ? (
406
+ // No label line to ride: the "?" goes at the field's end, outside the box, as on
407
+ // an unlabelled Select.
408
+ <div className="flex items-center gap-1.5">
409
+ <div className="relative min-w-0 flex-1">{control}</div>
410
+ <span className="flex shrink-0 items-center">{labelHint}</span>
411
+ </div>
412
+ ) : (
413
+ <div className="relative">{control}</div>
414
+ );
415
+
416
+ return (
417
+ // `rest` dresses the wrapper — a `data-tour` anchor, a test id. The name, the
418
+ // description and the invalid state are on the trigger above.
419
+ <div {...rest} className={className}>
420
+ {box}
421
+ {textHint && (
422
+ <p id={hintId} className="mt-1 text-[11px] leading-tight text-[var(--text-muted)]">
423
+ {hint}
424
+ </p>
425
+ )}
426
+ {field.errorEl}
427
+ <ComboboxPanel
428
+ // The core with the rows ranked above in place of its own filter's.
429
+ core={{ ...core, results }}
430
+ listboxId={listboxId}
431
+ // On a phone the list is a full-screen sheet, which needs the field's own
432
+ // name to say what it is asking for (live #200).
433
+ sheetTitle={label ?? text.country}
434
+ searchPlaceholder={text.search}
435
+ isSelected={(v) => v === code}
436
+ onChoose={choose}
437
+ showCreate={false}
438
+ onCreate={() => {}}
439
+ createContent={null}
440
+ />
441
+ </div>
442
+ );
443
+ }
@@ -2,6 +2,7 @@ import { useMemo, useId } from "react";
2
2
  import type { ComponentPropsWithoutRef, ReactNode } from "react";
3
3
  import { Check } from "lucide-react";
4
4
  import { FieldChevron, FieldLabel, FIELD_TRIGGER, FIELD_INVALID, FIELD_FLOATING_PAD } from "./ui";
5
+ import { hasMessage, mergeDescribedBy } from "./choice-parts";
5
6
  import { cn } from "../lib/cn";
6
7
  import { DropdownPanel, DropdownSearchHeader, useDropdownSearch } from "./dropdown";
7
8
  import {
@@ -117,6 +118,22 @@ export interface CurrencySelectProps extends Omit<ComponentPropsWithoutRef<"div"
117
118
  * this package carries it now, so a form can mark any of its fields rather than
118
119
  * only the one that happened to have it first. */
119
120
  invalid?: boolean;
121
+ /**
122
+ * What is wrong with the choice, in the caller's own words — {@link Input}'s `error`
123
+ * (keksdose K4: of the kit's pickers this one had neither a message nor a caption, so
124
+ * "the account is in EUR" sat in a loose `<p>` beside it that nothing pointed at).
125
+ * Rendered under the field, after a text `hint`; merged into the trigger's
126
+ * `aria-describedby`; implies `invalid`. `null`, `false` and `""` are no message.
127
+ */
128
+ error?: ReactNode;
129
+ /**
130
+ * {@link Select}'s `hint`, the same rule (keksdose K4). A {@link FieldHint} "?" rides
131
+ * the label's line; on an unlabelled picker it sits at the field's end edge, outside
132
+ * the box. Plain TEXT (a string or a number) is a caption UNDER the field, attached
133
+ * to the trigger through `aria-describedby` — the label line has no room for a
134
+ * sentence.
135
+ */
136
+ hint?: ReactNode;
120
137
  /** Every user-facing string this control owns, shaped like `AmountInput`'s
121
138
  * `labels` so the two currency pickers in the kit are configured the same way.
122
139
  *
@@ -156,9 +173,13 @@ export function CurrencySelect({
156
173
  label,
157
174
  options,
158
175
  invalid,
176
+ error,
177
+ hint,
159
178
  labels,
160
179
  currencyNames,
161
180
  "aria-label": ariaLabel,
181
+ "aria-describedby": ariaDescribedBy,
182
+ "aria-invalid": ariaInvalid,
162
183
  ...rest
163
184
  }: CurrencySelectProps) {
164
185
  const { open, setOpen, wrapperRef, query, setQuery, inputRef } = useDropdownSearch();
@@ -185,6 +206,18 @@ export function CurrencySelect({
185
206
  }, [query, pool, currencyNames, locale]);
186
207
 
187
208
  const listboxId = `${useId()}-listbox`;
209
+ const hintId = useId();
210
+ const errorId = useId();
211
+ // Text is a caption under the field; anything else (a FieldHint) rides the label
212
+ // line. See `hint`.
213
+ const textHint = (typeof hint === "string" && hint !== "") || typeof hint === "number";
214
+ const labelHint = !textHint && hasMessage(hint) ? hint : undefined;
215
+ const hasError = hasMessage(error);
216
+ // On the TRIGGER, which is the control with a role: a caller's description used to
217
+ // land on the wrapper `<div>` through the spread, where nothing reads it. The
218
+ // caller's ids first, then the caption, then the error — Select's order.
219
+ const describedBy = mergeDescribedBy(ariaDescribedBy, textHint && hintId, hasError && errorId);
220
+ const isInvalid = Boolean(invalid) || hasError || ariaInvalid === true || ariaInvalid === "true";
188
221
 
189
222
  // The trigger's NAME. A combobox does not take its name from its content, and the
190
223
  // floating label is a <span>, not a <label for> — so without `aria-label` from the
@@ -194,44 +227,70 @@ export function CurrencySelect({
194
227
  const fieldName = typeof label === "string" ? label : text.currency;
195
228
  const name = ariaLabel ?? common.fieldValue(fieldName, selected ? selected.code : (placeholder ?? text.currency));
196
229
 
230
+ const trigger = (
231
+ <button
232
+ type="button"
233
+ aria-label={name}
234
+ onClick={() => setOpen((v) => !v)}
235
+ // The implicit `button` role supports neither `aria-expanded` nor
236
+ // `aria-invalid`, so this trigger previously wore a red border and announced
237
+ // nothing about being invalid or about there being a list behind it. The role
238
+ // that DESCRIBES it is the one that also supports the attributes — the same
239
+ // treatment its siblings (MultiSelect, the entity comboboxes) now carry, so a
240
+ // form row of pickers reads the same way throughout.
241
+ role="combobox"
242
+ aria-haspopup="listbox"
243
+ aria-controls={listboxId}
244
+ aria-expanded={open}
245
+ aria-invalid={isInvalid || undefined}
246
+ aria-describedby={describedBy}
247
+ className={cn(FIELD_TRIGGER, "pe-9", label !== undefined && FIELD_FLOATING_PAD, isInvalid && FIELD_INVALID)}
248
+ >
249
+ {/* The trigger stays COMPACT — flag + code only — so it never clips in a
250
+ narrow field; the full names live in the (wider) popup (feedback
251
+ #308). */}
252
+ <span className={cn("flex items-center gap-2 min-w-0", selected ? "text-[var(--text-primary)]" : "text-[var(--text-placeholder)]")}>
253
+ {selected ? (
254
+ <>
255
+ <CurrencyFlag country={selected.country} />
256
+ <span className="font-medium">{selected.code}</span>
257
+ </>
258
+ ) : (
259
+ placeholder ?? text.currency
260
+ )}
261
+ </span>
262
+ <FieldChevron />
263
+ </button>
264
+ );
265
+
197
266
  return (
198
267
  // The wrapper takes `rest`; the trigger takes the name. This field's label is a
199
268
  // floating <span> rather than a <label for>, so a caller who wants the control to
200
269
  // announce more than the code inside it has nowhere else to put it.
201
270
  <div {...rest} ref={wrapperRef} className={cn("relative", className)}>
202
- {label !== undefined && <FieldLabel>{label}</FieldLabel>}
203
- <button
204
- type="button"
205
- aria-label={name}
206
- onClick={() => setOpen((v) => !v)}
207
- // The implicit `button` role supports neither `aria-expanded` nor
208
- // `aria-invalid`, so this trigger previously wore a red border and announced
209
- // nothing about being invalid or about there being a list behind it. The role
210
- // that DESCRIBES it is the one that also supports the attributes — the same
211
- // treatment its siblings (MultiSelect, the entity comboboxes) now carry, so a
212
- // form row of pickers reads the same way throughout.
213
- role="combobox"
214
- aria-haspopup="listbox"
215
- aria-controls={listboxId}
216
- aria-expanded={open}
217
- aria-invalid={invalid || undefined}
218
- className={cn(FIELD_TRIGGER, "pe-9", label !== undefined && FIELD_FLOATING_PAD, invalid && FIELD_INVALID)}
219
- >
220
- {/* The trigger stays COMPACT — flag + code only — so it never clips in a
221
- narrow field; the full names live in the (wider) popup (feedback
222
- #308). */}
223
- <span className={cn("flex items-center gap-2 min-w-0", selected ? "text-[var(--text-primary)]" : "text-[var(--text-placeholder)]")}>
224
- {selected ? (
225
- <>
226
- <CurrencyFlag country={selected.country} />
227
- <span className="font-medium">{selected.code}</span>
228
- </>
229
- ) : (
230
- placeholder ?? text.currency
231
- )}
232
- </span>
233
- <FieldChevron />
234
- </button>
271
+ {label !== undefined &&
272
+ (labelHint === undefined ? (
273
+ <FieldLabel>{label}</FieldLabel>
274
+ ) : (
275
+ // The label and its "?" as one row on the label's line, as FloatingField lays
276
+ // out a static label with a hint: the row places both, the label truncates
277
+ // and the hint keeps its width. `z-10` like FieldLabel's own: the trigger is
278
+ // `relative` and comes later, so it would paint over the strip.
279
+ <div className="pointer-events-none absolute inset-x-3 top-1 z-10 flex items-center gap-1">
280
+ <FieldLabel className="static z-auto min-w-0 max-w-none">{label}</FieldLabel>
281
+ <span className="pointer-events-auto flex shrink-0 items-center">{labelHint}</span>
282
+ </div>
283
+ ))}
284
+ {label === undefined && labelHint !== undefined ? (
285
+ // No label line to ride: at the field's end edge, outside the box, as an
286
+ // unlabelled Select places its FieldHint.
287
+ <div className="flex items-center gap-1.5">
288
+ <div className="min-w-0 flex-1">{trigger}</div>
289
+ <span className="flex shrink-0 items-center">{labelHint}</span>
290
+ </div>
291
+ ) : (
292
+ trigger
293
+ )}
235
294
  {open && (
236
295
  <DropdownPanel
237
296
  // At least as wide as the trigger, but grows to fit the full currency
@@ -278,6 +337,23 @@ export function CurrencySelect({
278
337
  })}
279
338
  </DropdownPanel>
280
339
  )}
340
+ {/* After the panel, not before it, as MultiSelect places its message: the panel
341
+ is `absolute` with no `top`, so it opens at its place in the flow — under the
342
+ caption, had the caption come first. */}
343
+ {textHint && (
344
+ <p id={hintId} className="mt-1 text-[11px] leading-tight text-[var(--text-muted)]">
345
+ {hint}
346
+ </p>
347
+ )}
348
+ {hasError && (
349
+ <p id={errorId} className={FIELD_ERROR_CLASS}>
350
+ {error}
351
+ </p>
352
+ )}
281
353
  </div>
282
354
  );
283
355
  }
356
+
357
+ // The error line under a field — the type of `ui.tsx`'s (module-private) one, so a
358
+ // CurrencySelect's message is indistinguishable from an Input's.
359
+ const FIELD_ERROR_CLASS = "mt-1 text-[11px] leading-tight text-[var(--danger)]";