@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
@@ -11,6 +11,7 @@ import {
11
11
  type ComboOption,
12
12
  } from "./combobox-core";
13
13
  import { DEFAULT_COMBOBOX_LABELS, DEFAULT_COMMON_LABELS, useKitLabels } from "../i18n/kit-labels";
14
+ import { EndHintRow, FieldCaption, LABEL_IN_ROW, StaticLabelRow, useFieldHint } from "./field-parts";
14
15
 
15
16
  export type { ComboClearValue, ComboOption } from "./combobox-core";
16
17
 
@@ -61,6 +62,13 @@ export interface EntityComboboxProps<V extends string | number, C extends ComboC
61
62
  /** What is wrong with the value, as {@link Input}'s `error`: rendered under the
62
63
  * field, on the trigger's `aria-describedby`, and implies `invalid`. */
63
64
  error?: ReactNode;
65
+ /**
66
+ * Standing advice — {@link Combobox}'s `hint`, so the pickers read like every other
67
+ * field (keksdose K4): plain TEXT is a caption under the field on the trigger's
68
+ * `aria-describedby`, before any error; a {@link FieldHint} "?" rides the label line,
69
+ * or with no label sits at the trigger's end edge, outside the box.
70
+ */
71
+ hint?: ReactNode;
64
72
  /** Narrow `options` client-side by the query. Default `true`; `false` shows them
65
73
  * as given (a server-ranked list). */
66
74
  filter?: boolean;
@@ -100,6 +108,7 @@ export function EntityCombobox<V extends string | number, C extends ComboClearVa
100
108
  className,
101
109
  invalid,
102
110
  error,
111
+ hint,
103
112
  filter,
104
113
  minChars,
105
114
  debounceMs,
@@ -123,10 +132,11 @@ export function EntityCombobox<V extends string | number, C extends ComboClearVa
123
132
  minChars,
124
133
  debounceMs,
125
134
  });
135
+ const hintParts = useFieldHint(hint, ariaDescribedBy);
126
136
  const field = useComboboxFieldError(
127
137
  error,
128
138
  invalid || ariaInvalid === true || ariaInvalid === "true",
129
- ariaDescribedBy,
139
+ hintParts.describedBy,
130
140
  );
131
141
  // The props are the per-instance overrides, the provider the app-wide ones; a
132
142
  // prop left `undefined` falls through to the provider rather than masking it.
@@ -168,94 +178,103 @@ export function EntityCombobox<V extends string | number, C extends ComboClearVa
168
178
  // has one. Spread FIRST so the trigger's ARIA and the
169
179
  // handlers that open the panel cannot be clobbered from outside.
170
180
  <div {...rest} className={cn("relative", className)}>
171
- {label !== undefined && <FieldLabel>{label}</FieldLabel>}
172
- <button
173
- ref={triggerRef}
174
- id={id}
175
- type="button"
176
- // A combobox, not a button. The distinction is not pedantry: this control
177
- // carried `aria-invalid`, which `button` does not support, so a required
178
- // field left empty painted a rose border and told a reader nothing at all —
179
- // and ESLint flagged it as exactly that (`role-supports-aria-props`). The
180
- // fix the audit asked for is the role that describes what this IS: a closed
181
- // choice that expands into the list named below. `combobox` supports
182
- // `aria-invalid`, so the border and the announcement finally agree.
183
- role="combobox"
184
- aria-expanded={open}
185
- aria-controls={listboxId}
186
- aria-haspopup="listbox"
187
- // The label is a floating <span>, not a <label for>, so without this the
188
- // trigger's accessible name is whatever value happens to be selected —
189
- // "Checking" with nothing saying it is the account. The label AND the
190
- // value, because `aria-label` replaces the content rather than adding to
191
- // it, and a control that announces only its name has lost the answer.
192
- // A caller's own name wins over the composition: two fields labelled
193
- // "Account" on a transfer form are the from and the to, and only the
194
- // caller knows which is which.
195
- aria-label={
196
- ariaLabel ??
197
- (typeof label === "string" ? common.fieldValue(label, triggerText) : undefined)
198
- }
199
- disabled={disabled}
200
- aria-invalid={field.isInvalid || undefined}
201
- aria-describedby={field.describedBy}
202
- aria-required={ariaRequired}
203
- onClick={() => !disabled && setOpen((o) => !o)}
204
- // Down/Up opens the list from the closed trigger, per the APG. Enter and
205
- // Space already do it through the button's own click.
206
- onKeyDown={(e) => {
207
- if (disabled) return;
208
- if (e.key === "ArrowDown" || e.key === "ArrowUp") {
209
- e.preventDefault();
210
- setOpen(true);
181
+ {label !== undefined && hintParts.labelHint === undefined && <FieldLabel>{label}</FieldLabel>}
182
+ <EndHintRow hint={label === undefined ? hintParts.labelHint : undefined}>
183
+ <button
184
+ ref={triggerRef}
185
+ id={id}
186
+ type="button"
187
+ // A combobox, not a button. The distinction is not pedantry: this control
188
+ // carried `aria-invalid`, which `button` does not support, so a required
189
+ // field left empty painted a rose border and told a reader nothing at all —
190
+ // and ESLint flagged it as exactly that (`role-supports-aria-props`). The
191
+ // fix the audit asked for is the role that describes what this IS: a closed
192
+ // choice that expands into the list named below. `combobox` supports
193
+ // `aria-invalid`, so the border and the announcement finally agree.
194
+ role="combobox"
195
+ aria-expanded={open}
196
+ aria-controls={listboxId}
197
+ aria-haspopup="listbox"
198
+ // The label is a floating <span>, not a <label for>, so without this the
199
+ // trigger's accessible name is whatever value happens to be selected —
200
+ // "Checking" with nothing saying it is the account. The label AND the
201
+ // value, because `aria-label` replaces the content rather than adding to
202
+ // it, and a control that announces only its name has lost the answer.
203
+ // A caller's own name wins over the composition: two fields labelled
204
+ // "Account" on a transfer form are the from and the to, and only the
205
+ // caller knows which is which.
206
+ aria-label={
207
+ ariaLabel ??
208
+ (typeof label === "string" ? common.fieldValue(label, triggerText) : undefined)
211
209
  }
212
- }}
213
- className={cn(
214
- FIELD_TRIGGER,
215
- "pe-9",
216
- label !== undefined && FIELD_FLOATING_PAD,
217
- disabled && "cursor-not-allowed opacity-50",
218
- field.isInvalid && FIELD_INVALID,
219
- )}
220
- >
221
- <span className="flex min-w-0 items-center gap-2">
222
- {selectedOption?.icon && <span className="shrink-0">{selectedOption.icon}</span>}
223
- <span
224
- className={cn(
225
- "truncate",
226
- // A chosen value is the field's VALUE, so it is set in the same ink an
227
- // <input>'s value is — FIELD_BASE's own text colour, inherited rather
228
- // than restated (Keksdose dev#477). It used to be one notch lighter
229
- // than the typeahead fields beside it, which is visible when a picker
230
- // and a text field share a form row. Nothing selected keeps the
231
- // placeholder tone (`--text-placeholder`), which every field here
232
- // agrees on.
233
- !selectedOption && "text-[var(--text-placeholder)]",
234
- )}
235
- >
236
- {selectedOption?.label ?? placeholder ?? ""}
237
- </span>
238
- </span>
239
- {showClear ? (
240
- /* eslint-disable-next-line jsx-a11y/click-events-have-key-events -- tabIndex -1 inside
241
- the trigger <button>, so it never holds focus and a key handler here could
242
- never fire; the keys go to the trigger. A pointer shortcut only. */
243
- <span
244
- role="button"
245
- tabIndex={-1}
246
- aria-label={labels.clear}
247
- onClick={(e) => {
248
- e.stopPropagation();
249
- onChange(clearValue);
250
- }}
251
- className="absolute end-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-[var(--text-placeholder)] hover:text-[var(--text-secondary)]"
252
- >
253
- <X className="size-4" />
210
+ disabled={disabled}
211
+ aria-invalid={field.isInvalid || undefined}
212
+ aria-describedby={field.describedBy}
213
+ aria-required={ariaRequired}
214
+ onClick={() => !disabled && setOpen((o) => !o)}
215
+ // Down/Up opens the list from the closed trigger, per the APG. Enter and
216
+ // Space already do it through the button's own click.
217
+ onKeyDown={(e) => {
218
+ if (disabled) return;
219
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
220
+ e.preventDefault();
221
+ setOpen(true);
222
+ }
223
+ }}
224
+ className={cn(
225
+ FIELD_TRIGGER,
226
+ "pe-9",
227
+ label !== undefined && FIELD_FLOATING_PAD,
228
+ disabled && "cursor-not-allowed opacity-50",
229
+ field.isInvalid && FIELD_INVALID,
230
+ )}
231
+ >
232
+ <span className="flex min-w-0 items-center gap-2">
233
+ {selectedOption?.icon && <span className="shrink-0">{selectedOption.icon}</span>}
234
+ <span
235
+ className={cn(
236
+ "truncate",
237
+ // A chosen value is the field's VALUE, so it is set in the same ink an
238
+ // <input>'s value is — FIELD_BASE's own text colour, inherited rather
239
+ // than restated (Keksdose dev#477). It used to be one notch lighter
240
+ // than the typeahead fields beside it, which is visible when a picker
241
+ // and a text field share a form row. Nothing selected keeps the
242
+ // placeholder tone (`--text-placeholder`), which every field here
243
+ // agrees on.
244
+ !selectedOption && "text-[var(--text-placeholder)]",
245
+ )}
246
+ >
247
+ {selectedOption?.label ?? placeholder ?? ""}
248
+ </span>
254
249
  </span>
255
- ) : (
256
- <FieldChevron />
257
- )}
258
- </button>
250
+ {showClear ? (
251
+ /* eslint-disable-next-line jsx-a11y/click-events-have-key-events -- tabIndex -1 inside
252
+ the trigger <button>, so it never holds focus and a key handler here could
253
+ never fire; the keys go to the trigger. A pointer shortcut only. */
254
+ <span
255
+ role="button"
256
+ tabIndex={-1}
257
+ aria-label={labels.clear}
258
+ onClick={(e) => {
259
+ e.stopPropagation();
260
+ onChange(clearValue);
261
+ }}
262
+ className="absolute end-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-[var(--text-placeholder)] hover:text-[var(--text-secondary)]"
263
+ >
264
+ <X className="size-4" />
265
+ </span>
266
+ ) : (
267
+ <FieldChevron />
268
+ )}
269
+ </button>
270
+ </EndHintRow>
271
+ {/* With a "?" the label shares the top strip with it — after the trigger, so the
272
+ "?" follows the control in the tab order, as on Input and Select. */}
273
+ {label !== undefined && hintParts.labelHint !== undefined && (
274
+ <StaticLabelRow hint={hintParts.labelHint}>
275
+ <FieldLabel className={LABEL_IN_ROW}>{label}</FieldLabel>
276
+ </StaticLabelRow>
277
+ )}
259
278
  <ComboboxPanel
260
279
  core={core}
261
280
  listboxId={listboxId}
@@ -275,6 +294,7 @@ export function EntityCombobox<V extends string | number, C extends ComboClearVa
275
294
  }}
276
295
  createContent={labels.create(q)}
277
296
  />
297
+ <FieldCaption parts={hintParts} />
278
298
  {field.errorEl}
279
299
  </div>
280
300
  );
@@ -0,0 +1,190 @@
1
+ // Internal — not re-exported from the barrel. The pieces of a field's anatomy (its
2
+ // label line, the caption and the error under it, the label STRIP over a bare group)
3
+ // for the controls that are not built on `FloatingField`: the date and month pickers,
4
+ // whose trigger is a button in a wrapper of their own, and the groups (ToggleGroup,
5
+ // SwatchPicker, IconPicker), which have no field box at all.
6
+ //
7
+ // `Input` and `Select` keep their own copies in ui.tsx; these follow them class for
8
+ // class, so a DatePicker's caption and a Select's caption are the same 11px line in
9
+ // the same place (keksdose K4: "uniform field anatomy … today it is a patchwork").
10
+
11
+ import { useId } from "react";
12
+ import type { ReactNode } from "react";
13
+ import { cn } from "../lib/cn";
14
+ import { FieldLabel } from "./ui";
15
+
16
+ /** `undefined`, `null`, `false` and `""` are what a caller's `cond && "…"` evaluates to
17
+ * when there is nothing to say; none of them is content. A numeric `0` is. */
18
+ export function hasContent(node: ReactNode): boolean {
19
+ return node !== undefined && node !== null && node !== false && node !== "";
20
+ }
21
+
22
+ /**
23
+ * A `hint` that is plain TEXT is a caption under the field; anything else (a
24
+ * `FieldHint` "?") rides the label line — the rule `Select` set in 0.15: the label
25
+ * line is 11px of strip shared with the label, and a sentence placed there was set on
26
+ * top of both the label and the value.
27
+ */
28
+ export function isTextHint(hint: ReactNode): boolean {
29
+ return (typeof hint === "string" && hint !== "") || typeof hint === "number";
30
+ }
31
+
32
+ const FIELD_CAPTION_CLASS = "mt-1 text-[11px] leading-tight text-[var(--text-muted)]";
33
+ const FIELD_ERROR_CLASS = "mt-1 text-[11px] leading-tight text-[var(--danger)]";
34
+
35
+ export interface FieldMessages {
36
+ /** `invalid`, a message, or an `aria-invalid` from outside (a form library). */
37
+ isInvalid: boolean;
38
+ /** The caller's description, then the caption, then the error — the standing
39
+ * advice before the news, as `useFieldError` in ui.tsx orders them. */
40
+ describedBy: string | undefined;
41
+ /** The caption and the error, for under the field; `null` when there is neither. */
42
+ below: ReactNode;
43
+ /** The hint for the LABEL line (a `FieldHint`), or `undefined` when the hint is a
44
+ * caption or there is none. */
45
+ labelHint: ReactNode;
46
+ }
47
+
48
+ /** `hint` / `error` / `invalid` resolved the way `Select` resolves them. */
49
+ export function useFieldMessages({
50
+ hint,
51
+ error,
52
+ invalid,
53
+ describedBy,
54
+ ariaInvalid,
55
+ }: {
56
+ hint?: ReactNode;
57
+ error?: ReactNode;
58
+ invalid?: boolean;
59
+ describedBy?: string;
60
+ ariaInvalid?: unknown;
61
+ }): FieldMessages {
62
+ const hintId = useId();
63
+ const errorId = useId();
64
+ const textHint = isTextHint(hint);
65
+ const hasError = hasContent(error);
66
+ return {
67
+ isInvalid: Boolean(invalid) || hasError || ariaInvalid === true || ariaInvalid === "true",
68
+ describedBy:
69
+ [describedBy, textHint && hintId, hasError && errorId].filter(Boolean).join(" ") || undefined,
70
+ below:
71
+ textHint || hasError ? (
72
+ <>
73
+ {textHint && (
74
+ <p id={hintId} className={FIELD_CAPTION_CLASS}>
75
+ {hint}
76
+ </p>
77
+ )}
78
+ {hasError && (
79
+ <p id={errorId} className={FIELD_ERROR_CLASS}>
80
+ {error}
81
+ </p>
82
+ )}
83
+ </>
84
+ ) : null,
85
+ labelHint: !textHint && hasContent(hint) ? hint : undefined,
86
+ };
87
+ }
88
+
89
+ /**
90
+ * The field and what is under it, as one box — `FieldGroup` in ui.tsx, for the same
91
+ * reasons written there. Nothing of its own while there is nothing under the field and
92
+ * the caller passed no `error` key (the DOM is exactly what it was before these props);
93
+ * once `error` is passed the box is kept even with no message, so the trigger is never
94
+ * reparented — and remounted, losing focus — as the message comes and goes.
95
+ */
96
+ export function FieldBox({
97
+ below,
98
+ reserve,
99
+ children,
100
+ }: {
101
+ below: ReactNode;
102
+ reserve: boolean;
103
+ children: ReactNode;
104
+ }) {
105
+ if (below === null && !reserve) return <>{children}</>;
106
+ return (
107
+ <div>
108
+ {children}
109
+ {below}
110
+ </div>
111
+ );
112
+ }
113
+
114
+ /**
115
+ * The static label in a trigger field's top strip — a {@link FieldLabel}, or, with a
116
+ * `FieldHint`, the label and its "?" laid out on ONE flex row (dev#468: two absolute
117
+ * offsets guessing at the same baseline put the "?" three pixels above its word).
118
+ * `FloatingField`'s static-label row, except that it stops at `end-9`: the date and
119
+ * month triggers keep that column (`pe-9`) for the calendar glyph or the clear ×, and a
120
+ * long label truncates before it rather than putting the "?" on top of it.
121
+ */
122
+ export function FieldLabelLine({ label, hint }: { label: ReactNode; hint?: ReactNode }) {
123
+ if (!hasContent(hint)) return <FieldLabel>{label}</FieldLabel>;
124
+ return (
125
+ // `z-10` as FieldLabel has it: the trigger after it is `relative` and would paint
126
+ // over the row (and take the "?"'s hover) otherwise.
127
+ <div className="pointer-events-none absolute start-3 end-9 top-1 z-10 flex items-center gap-1">
128
+ <FieldLabel className="static min-w-0 max-w-full">{label}</FieldLabel>
129
+ <span className="pointer-events-auto flex shrink-0 items-center">{hint}</span>
130
+ </div>
131
+ );
132
+ }
133
+
134
+ /**
135
+ * How tall the strip over a bare group is.
136
+ *
137
+ * - `"toggle"` — 16px. keksdose's `ClearedStatusPicker` (live #288 rework, #431,
138
+ * measured): *"16px of label strip plus a 26px group is exactly a labelled Input"*,
139
+ * so a status group in a row of 42px fields ends where they end. The label's line
140
+ * box runs into the group's top hairline, but the ACTIVE fill — the thing feedback
141
+ * #117 was about — starts at 19px, below the label's 17.75px.
142
+ * - `"tiles"` — 20px, keksdose's `FlagPicker` and location field (`pt-5`). A tile has
143
+ * no inset fill: its selection IS its frame, a 2px dark border-and-ring from the
144
+ * tile's top edge, so the tile has to start below the whole label line.
145
+ */
146
+ export const LABEL_STRIP_PAD = { toggle: "pt-4", tiles: "pt-5" } as const;
147
+
148
+ /**
149
+ * The 11px label (and its `FieldHint`) over a group that is NOT a field — keksdose K2:
150
+ * a segmented control or a row of swatches standing in a form row beside labelled
151
+ * inputs, whose label must sit on the same line as theirs (`top-1`, `inset-x-3`, the
152
+ * type of a field's static label) without the group growing a field's border round
153
+ * it. `id` goes on the label so the group can be `aria-labelledby` it.
154
+ *
155
+ * `relative` is load-bearing twice over: the row is positioned against it, and a
156
+ * caller's `sr-only` label cannot escape it (sr-only-containment).
157
+ */
158
+ export function LabelStrip({
159
+ labelId,
160
+ label,
161
+ hint,
162
+ disabled,
163
+ pad,
164
+ className,
165
+ children,
166
+ }: {
167
+ labelId: string;
168
+ label: ReactNode;
169
+ hint?: ReactNode;
170
+ /** Dim the label with its group, as `peer-disabled:` dims a field's own. */
171
+ disabled?: boolean;
172
+ pad: keyof typeof LABEL_STRIP_PAD;
173
+ className?: string;
174
+ children: ReactNode;
175
+ }) {
176
+ return (
177
+ <div className={cn("relative min-w-0", LABEL_STRIP_PAD[pad], className)}>
178
+ <div className="pointer-events-none absolute inset-x-3 top-1 flex items-center gap-1">
179
+ {/* `static`: FieldLabel positions itself absolutely; here the ROW is
180
+ positioned, so the label and its "?" are centred on one line by the flex
181
+ layout (dev#468). */}
182
+ <FieldLabel id={labelId} className={cn("static min-w-0 max-w-full", disabled && "opacity-50")}>
183
+ {label}
184
+ </FieldLabel>
185
+ {hasContent(hint) && <span className="pointer-events-auto flex shrink-0 items-center">{hint}</span>}
186
+ </div>
187
+ {children}
188
+ </div>
189
+ );
190
+ }
@@ -0,0 +1,184 @@
1
+ import { createContext, useId } from "react";
2
+ import type { ReactNode } from "react";
3
+ import { cn } from "../lib/cn";
4
+ import { Tooltip } from "./tooltip";
5
+ import { useCommitReason } from "./write-lock";
6
+ import { hasMessage, mergeDescribedBy } from "./choice-parts";
7
+
8
+ /**
9
+ * Small pieces the fields share for the 0.22.0 anatomy round (keksdose K3/K4, kastlan
10
+ * 8). Private to the package — not re-exported from the barrel — like `choice-parts`.
11
+ *
12
+ * Nothing here imports `ui.tsx`: `ui.tsx` imports this, and so do the combobox family
13
+ * and the native choice controls, which is the point of keeping the rules in one place.
14
+ */
15
+
16
+ /* ── hint ─────────────────────────────────────────────────────────────────── */
17
+
18
+ /**
19
+ * Whether a `hint` is a CAPTION (plain text: a non-empty string or a number) or
20
+ * something for the label line (a {@link FieldHint} "?", or any other node).
21
+ *
22
+ * The rule {@link Select} and `NumberInput` already keep: the label line is 11px of
23
+ * strip shared with the label, and a sentence placed there ran over the label and into
24
+ * the value; a "?" under the field would be a button floating free of the label it
25
+ * explains.
26
+ */
27
+ export function isTextHint(hint: ReactNode): hint is string | number {
28
+ return (typeof hint === "string" && hint !== "") || typeof hint === "number";
29
+ }
30
+
31
+ /** The caption under a field — Select's and NumberInput's type, so every field's
32
+ * standing advice reads alike. */
33
+ export const FIELD_CAPTION_CLASS = "mt-1 text-[11px] leading-tight text-[var(--text-muted)]";
34
+
35
+ export interface FieldHintParts {
36
+ /** The caller's `aria-describedby` with the caption's id appended — standing
37
+ * advice first; the error (the news) is appended after this by the field. */
38
+ describedBy: string | undefined;
39
+ /** The caption's id while there is one. */
40
+ captionId: string | undefined;
41
+ /** The caption itself, or `undefined` for a hint that is not text. */
42
+ captionText: string | number | undefined;
43
+ /** A non-text hint (a FieldHint) for the label line — or, on a field with no label
44
+ * line, the end edge outside the box. `undefined` when there is none. */
45
+ labelHint: ReactNode | undefined;
46
+ }
47
+
48
+ /**
49
+ * Split a field's `hint` into the caption under it (attached with `aria-describedby`)
50
+ * or the "?" on its label line. See {@link isTextHint}.
51
+ */
52
+ export function useFieldHint(hint: ReactNode, describedBy: string | undefined): FieldHintParts {
53
+ const captionId = useId();
54
+ const text = isTextHint(hint);
55
+ return {
56
+ describedBy: text ? mergeDescribedBy(describedBy, captionId) : describedBy,
57
+ captionId: text ? captionId : undefined,
58
+ captionText: text ? hint : undefined,
59
+ labelHint: !text && hasMessage(hint) ? hint : undefined,
60
+ };
61
+ }
62
+
63
+ /** The caption under a field, as {@link Select} draws its own. */
64
+ export function FieldCaption({ parts, className }: { parts: FieldHintParts; className?: string }) {
65
+ if (parts.captionText === undefined) return null;
66
+ return (
67
+ <p id={parts.captionId} className={cn(FIELD_CAPTION_CLASS, className)}>
68
+ {parts.captionText}
69
+ </p>
70
+ );
71
+ }
72
+
73
+ /**
74
+ * Classes that take a static floating label (`FLOATING_LABEL_STATIC`: `FieldLabel`,
75
+ * `ComboboxFieldLabel`) out of its own absolute slot and into a {@link StaticLabelRow}.
76
+ * `static` wins over `absolute` through `cn`'s merge; `min-w-0` lets it truncate in
77
+ * the row.
78
+ */
79
+ export const LABEL_IN_ROW = "static min-w-0";
80
+
81
+ /**
82
+ * A static label and its "?" on one line, in the top strip of a field that lays out its
83
+ * own label — the combobox family, which cannot use {@link FloatingField} because the
84
+ * trigger, the anchored panel and the label hang off a box of their own.
85
+ *
86
+ * FloatingField's static row, with one difference: it stops at `end-9`, the column the
87
+ * chevron (or the clear "×") takes, so a long label truncates before it rather than
88
+ * putting the "?" on top of the chevron. The hint keeps its width; the label gives way.
89
+ */
90
+ export function StaticLabelRow({ hint, children }: { hint: ReactNode; children: ReactNode }) {
91
+ return (
92
+ <div className="pointer-events-none absolute start-3 end-9 top-1 z-10 flex items-center gap-1">
93
+ {children}
94
+ <span className="pointer-events-auto flex shrink-0 items-center">{hint}</span>
95
+ </div>
96
+ );
97
+ }
98
+
99
+ /**
100
+ * A field with no label line and a FieldHint "?": the hint at the end edge, OUTSIDE the
101
+ * box, as an unlabelled {@link Select} has placed it since 0.15.5. Without a hint the
102
+ * field is returned exactly as it was, so the wrapper costs nothing where it does not
103
+ * apply.
104
+ */
105
+ export function EndHintRow({
106
+ hint,
107
+ className,
108
+ children,
109
+ }: {
110
+ hint: ReactNode | undefined;
111
+ className?: string;
112
+ children: ReactNode;
113
+ }) {
114
+ if (hint === undefined) return <>{children}</>;
115
+ return (
116
+ <div className={cn("flex items-center gap-1.5", className)}>
117
+ <div className="min-w-0 flex-1">{children}</div>
118
+ <span className="flex shrink-0 items-center">{hint}</span>
119
+ </div>
120
+ );
121
+ }
122
+
123
+ /* ── the write lock on a control that saves itself (keksdose K3) ───────────── */
124
+
125
+ export interface LockReason {
126
+ /** The reason in force — the lock's (with `commit` under a locked
127
+ * `WriteLockProvider`), else the control's own `disabledReason`. */
128
+ reason: ReactNode;
129
+ /** There is a reason: the control is locked the focusable way. */
130
+ locked: boolean;
131
+ /** The id of the reason's hidden copy, for the control's `aria-describedby`. */
132
+ reasonId: string;
133
+ }
134
+
135
+ /**
136
+ * {@link Button}'s `commit` + `disabledReason`, for a control that IS its own commit — a
137
+ * switch, a checkbox or a select that saves on change, a list row whose click writes.
138
+ * The lock's reason wins over the control's own (`useCommitReason`).
139
+ */
140
+ export function useLockReason(commit: boolean | undefined, disabledReason: ReactNode): LockReason {
141
+ const reason = useCommitReason(commit, disabledReason);
142
+ const reasonId = useId();
143
+ return { reason, locked: hasMessage(reason), reasonId };
144
+ }
145
+
146
+ /**
147
+ * The kit {@link Tooltip} around a locked control, with a `hidden` copy of the reason for
148
+ * the control's `aria-describedby` — Button's anatomy. The control goes in a FRAGMENT so
149
+ * the Tooltip does not also clone its bubble's id into the description: the hidden copy
150
+ * already describes it, and the same sentence twice would be read on every focus.
151
+ *
152
+ * `className` is the wrapper's, which becomes the outermost element while locked — a
153
+ * control moves its own layout classes here so a lock does not reflow the form.
154
+ */
155
+ export function LockedReason({
156
+ lock,
157
+ className,
158
+ children,
159
+ }: {
160
+ lock: LockReason;
161
+ className?: string;
162
+ children: ReactNode;
163
+ }) {
164
+ return (
165
+ <Tooltip label={lock.reason} className={className}>
166
+ <>
167
+ {children}
168
+ <span id={lock.reasonId} hidden>
169
+ {lock.reason}
170
+ </span>
171
+ </>
172
+ </Tooltip>
173
+ );
174
+ }
175
+
176
+ /* ── groups ───────────────────────────────────────────────────────────────── */
177
+
178
+ /**
179
+ * Set by {@link CheckboxGroup}: its legend carries the one required star, so a box
180
+ * inside it keeps the native `required` (a form still refuses an empty set) but draws no
181
+ * star of its own — four stars on four languages would read as four separate demands.
182
+ * ChoiceCardGroup's `InGroupContext`, for the plain checkbox.
183
+ */
184
+ export const RequiredStarOnLegend = createContext(false);