@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
@@ -22,6 +22,16 @@ import {
22
22
  type ComboOption,
23
23
  } from "./combobox-core";
24
24
  import { DEFAULT_COMBOBOX_LABELS, useKitLabels } from "../i18n/kit-labels";
25
+ import {
26
+ EndHintRow,
27
+ FieldCaption,
28
+ LABEL_IN_ROW,
29
+ LockedReason,
30
+ StaticLabelRow,
31
+ useFieldHint,
32
+ useLockReason,
33
+ } from "./field-parts";
34
+ import { mergeDescribedBy } from "./choice-parts";
25
35
 
26
36
  /**
27
37
  * `value`/`onChange` are the TEXT's, and `onSelect` is "a suggestion was taken" —
@@ -104,6 +114,14 @@ export interface AutocompleteProps<V extends string | number = string>
104
114
  * its `aria-describedby`, implies `invalid`. Not a failed lookup — that is
105
115
  * `loadErrorLabel`, shown in the list. */
106
116
  error?: ReactNode;
117
+ /**
118
+ * Standing advice — {@link Combobox}'s `hint`, so this field reads like every other
119
+ * (keksdose K4): plain TEXT is a caption under the field on the input's
120
+ * `aria-describedby`, before any error; a {@link FieldHint} "?" rides the label line,
121
+ * or with no label sits at the field's end edge, outside the box. Not the list's
122
+ * `status` line, which talks about the lookup, not the field.
123
+ */
124
+ hint?: ReactNode;
107
125
  /**
108
126
  * The caller's own line in the list, replacing the automatic one (loading / failed
109
127
  * / no results). For what only the caller knows: "offline", "the address service
@@ -117,6 +135,27 @@ export interface AutocompleteProps<V extends string | number = string>
117
135
  loadErrorLabel?: string;
118
136
  /** Classes for the `<input>` itself; `className` styles the wrapper. */
119
137
  inputClassName?: string;
138
+ /**
139
+ * Why the field cannot be changed — {@link Button}'s `disabledReason`, the Combobox
140
+ * family's write lock (keksdose G2, 0.23), for a field whose pick SAVES: an address
141
+ * search whose taken row writes the address, a lookup that links a record.
142
+ *
143
+ * With a reason the input stays focusable and `aria-disabled`, is `readOnly` (the
144
+ * settled grey FIELD_BASE gives a `[readonly]` field), opens no list and asks no
145
+ * `loadOptions` — whatever `open` says — and nothing reaches `onChange`, `onSelect` or
146
+ * `onOpenChange(true)`. Enter is swallowed, so a locked field does not submit the form
147
+ * around it, and a caller's `onKeyDown` does not hear it; every other key still
148
+ * reaches the caller (an Escape that closes its panel) and the caret. The reason is in
149
+ * the kit {@link Tooltip} and on the input's `aria-describedby`. Wins over `disabled`.
150
+ */
151
+ disabledReason?: ReactNode;
152
+ /**
153
+ * This field COMMITS. Under a locked {@link WriteLockProvider} it is locked the
154
+ * `disabledReason` way with the lock's reason (which wins over its own). A search
155
+ * whose result is only draft state — the form's Save writes it — leaves this off and
156
+ * stays live under the lock. No provider, or an unlocked one: no effect.
157
+ */
158
+ commit?: boolean;
120
159
  }
121
160
 
122
161
  function AutocompleteInner<V extends string | number = string>(
@@ -135,6 +174,7 @@ function AutocompleteInner<V extends string | number = string>(
135
174
  icon,
136
175
  invalid,
137
176
  error,
177
+ hint,
138
178
  status,
139
179
  emptyLabel,
140
180
  loadErrorLabel,
@@ -145,6 +185,8 @@ function AutocompleteInner<V extends string | number = string>(
145
185
  inputClassName,
146
186
  id,
147
187
  disabled,
188
+ disabledReason,
189
+ commit,
148
190
  onKeyDown,
149
191
  onFocus,
150
192
  onBlur,
@@ -166,7 +208,8 @@ function AutocompleteInner<V extends string | number = string>(
166
208
  noResults: emptyLabel,
167
209
  loadError: loadErrorLabel,
168
210
  });
169
- const field = useComboboxFieldError(error, invalid, ariaDescribedBy);
211
+ const hintParts = useFieldHint(hint, ariaDescribedBy);
212
+ const field = useComboboxFieldError(error, invalid, hintParts.describedBy);
170
213
 
171
214
  /** The user's intent: the field is focused and the list has not been dismissed.
172
215
  * Whether anything SHOWS is decided below, from what there is to show. */
@@ -180,7 +223,11 @@ function AutocompleteInner<V extends string | number = string>(
180
223
  if (next !== open) onOpenChange?.(next);
181
224
  };
182
225
  const [active, setActive] = useState(-1);
183
- const live = open && !disabled;
226
+ // The lock: focusable, `readOnly`, no list — see `disabledReason`. Folded into `live`,
227
+ // so a locked field asks nothing and shows nothing even with `open` held true.
228
+ const lock = useLockReason(commit, disabledReason);
229
+ const locked = lock.locked;
230
+ const live = open && !disabled && !locked;
184
231
  const { results, busy, failed, tooShort } = useOptionSource<V>({
185
232
  options,
186
233
  loadOptions,
@@ -219,7 +266,7 @@ function AutocompleteInner<V extends string | number = string>(
219
266
  setActive(-1);
220
267
  };
221
268
  const take = (o: ComboOption<V>) => {
222
- if (o.disabled) return;
269
+ if (o.disabled || locked) return;
223
270
  if (fillOnSelect) onChange(o.label);
224
271
  onSelect?.(o);
225
272
  close();
@@ -234,6 +281,16 @@ function AutocompleteInner<V extends string | number = string>(
234
281
  // label still names the field.
235
282
  const labelledBy =
236
283
  ariaLabel === undefined ? (ariaLabelledBy ?? (hasLabel ? labelId : undefined)) : undefined;
284
+ // While locked the field sits in the reason's Tooltip — in a fragment with a hidden
285
+ // copy of the reason, Button's anatomy; `block` so a full-width field stays full width.
286
+ const withLock = (box: ReactNode) =>
287
+ locked ? (
288
+ <LockedReason lock={lock} className="block">
289
+ {box}
290
+ </LockedReason>
291
+ ) : (
292
+ box
293
+ );
237
294
 
238
295
  return (
239
296
  // `relative`: the floating label and the live region's `sr-only` both need a
@@ -241,124 +298,151 @@ function AutocompleteInner<V extends string | number = string>(
241
298
  <div className={cn("relative", className)}>
242
299
  {/* A real <label for> (lenkbank), and still `aria-labelledby`'s target, so the
243
300
  name — and the listbox's, which borrows it — is exactly what it was. */}
244
- {hasLabel && (
301
+ {hasLabel && hintParts.labelHint === undefined && (
245
302
  <ComboboxFieldLabel id={labelId} htmlFor={fieldId}>
246
303
  {label}
247
304
  </ComboboxFieldLabel>
248
305
  )}
249
- <div ref={fieldRef} className="relative">
250
- {icon && (
251
- <span
252
- aria-hidden
253
- className={cn(
254
- "pointer-events-none absolute flex text-[var(--text-muted)]",
255
- small ? "start-2 [&>svg]:size-3.5" : "start-2.5 [&>svg]:size-4",
256
- // Labelled, the icon sits on the VALUE's line — the one line box below
257
- // FIELD_FLOATING_PAD's `pt-4` and the 1px border — not on the middle of
258
- // the box. Centred on the box it rose into the top strip and sat on the
259
- // label's first letters, which float at the same start inset. On the
260
- // value line it is beside what it describes, and the label keeps the
261
- // start edge every other label in the form's column shares.
262
- hasLabel
263
- ? "top-[calc(1rem+1px)] h-5 items-center"
264
- : "top-1/2 -translate-y-1/2",
306
+ <EndHintRow hint={hasLabel ? undefined : hintParts.labelHint}>
307
+ {withLock(
308
+ <div ref={fieldRef} className={cn("relative", locked && "cursor-not-allowed")}>
309
+ {icon && (
310
+ <span
311
+ aria-hidden
312
+ className={cn(
313
+ "pointer-events-none absolute flex text-[var(--text-muted)]",
314
+ small ? "start-2 [&>svg]:size-3.5" : "start-2.5 [&>svg]:size-4",
315
+ // Labelled, the icon sits on the VALUE's line — the one line box below
316
+ // FIELD_FLOATING_PAD's `pt-4` and the 1px border — not on the middle of
317
+ // the box. Centred on the box it rose into the top strip and sat on the
318
+ // label's first letters, which float at the same start inset. On the
319
+ // value line it is beside what it describes, and the label keeps the
320
+ // start edge every other label in the form's column shares.
321
+ hasLabel
322
+ ? "top-[calc(1rem+1px)] h-5 items-center"
323
+ : "top-1/2 -translate-y-1/2",
324
+ )}
325
+ >
326
+ {icon}
327
+ </span>
265
328
  )}
266
- >
267
- {icon}
268
- </span>
269
- )}
270
- <input
271
- {...rest}
272
- ref={ref}
273
- id={fieldId}
274
- size={nativeSize}
275
- type="text"
276
- value={value}
277
- disabled={disabled}
278
- role="combobox"
279
- aria-autocomplete="list"
280
- aria-expanded={expanded}
281
- // Required by the role, and set while closed too, as on the siblings.
282
- aria-controls={listboxId}
283
- aria-activedescendant={activeId}
284
- aria-label={ariaLabel}
285
- aria-labelledby={labelledBy}
286
- aria-invalid={field.isInvalid || rest["aria-invalid"] || undefined}
287
- aria-describedby={field.describedBy}
288
- autoComplete="off"
289
- onFocus={(e) => {
290
- onFocus?.(e);
291
- // A seeded field (keksdose's `initialQuery`) is looked up as it is
292
- // focused; an empty one asks nothing, because of `minChars`.
293
- setOpen(true);
294
- }}
295
- onBlur={(e) => {
296
- onBlur?.(e);
297
- close();
298
- }}
299
- onChange={(e) => {
300
- onChange(e.target.value);
301
- setOpen(true);
302
- setActive(-1);
303
- }}
304
- onKeyDown={(e) => {
305
- // An Escape that closes the OPEN list is the list's, and is consumed before
306
- // the caller sees it. A caller whose Escape means "close the panel" (keksdose's
307
- // address search) otherwise closed the whole panel when the user only meant
308
- // to dismiss the suggestions — the opposite of what the docs promised.
309
- if (e.key === "Escape" && expanded) {
310
- e.preventDefault();
311
- e.stopPropagation();
312
- close();
313
- return;
314
- }
315
- onKeyDown?.(e);
316
- if (e.defaultPrevented) return;
317
- // Disabled rows are passed over; at either end the highlight stays put.
318
- if (e.key === "ArrowDown") {
319
- e.preventDefault();
320
- if (!open) setOpen(true);
321
- else setActive((i) => stepEnabled(results, i, 1));
322
- } else if (e.key === "ArrowUp") {
323
- e.preventDefault();
324
- if (!open) setOpen(true);
325
- // From "nothing highlighted", Up goes to the bottom, as the APG has it.
326
- else setActive((i) => stepEnabled(results, i < 0 ? results.length : i, -1));
327
- } else if (e.key === "Enter") {
328
- // No row highlighted: the text is the answer, and a form's own submit
329
- // is left alone.
330
- if (expanded && activeId) {
331
- e.preventDefault();
332
- take(results[active]);
333
- }
334
- } else if (e.key === "Tab") {
335
- close();
336
- }
337
- }}
338
- className={cn(
339
- FIELD_BASE,
340
- hasLabel && FIELD_FLOATING_PAD,
341
- // Select's `SELECT_SM` box: a fixed 28px, so the caller's line height
342
- // cannot grow it.
343
- small && "h-7 py-0 pe-2 ps-2 text-xs",
344
- icon ? (small ? "ps-7" : "ps-8") : undefined,
345
- busy && (small ? "pe-7" : "pe-9"),
346
- field.isInvalid && FIELD_INVALID,
347
- inputClassName,
348
- )}
349
- />
350
- {busy && live && (
351
- // Decorative: the live region below already says "Loading…".
352
- <span
353
- className={cn(
354
- "pointer-events-none absolute top-1/2 flex -translate-y-1/2",
355
- small ? "end-2" : "end-2.5",
329
+ <input
330
+ {...rest}
331
+ ref={ref}
332
+ id={fieldId}
333
+ size={nativeSize}
334
+ type="text"
335
+ value={value}
336
+ // Locked: focusable, `readOnly`, `aria-disabled` — see `disabledReason`.
337
+ disabled={locked ? undefined : disabled}
338
+ readOnly={locked || rest.readOnly}
339
+ aria-disabled={locked || rest["aria-disabled"]}
340
+ role="combobox"
341
+ aria-autocomplete="list"
342
+ aria-expanded={expanded}
343
+ // Required by the role, and set while closed too, as on the siblings.
344
+ aria-controls={listboxId}
345
+ aria-activedescendant={activeId}
346
+ aria-label={ariaLabel}
347
+ aria-labelledby={labelledBy}
348
+ aria-invalid={field.isInvalid || rest["aria-invalid"] || undefined}
349
+ aria-describedby={mergeDescribedBy(field.describedBy, locked && lock.reasonId)}
350
+ autoComplete="off"
351
+ onFocus={(e) => {
352
+ onFocus?.(e);
353
+ // A locked field takes focus to say why, and opens nothing.
354
+ if (locked) return;
355
+ // A seeded field (keksdose's `initialQuery`) is looked up as it is
356
+ // focused; an empty one asks nothing, because of `minChars`.
357
+ setOpen(true);
358
+ }}
359
+ onBlur={(e) => {
360
+ onBlur?.(e);
361
+ close();
362
+ }}
363
+ onChange={(e) => {
364
+ if (locked) return;
365
+ onChange(e.target.value);
366
+ setOpen(true);
367
+ setActive(-1);
368
+ }}
369
+ onKeyDown={(e) => {
370
+ // Locked: Enter is swallowed (no form submit, no caller's "search now");
371
+ // every other key is the caller's and the caret's. No list to drive.
372
+ if (locked) {
373
+ if (e.key === "Enter") e.preventDefault();
374
+ else onKeyDown?.(e);
375
+ return;
376
+ }
377
+ // An Escape that closes the OPEN list is the list's, and is consumed before
378
+ // the caller sees it. A caller whose Escape means "close the panel" (keksdose's
379
+ // address search) otherwise closed the whole panel when the user only meant
380
+ // to dismiss the suggestions — the opposite of what the docs promised.
381
+ if (e.key === "Escape" && expanded) {
382
+ e.preventDefault();
383
+ e.stopPropagation();
384
+ close();
385
+ return;
386
+ }
387
+ onKeyDown?.(e);
388
+ if (e.defaultPrevented) return;
389
+ // Disabled rows are passed over; at either end the highlight stays put.
390
+ if (e.key === "ArrowDown") {
391
+ e.preventDefault();
392
+ if (!open) setOpen(true);
393
+ else setActive((i) => stepEnabled(results, i, 1));
394
+ } else if (e.key === "ArrowUp") {
395
+ e.preventDefault();
396
+ if (!open) setOpen(true);
397
+ // From "nothing highlighted", Up goes to the bottom, as the APG has it.
398
+ else setActive((i) => stepEnabled(results, i < 0 ? results.length : i, -1));
399
+ } else if (e.key === "Enter") {
400
+ // No row highlighted: the text is the answer, and a form's own submit
401
+ // is left alone.
402
+ if (expanded && activeId) {
403
+ e.preventDefault();
404
+ take(results[active]);
405
+ }
406
+ } else if (e.key === "Tab") {
407
+ close();
408
+ }
409
+ }}
410
+ className={cn(
411
+ FIELD_BASE,
412
+ hasLabel && FIELD_FLOATING_PAD,
413
+ // Select's `SELECT_SM` box: a fixed 28px, so the caller's line height
414
+ // cannot grow it.
415
+ small && "h-7 py-0 pe-2 ps-2 text-xs",
416
+ icon ? (small ? "ps-7" : "ps-8") : undefined,
417
+ busy && (small ? "pe-7" : "pe-9"),
418
+ field.isInvalid && FIELD_INVALID,
419
+ inputClassName,
420
+ )}
421
+ />
422
+ {busy && live && (
423
+ // Decorative: the live region below already says "Loading…".
424
+ <span
425
+ className={cn(
426
+ "pointer-events-none absolute top-1/2 flex -translate-y-1/2",
427
+ small ? "end-2" : "end-2.5",
428
+ )}
429
+ >
430
+ <Spinner label={null} className={small ? "h-3.5 w-3.5" : "h-4 w-4"} />
431
+ </span>
356
432
  )}
357
- >
358
- <Spinner label={null} className={small ? "h-3.5 w-3.5" : "h-4 w-4"} />
359
- </span>
433
+ </div>,
360
434
  )}
361
- </div>
435
+ </EndHintRow>
436
+ {/* With a "?" the label shares the top strip with it, after the field so the "?"
437
+ follows the control in the tab order (as on Input and Select). */}
438
+ {hasLabel && hintParts.labelHint !== undefined && (
439
+ <StaticLabelRow hint={hintParts.labelHint}>
440
+ <ComboboxFieldLabel id={labelId} htmlFor={fieldId} className={LABEL_IN_ROW}>
441
+ {label}
442
+ </ComboboxFieldLabel>
443
+ </StaticLabelRow>
444
+ )}
445
+ <FieldCaption parts={hintParts} />
362
446
  {field.errorEl}
363
447
  {/* The live region, kept mounted so a change IS an announcement: focus stays
364
448
  in the field, and without it a reader typed into a geocoder and heard only
@@ -0,0 +1,229 @@
1
+ import { useId } from "react";
2
+ import type { ReactNode } from "react";
3
+ import { cn } from "../lib/cn";
4
+ import { Checkbox } from "./checkbox";
5
+ import { hasMessage, mergeDescribedBy } from "./choice-parts";
6
+ import { useCommitReason } from "./write-lock";
7
+ import { RequiredStarOnLegend } from "./field-parts";
8
+
9
+ /** One box of a {@link CheckboxGroup}. */
10
+ export interface CheckboxGroupOption<T extends string = string> {
11
+ value: T;
12
+ /** The words beside the box — its accessible name. */
13
+ label: ReactNode;
14
+ /** A line under the label ("Imprint, Privacy Policy and Terms only"), attached to the
15
+ * box with `aria-describedby` — {@link Checkbox}'s `description`. */
16
+ hint?: ReactNode;
17
+ /** This box cannot be changed; the others can. */
18
+ disabled?: boolean;
19
+ }
20
+
21
+ /** Whether the legend is drawn. `"sr-only"` keeps it as the group's name for a screen
22
+ * reader where the dialog's title already says it to the eye. */
23
+ export type CheckboxGroupLegendVisibility = "visible" | "sr-only";
24
+
25
+ export interface CheckboxGroupProps<T extends string = string> {
26
+ /** The question the boxes answer — the fieldset's `<legend>`, which names the group.
27
+ * Without one, pass `aria-label`. */
28
+ legend?: ReactNode;
29
+ /** Default `"visible"`. */
30
+ legendVisibility?: CheckboxGroupLegendVisibility;
31
+ "aria-label"?: string;
32
+ /** One box per option, in this order. */
33
+ options: CheckboxGroupOption<T>[];
34
+ /** The ticked values. Controlled. */
35
+ value: T[];
36
+ /**
37
+ * The new set on every tick. In the ORDER OF `options`, not the order the boxes were
38
+ * ticked, so a list saved from it does not change with the order someone clicked in
39
+ * (ChoiceCardGroup's rule). A value in `value` that no option carries — a grant for a
40
+ * locale the app no longer offers — is kept, after the known ones: a picker that
41
+ * dropped what it could not show would delete it on the next unrelated tick.
42
+ */
43
+ onChange: (value: T[]) => void;
44
+ /**
45
+ * `"vertical"` (default): one box per line. `"horizontal"`: a wrapping row, for a few
46
+ * short labels. With `columns`, vertical becomes a grid of that many columns from the
47
+ * `sm` breakpoint up and stays one column on a phone — the reviewer dialog's shape.
48
+ */
49
+ layout?: "vertical" | "horizontal";
50
+ /** Columns for a vertical group, from `sm` up (one on a phone). Default 1. */
51
+ columns?: 1 | 2 | 3 | 4;
52
+ /** Shared by every box — what a plain form submits the values under. */
53
+ name?: string;
54
+ /** Every box: the fieldset's own `disabled` (which disables them all natively), each
55
+ * box faded as a disabled Checkbox is, and the legend with them. */
56
+ disabled?: boolean;
57
+ /**
58
+ * At least one box must be ticked. Draws the required star after the legend, and puts
59
+ * the native `required` on every box while NONE is ticked — so a form refuses an
60
+ * empty set — and on none once one is (ChoiceCardGroup's rule: checkboxes cannot say
61
+ * "at least one" natively).
62
+ */
63
+ required?: boolean;
64
+ /**
65
+ * Standing advice for the group, under the boxes, attached to the fieldset with
66
+ * `aria-describedby` before the error — the field anatomy every kit field keeps.
67
+ */
68
+ hint?: ReactNode;
69
+ /** What is wrong with the set, in the caller's words. Under the boxes, on the
70
+ * fieldset's `aria-describedby`, and paints every box invalid. */
71
+ error?: ReactNode;
72
+ /** Paints every box invalid without a message (the message lives elsewhere). */
73
+ invalid?: boolean;
74
+ /**
75
+ * The group SAVES on change — each tick is written straight away (a reviewer's
76
+ * per-locale grants). Under a locked {@link WriteLockProvider} every box is locked
77
+ * the focusable way with the lock's reason. See {@link Checkbox}'s `commit`.
78
+ */
79
+ commit?: boolean;
80
+ /** Why no box can be changed — every box gets it, and it wins over `disabled` (the
81
+ * boxes stay focusable to say it). See {@link Checkbox}'s `disabledReason`. */
82
+ disabledReason?: ReactNode;
83
+ /** The fieldset's id. */
84
+ id?: string;
85
+ /** Classes for the fieldset — the outermost element, where layout belongs. */
86
+ className?: string;
87
+ /** Classes for the box list (the grid or the row). */
88
+ listClassName?: string;
89
+ /** Ids of something else on the page that describes the group; merged before the
90
+ * group's own hint and error. */
91
+ "aria-describedby"?: string;
92
+ }
93
+
94
+ // Literal class strings so Tailwind's scanner sees each one.
95
+ const COLUMNS: Record<NonNullable<CheckboxGroupProps["columns"]>, string> = {
96
+ 1: "flex flex-col gap-2",
97
+ 2: "grid grid-cols-1 gap-2 sm:grid-cols-2",
98
+ 3: "grid grid-cols-1 gap-2 sm:grid-cols-3",
99
+ 4: "grid grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-4",
100
+ };
101
+
102
+ /**
103
+ * A set of kit {@link Checkbox}es answering one question, in a `<fieldset>`.
104
+ *
105
+ * Kurvenschmiede's reviewer dialog (features/admin/reviewer-dialog.tsx) builds this by
106
+ * hand — a fieldset, an `sr-only` legend, a grid of checkboxes, a `toggle(code, on)`
107
+ * that adds or filters a `string[]` — and keksdose's and kastlan's reviewer grants build
108
+ * the same picker for the same per-locale grant. The hand-built ones differ in the parts
109
+ * that are easy to get wrong: whether the legend names the group, whether a hint or an
110
+ * error is attached to anything, what order the saved list comes out in, and whether a
111
+ * value the picker cannot show survives a tick.
112
+ *
113
+ * Every box is a kit {@link Checkbox}, so the native checkbox semantics stay (each box
114
+ * submits under `name` in a plain form). The legend is the group's name; the hint and
115
+ * error describe the fieldset. For a choice of ONE, use radios ({@link ChoiceCardGroup},
116
+ * {@link ToggleGroup}).
117
+ */
118
+ export function CheckboxGroup<T extends string = string>({
119
+ legend,
120
+ legendVisibility = "visible",
121
+ "aria-label": ariaLabel,
122
+ options,
123
+ value,
124
+ onChange,
125
+ layout = "vertical",
126
+ columns = 1,
127
+ name,
128
+ disabled,
129
+ required,
130
+ hint,
131
+ error,
132
+ invalid,
133
+ commit,
134
+ disabledReason,
135
+ id,
136
+ className,
137
+ listClassName,
138
+ "aria-describedby": ariaDescribedBy,
139
+ }: CheckboxGroupProps<T>) {
140
+ const generated = useId();
141
+ const hintId = `${generated}-hint`;
142
+ const errorId = `${generated}-error`;
143
+ const showHint = hasMessage(hint);
144
+ const showError = hasMessage(error);
145
+ const isInvalid = Boolean(invalid) || showError;
146
+ const ticked = new Set(value);
147
+ // Resolved here rather than per box: a reason has to keep the fieldset from being
148
+ // natively `disabled` too, or it would take every box out of the tab order — the
149
+ // thing a reason exists to prevent.
150
+ const reason = useCommitReason(commit, disabledReason);
151
+ const locked = hasMessage(reason);
152
+
153
+ const toggle = (v: T, on: boolean) => {
154
+ const next = new Set(value);
155
+ if (on) next.add(v);
156
+ else next.delete(v);
157
+ const known = new Set(options.map((o) => o.value));
158
+ onChange([
159
+ ...options.map((o) => o.value).filter((x) => next.has(x)),
160
+ // Values no option carries, kept in their own order — see `onChange`.
161
+ ...value.filter((x) => !known.has(x)),
162
+ ]);
163
+ };
164
+
165
+ return (
166
+ <fieldset
167
+ id={id}
168
+ aria-label={ariaLabel}
169
+ aria-describedby={mergeDescribedBy(ariaDescribedBy, showHint && hintId, showError && errorId)}
170
+ disabled={disabled && !locked}
171
+ // `relative` holds an `sr-only` legend (sr-only-containment.test); `min-w-0`
172
+ // undoes a fieldset's `min-width: min-content`, which would otherwise push a long
173
+ // label past a phone's edge instead of wrapping it.
174
+ className={cn("relative m-0 min-w-0 border-0 p-0", className)}
175
+ >
176
+ {legend !== undefined && (
177
+ <legend
178
+ className={
179
+ legendVisibility === "sr-only"
180
+ ? "sr-only"
181
+ : cn("mb-2 p-0 text-sm font-medium text-[var(--text-primary)]", (disabled || locked) && "opacity-60")
182
+ }
183
+ >
184
+ {legend}
185
+ {required && (
186
+ <span aria-hidden className="ms-0.5 text-[var(--danger)]">
187
+ *
188
+ </span>
189
+ )}
190
+ </legend>
191
+ )}
192
+ <RequiredStarOnLegend.Provider value={true}>
193
+ <div
194
+ className={cn(
195
+ layout === "horizontal" ? "flex flex-wrap gap-x-4 gap-y-2" : COLUMNS[columns],
196
+ listClassName,
197
+ )}
198
+ >
199
+ {options.map((o) => (
200
+ <Checkbox
201
+ key={o.value}
202
+ name={name}
203
+ value={o.value}
204
+ label={o.label}
205
+ description={o.hint}
206
+ checked={ticked.has(o.value)}
207
+ onCheckedChange={(on) => toggle(o.value, on)}
208
+ disabled={disabled || o.disabled}
209
+ invalid={isInvalid || undefined}
210
+ required={required && value.length === 0 ? true : undefined}
211
+ disabledReason={reason}
212
+ />
213
+ ))}
214
+ </div>
215
+ </RequiredStarOnLegend.Provider>
216
+ {showHint && (
217
+ // A `div`, not a `p`: a hint may be a node with a link in it.
218
+ <div id={hintId} className="mt-1.5 text-[11px] leading-tight text-[var(--text-muted)]">
219
+ {hint}
220
+ </div>
221
+ )}
222
+ {showError && (
223
+ <p id={errorId} className="mt-1 text-[11px] leading-tight text-[var(--danger)]">
224
+ {error}
225
+ </p>
226
+ )}
227
+ </fieldset>
228
+ );
229
+ }