@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
@@ -22,6 +22,7 @@ import {
22
22
  type ComboOption,
23
23
  } from "./combobox-core";
24
24
  import { DEFAULT_COMBOBOX_LABELS, useKitLabels } from "../i18n/kit-labels";
25
+ import { EndHintRow, FieldCaption, LABEL_IN_ROW, StaticLabelRow, useFieldHint } from "./field-parts";
25
26
 
26
27
  /**
27
28
  * `value`/`onChange` are the TEXT's, and `onSelect` is "a suggestion was taken" —
@@ -104,6 +105,14 @@ export interface AutocompleteProps<V extends string | number = string>
104
105
  * its `aria-describedby`, implies `invalid`. Not a failed lookup — that is
105
106
  * `loadErrorLabel`, shown in the list. */
106
107
  error?: ReactNode;
108
+ /**
109
+ * Standing advice — {@link Combobox}'s `hint`, so this field reads like every other
110
+ * (keksdose K4): plain TEXT is a caption under the field on the input's
111
+ * `aria-describedby`, before any error; a {@link FieldHint} "?" rides the label line,
112
+ * or with no label sits at the field's end edge, outside the box. Not the list's
113
+ * `status` line, which talks about the lookup, not the field.
114
+ */
115
+ hint?: ReactNode;
107
116
  /**
108
117
  * The caller's own line in the list, replacing the automatic one (loading / failed
109
118
  * / no results). For what only the caller knows: "offline", "the address service
@@ -135,6 +144,7 @@ function AutocompleteInner<V extends string | number = string>(
135
144
  icon,
136
145
  invalid,
137
146
  error,
147
+ hint,
138
148
  status,
139
149
  emptyLabel,
140
150
  loadErrorLabel,
@@ -166,7 +176,8 @@ function AutocompleteInner<V extends string | number = string>(
166
176
  noResults: emptyLabel,
167
177
  loadError: loadErrorLabel,
168
178
  });
169
- const field = useComboboxFieldError(error, invalid, ariaDescribedBy);
179
+ const hintParts = useFieldHint(hint, ariaDescribedBy);
180
+ const field = useComboboxFieldError(error, invalid, hintParts.describedBy);
170
181
 
171
182
  /** The user's intent: the field is focused and the list has not been dismissed.
172
183
  * Whether anything SHOWS is decided below, from what there is to show. */
@@ -241,124 +252,136 @@ function AutocompleteInner<V extends string | number = string>(
241
252
  <div className={cn("relative", className)}>
242
253
  {/* A real <label for> (lenkbank), and still `aria-labelledby`'s target, so the
243
254
  name — and the listbox's, which borrows it — is exactly what it was. */}
244
- {hasLabel && (
255
+ {hasLabel && hintParts.labelHint === undefined && (
245
256
  <ComboboxFieldLabel id={labelId} htmlFor={fieldId}>
246
257
  {label}
247
258
  </ComboboxFieldLabel>
248
259
  )}
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",
265
- )}
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();
260
+ <EndHintRow hint={hasLabel ? undefined : hintParts.labelHint}>
261
+ <div ref={fieldRef} className="relative">
262
+ {icon && (
263
+ <span
264
+ aria-hidden
265
+ className={cn(
266
+ "pointer-events-none absolute flex text-[var(--text-muted)]",
267
+ small ? "start-2 [&>svg]:size-3.5" : "start-2.5 [&>svg]:size-4",
268
+ // Labelled, the icon sits on the VALUE's line — the one line box below
269
+ // FIELD_FLOATING_PAD's `pt-4` and the 1px border — not on the middle of
270
+ // the box. Centred on the box it rose into the top strip and sat on the
271
+ // label's first letters, which float at the same start inset. On the
272
+ // value line it is beside what it describes, and the label keeps the
273
+ // start edge every other label in the form's column shares.
274
+ hasLabel
275
+ ? "top-[calc(1rem+1px)] h-5 items-center"
276
+ : "top-1/2 -translate-y-1/2",
277
+ )}
278
+ >
279
+ {icon}
280
+ </span>
281
+ )}
282
+ <input
283
+ {...rest}
284
+ ref={ref}
285
+ id={fieldId}
286
+ size={nativeSize}
287
+ type="text"
288
+ value={value}
289
+ disabled={disabled}
290
+ role="combobox"
291
+ aria-autocomplete="list"
292
+ aria-expanded={expanded}
293
+ // Required by the role, and set while closed too, as on the siblings.
294
+ aria-controls={listboxId}
295
+ aria-activedescendant={activeId}
296
+ aria-label={ariaLabel}
297
+ aria-labelledby={labelledBy}
298
+ aria-invalid={field.isInvalid || rest["aria-invalid"] || undefined}
299
+ aria-describedby={field.describedBy}
300
+ autoComplete="off"
301
+ onFocus={(e) => {
302
+ onFocus?.(e);
303
+ // A seeded field (keksdose's `initialQuery`) is looked up as it is
304
+ // focused; an empty one asks nothing, because of `minChars`.
305
+ setOpen(true);
306
+ }}
307
+ onBlur={(e) => {
308
+ onBlur?.(e);
312
309
  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) {
310
+ }}
311
+ onChange={(e) => {
312
+ onChange(e.target.value);
313
+ setOpen(true);
314
+ setActive(-1);
315
+ }}
316
+ onKeyDown={(e) => {
317
+ // An Escape that closes the OPEN list is the list's, and is consumed before
318
+ // the caller sees it. A caller whose Escape means "close the panel" (keksdose's
319
+ // address search) otherwise closed the whole panel when the user only meant
320
+ // to dismiss the suggestions — the opposite of what the docs promised.
321
+ if (e.key === "Escape" && expanded) {
331
322
  e.preventDefault();
332
- take(results[active]);
323
+ e.stopPropagation();
324
+ close();
325
+ return;
333
326
  }
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
327
+ onKeyDown?.(e);
328
+ if (e.defaultPrevented) return;
329
+ // Disabled rows are passed over; at either end the highlight stays put.
330
+ if (e.key === "ArrowDown") {
331
+ e.preventDefault();
332
+ if (!open) setOpen(true);
333
+ else setActive((i) => stepEnabled(results, i, 1));
334
+ } else if (e.key === "ArrowUp") {
335
+ e.preventDefault();
336
+ if (!open) setOpen(true);
337
+ // From "nothing highlighted", Up goes to the bottom, as the APG has it.
338
+ else setActive((i) => stepEnabled(results, i < 0 ? results.length : i, -1));
339
+ } else if (e.key === "Enter") {
340
+ // No row highlighted: the text is the answer, and a form's own submit
341
+ // is left alone.
342
+ if (expanded && activeId) {
343
+ e.preventDefault();
344
+ take(results[active]);
345
+ }
346
+ } else if (e.key === "Tab") {
347
+ close();
348
+ }
349
+ }}
353
350
  className={cn(
354
- "pointer-events-none absolute top-1/2 flex -translate-y-1/2",
355
- small ? "end-2" : "end-2.5",
351
+ FIELD_BASE,
352
+ hasLabel && FIELD_FLOATING_PAD,
353
+ // Select's `SELECT_SM` box: a fixed 28px, so the caller's line height
354
+ // cannot grow it.
355
+ small && "h-7 py-0 pe-2 ps-2 text-xs",
356
+ icon ? (small ? "ps-7" : "ps-8") : undefined,
357
+ busy && (small ? "pe-7" : "pe-9"),
358
+ field.isInvalid && FIELD_INVALID,
359
+ inputClassName,
356
360
  )}
357
- >
358
- <Spinner label={null} className={small ? "h-3.5 w-3.5" : "h-4 w-4"} />
359
- </span>
360
- )}
361
- </div>
361
+ />
362
+ {busy && live && (
363
+ // Decorative: the live region below already says "Loading…".
364
+ <span
365
+ className={cn(
366
+ "pointer-events-none absolute top-1/2 flex -translate-y-1/2",
367
+ small ? "end-2" : "end-2.5",
368
+ )}
369
+ >
370
+ <Spinner label={null} className={small ? "h-3.5 w-3.5" : "h-4 w-4"} />
371
+ </span>
372
+ )}
373
+ </div>
374
+ </EndHintRow>
375
+ {/* With a "?" the label shares the top strip with it, after the field so the "?"
376
+ follows the control in the tab order (as on Input and Select). */}
377
+ {hasLabel && hintParts.labelHint !== undefined && (
378
+ <StaticLabelRow hint={hintParts.labelHint}>
379
+ <ComboboxFieldLabel id={labelId} htmlFor={fieldId} className={LABEL_IN_ROW}>
380
+ {label}
381
+ </ComboboxFieldLabel>
382
+ </StaticLabelRow>
383
+ )}
384
+ <FieldCaption parts={hintParts} />
362
385
  {field.errorEl}
363
386
  {/* The live region, kept mounted so a change IS an announcement: focus stays
364
387
  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
+ }