@eifi1/ui-kit 0.21.0 → 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 (278) 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/iban-input.d.ts +66 -0
  72. package/dist/components/iban-input.js +125 -0
  73. package/dist/components/iban-input.js.map +1 -0
  74. package/dist/components/icon-picker.d.ts +12 -2
  75. package/dist/components/icon-picker.js +9 -2
  76. package/dist/components/icon-picker.js.map +1 -1
  77. package/dist/components/inline-edit-field.d.ts +167 -0
  78. package/dist/components/inline-edit-field.js +293 -0
  79. package/dist/components/inline-edit-field.js.map +1 -0
  80. package/dist/components/language-select.d.ts +127 -0
  81. package/dist/components/language-select.js +18 -0
  82. package/dist/components/language-select.js.map +1 -0
  83. package/dist/components/line-items.d.ts +25 -3
  84. package/dist/components/line-items.js +55 -46
  85. package/dist/components/line-items.js.map +1 -1
  86. package/dist/components/list.d.ts +25 -0
  87. package/dist/components/list.js +16 -5
  88. package/dist/components/list.js.map +1 -1
  89. package/dist/components/money-field.d.ts +20 -8
  90. package/dist/components/money-field.js.map +1 -1
  91. package/dist/components/month-picker.d.ts +52 -4
  92. package/dist/components/month-picker.js +290 -86
  93. package/dist/components/month-picker.js.map +1 -1
  94. package/dist/components/multi-select.d.ts +8 -1
  95. package/dist/components/multi-select.js +9 -4
  96. package/dist/components/multi-select.js.map +1 -1
  97. package/dist/components/number-field.d.ts +25 -13
  98. package/dist/components/number-field.js +6 -20
  99. package/dist/components/number-field.js.map +1 -1
  100. package/dist/components/number-input.d.ts +70 -13
  101. package/dist/components/number-input.js +90 -38
  102. package/dist/components/number-input.js.map +1 -1
  103. package/dist/components/numpad-sheet.d.ts +10 -8
  104. package/dist/components/one-time-code-input.d.ts +100 -0
  105. package/dist/components/one-time-code-input.js +137 -0
  106. package/dist/components/one-time-code-input.js.map +1 -0
  107. package/dist/components/phone-input.d.ts +66 -0
  108. package/dist/components/phone-input.js +171 -0
  109. package/dist/components/phone-input.js.map +1 -0
  110. package/dist/components/series-chart.d.ts +10 -8
  111. package/dist/components/settings-fields.d.ts +10 -8
  112. package/dist/components/share-card.d.ts +8 -6
  113. package/dist/components/sign-chip.d.ts +66 -0
  114. package/dist/components/sign-chip.js +39 -0
  115. package/dist/components/sign-chip.js.map +1 -0
  116. package/dist/components/swatch-picker.d.ts +27 -2
  117. package/dist/components/swatch-picker.js +73 -31
  118. package/dist/components/swatch-picker.js.map +1 -1
  119. package/dist/components/switch.d.ts +19 -0
  120. package/dist/components/switch.js +24 -6
  121. package/dist/components/switch.js.map +1 -1
  122. package/dist/components/text-link.d.ts +10 -8
  123. package/dist/components/tile-radio.d.ts +68 -13
  124. package/dist/components/tile-radio.js +3 -3
  125. package/dist/components/tile-radio.js.map +1 -1
  126. package/dist/components/time-input.d.ts +15 -9
  127. package/dist/components/time-input.js.map +1 -1
  128. package/dist/components/toggle-group.d.ts +47 -1
  129. package/dist/components/toggle-group.js +110 -69
  130. package/dist/components/toggle-group.js.map +1 -1
  131. package/dist/components/ui.d.ts +10 -8
  132. package/dist/components/ui.js +280 -78
  133. package/dist/components/ui.js.map +1 -1
  134. package/dist/components/use-table-state.d.ts +1 -2
  135. package/dist/{data-table-3mi2R3so.d.ts → data-table-labels-B7OdnM0S.d.ts} +140 -3
  136. package/dist/data-table.d.ts +1 -2
  137. package/dist/data-table.js +2 -1
  138. package/dist/data-table.js.map +1 -1
  139. package/dist/feedback/feedback-attachment.d.ts +1 -1
  140. package/dist/feedback/feedback-attachment.js +210 -3
  141. package/dist/feedback/feedback-attachment.js.map +1 -1
  142. package/dist/feedback/feedback-dialog.d.ts +1 -1
  143. package/dist/feedback/feedback-dialog.js.map +1 -1
  144. package/dist/feedback/feedback-inbox.d.ts +1 -1
  145. package/dist/feedback/feedback-thread.d.ts +37 -3
  146. package/dist/feedback/feedback-thread.js +38 -9
  147. package/dist/feedback/feedback-thread.js.map +1 -1
  148. package/dist/{feedback-attachment-WFiXJ8We.d.ts → feedback-attachment-NhUm7Zga.d.ts} +70 -5
  149. package/dist/feedback.d.ts +2 -2
  150. package/dist/hooks/use-file-drop.d.ts +10 -8
  151. package/dist/i18n/defaults.d.ts +10 -8
  152. package/dist/i18n/defaults.js +17 -2
  153. package/dist/i18n/defaults.js.map +1 -1
  154. package/dist/i18n/german.d.ts +10 -8
  155. package/dist/i18n/german.js +50 -5
  156. package/dist/i18n/german.js.map +1 -1
  157. package/dist/i18n/kit-labels.d.ts +10 -8
  158. package/dist/i18n/kit-labels.js +8 -2
  159. package/dist/i18n/kit-labels.js.map +1 -1
  160. package/dist/i18n/languages.d.ts +10 -8
  161. package/dist/i18n/locales/de-CH.d.ts +10 -8
  162. package/dist/i18n/locales/en.d.ts +10 -8
  163. package/dist/i18n/locales/en.js +9 -0
  164. package/dist/i18n/locales/en.js.map +1 -1
  165. package/dist/i18n/locales/es.d.ts +10 -8
  166. package/dist/i18n/locales/es.js +48 -5
  167. package/dist/i18n/locales/es.js.map +1 -1
  168. package/dist/i18n/locales/fr.d.ts +10 -8
  169. package/dist/i18n/locales/fr.js +48 -5
  170. package/dist/i18n/locales/fr.js.map +1 -1
  171. package/dist/i18n/locales/hu.d.ts +10 -8
  172. package/dist/i18n/locales/hu.js +49 -5
  173. package/dist/i18n/locales/hu.js.map +1 -1
  174. package/dist/i18n/locales/it.d.ts +10 -8
  175. package/dist/i18n/locales/it.js +49 -5
  176. package/dist/i18n/locales/it.js.map +1 -1
  177. package/dist/i18n/locales/zh.d.ts +10 -8
  178. package/dist/i18n/locales/zh.js +48 -5
  179. package/dist/i18n/locales/zh.js.map +1 -1
  180. package/dist/i18n/review.d.ts +10 -8
  181. package/dist/i18n/review.js +8 -1
  182. package/dist/i18n/review.js.map +1 -1
  183. package/dist/index.d.ts +17 -10
  184. package/dist/index.js +57 -2
  185. package/dist/index.js.map +1 -1
  186. package/dist/{kit-labels-D1J2_jyD.d.ts → kit-labels-R8mIAc9N.d.ts} +811 -8
  187. package/dist/lib/countries.d.ts +43 -0
  188. package/dist/lib/countries.js +39 -0
  189. package/dist/lib/countries.js.map +1 -0
  190. package/dist/lib/decimal-marks.d.ts +46 -0
  191. package/dist/lib/decimal-marks.js +40 -0
  192. package/dist/lib/decimal-marks.js.map +1 -0
  193. package/dist/lib/iban.d.ts +98 -0
  194. package/dist/lib/iban.js +154 -0
  195. package/dist/lib/iban.js.map +1 -0
  196. package/dist/lib/isin.d.ts +30 -0
  197. package/dist/lib/isin.js +36 -0
  198. package/dist/lib/isin.js.map +1 -0
  199. package/dist/lib/phone.d.ts +69 -0
  200. package/dist/lib/phone.js +121 -0
  201. package/dist/lib/phone.js.map +1 -0
  202. package/dist/rhf/fields.d.ts +148 -11
  203. package/dist/rhf/fields.js +332 -3
  204. package/dist/rhf/fields.js.map +1 -1
  205. package/dist/rhf/form.d.ts +10 -8
  206. package/dist/rhf.d.ts +13 -9
  207. package/dist/rhf.js.map +1 -1
  208. package/dist/shell/app-shell.d.ts +10 -8
  209. package/dist/shell/top-bar-brand.d.ts +10 -8
  210. package/dist/shell.d.ts +10 -8
  211. package/dist/tour/tour.js +20 -24
  212. package/dist/tour/tour.js.map +1 -1
  213. package/dist/wizard/stepper-nav.d.ts +10 -8
  214. package/dist/wizard.d.ts +10 -8
  215. package/package.json +1 -1
  216. package/src/components/account-chips.tsx +17 -3
  217. package/src/components/account-settings.tsx +92 -26
  218. package/src/components/amount-input.tsx +49 -56
  219. package/src/components/autocomplete.tsx +133 -110
  220. package/src/components/checkbox-group.tsx +229 -0
  221. package/src/components/checkbox.tsx +72 -8
  222. package/src/components/chip.tsx +80 -10
  223. package/src/components/combobox.tsx +292 -253
  224. package/src/components/country-select.tsx +443 -0
  225. package/src/components/currency-select.tsx +109 -33
  226. package/src/components/danger-confirm.tsx +345 -77
  227. package/src/components/data-table-labels.ts +16 -1
  228. package/src/components/data-table.tsx +175 -6
  229. package/src/components/date-picker.tsx +173 -62
  230. package/src/components/entity-combobox.tsx +107 -87
  231. package/src/components/field-anatomy.tsx +190 -0
  232. package/src/components/field-parts.tsx +184 -0
  233. package/src/components/file-button.tsx +36 -3
  234. package/src/components/file-dropzone.tsx +106 -38
  235. package/src/components/form-actions.tsx +115 -2
  236. package/src/components/iban-input.tsx +231 -0
  237. package/src/components/icon-picker.tsx +26 -4
  238. package/src/components/inline-edit-field.tsx +533 -0
  239. package/src/components/language-select.tsx +78 -0
  240. package/src/components/line-items.tsx +35 -2
  241. package/src/components/list.tsx +55 -6
  242. package/src/components/money-field.tsx +10 -0
  243. package/src/components/month-picker.tsx +399 -63
  244. package/src/components/multi-select.tsx +80 -58
  245. package/src/components/number-field.tsx +28 -46
  246. package/src/components/number-input.tsx +210 -43
  247. package/src/components/one-time-code-input.tsx +288 -0
  248. package/src/components/phone-input.tsx +299 -0
  249. package/src/components/sign-chip.tsx +103 -0
  250. package/src/components/swatch-picker.tsx +118 -30
  251. package/src/components/switch.tsx +58 -7
  252. package/src/components/tile-radio.tsx +71 -16
  253. package/src/components/time-input.tsx +8 -1
  254. package/src/components/toggle-group.tsx +206 -73
  255. package/src/components/ui.tsx +472 -105
  256. package/src/data-table.ts +2 -1
  257. package/src/feedback/feedback-attachment.tsx +313 -9
  258. package/src/feedback/feedback-dialog.tsx +2 -0
  259. package/src/feedback/feedback-thread.tsx +66 -5
  260. package/src/i18n/defaults.ts +16 -1
  261. package/src/i18n/german.ts +47 -0
  262. package/src/i18n/kit-labels.tsx +78 -2
  263. package/src/i18n/locales/en.ts +10 -0
  264. package/src/i18n/locales/es.ts +46 -0
  265. package/src/i18n/locales/fr.ts +45 -0
  266. package/src/i18n/locales/hu.ts +46 -0
  267. package/src/i18n/locales/it.ts +47 -0
  268. package/src/i18n/locales/zh.ts +44 -0
  269. package/src/i18n/review.ts +7 -0
  270. package/src/index.ts +53 -3
  271. package/src/lib/countries.ts +91 -0
  272. package/src/lib/decimal-marks.ts +99 -0
  273. package/src/lib/iban.ts +171 -0
  274. package/src/lib/isin.ts +62 -0
  275. package/src/lib/phone.ts +211 -0
  276. package/src/rhf/fields.tsx +606 -9
  277. package/src/rhf.ts +2 -1
  278. 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
  // One look for both combobox flavors below — the suggestion list and its rows
27
28
  // must stay pixel-identical between the free-text and the id-keyed variant (and
@@ -149,6 +150,15 @@ export interface ComboboxProps
149
150
  * field, on the `<input>`'s `aria-describedby`, and implies `invalid` (lenkbank's
150
151
  * scope fields, which moved here off `<Input list>` + `<datalist>`). */
151
152
  error?: ReactNode;
153
+ /**
154
+ * Standing advice for the field — {@link Input}'s `hint`, so the combobox family
155
+ * reads like every other field (keksdose K4: the family had `error` and no `hint`, so
156
+ * the app's pickers carried their captions outside the component, attached to
157
+ * nothing). Plain TEXT is a caption under the field on the input's
158
+ * `aria-describedby`, before any error; a {@link FieldHint} "?" rides the label line,
159
+ * or with no label sits at the field's end edge, outside the box.
160
+ */
161
+ hint?: ReactNode;
152
162
  /** As `<input disabled>`: no focus, no list, the field's settled look. The chevron
153
163
  * stops toggling with it — it is a mouse target the input's own `disabled` does
154
164
  * not reach. */
@@ -232,6 +242,7 @@ export function Combobox({
232
242
  createLabel,
233
243
  invalid,
234
244
  error,
245
+ hint,
235
246
  disabled,
236
247
  optionAdornment,
237
248
  autoFocus,
@@ -246,10 +257,11 @@ export function Combobox({
246
257
  "aria-required": ariaRequired,
247
258
  ...rest
248
259
  }: ComboboxProps) {
260
+ const hintParts = useFieldHint(hint, ariaDescribedBy);
249
261
  const field = useComboboxFieldError(
250
262
  error,
251
263
  invalid || ariaInvalid === true || ariaInvalid === "true",
252
- ariaDescribedBy,
264
+ hintParts.describedBy,
253
265
  );
254
266
  const generated = useId();
255
267
  const fieldId = id ?? generated;
@@ -383,134 +395,147 @@ export function Combobox({
383
395
  <div {...rest} ref={wrapperRef} className={cn("relative", className)}>
384
396
  {/* A real <label for> since lenkbank's tests met a field `getByLabelText` could
385
397
  not find: it used to be a <span>, with the text copied onto `aria-label`. The
386
- name a reader hears is the same words either way. */}
387
- {label !== undefined && (
398
+ name a reader hears is the same words either way. With a "?" it moves into a
399
+ row after the field, below. */}
400
+ {label !== undefined && hintParts.labelHint === undefined && (
388
401
  <ComboboxFieldLabel htmlFor={fieldId} className={disabled ? "opacity-50" : undefined}>
389
402
  {label}
390
403
  </ComboboxFieldLabel>
391
404
  )}
392
- {/* The chevron centers against this inner wrapper, which hugs the input.
393
- The outer div can be taller than the input (as a grid item it
394
- stretches to the row height, e.g. next to the editor's category cell
395
- with its split button), which used to drag a top-1/2 chevron down to
396
- the input's bottom edge (feedback #248). */}
397
- {/* Dimmed as a whole when disabled, the way EntityCombobox dims its trigger.
398
- FIELD_BASE's grey alone left a disabled picker looking like a filled-in
399
- one beside the pickers that do dim (the label is not the input's `peer`,
400
- so it is dimmed by hand above). */}
401
- <div ref={fieldRef} className={cn("relative", disabled && "cursor-not-allowed opacity-50")}>
402
- <input
403
- id={fieldId}
404
- value={value}
405
- placeholder={placeholder}
406
- // The caller's name, over the <label for> above — which is what names the
407
- // field otherwise (dev#477: a field with neither announces only its text).
408
- aria-label={ariaLabel}
409
- role="combobox"
410
- aria-expanded={open}
411
- // The list this field is the mouth of. Required by the role, and the half
412
- // that was missing: the field said it was expanded and never said what it
413
- // had expanded, so a reader had no way from the box to the options
414
- // (ESLint's `role-has-required-aria-props`, the audit's §a11y).
415
- aria-controls={listboxId}
416
- aria-activedescendant={activeId}
417
- aria-autocomplete="list"
418
- aria-invalid={field.isInvalid || undefined}
419
- aria-describedby={field.describedBy}
420
- aria-required={ariaRequired}
421
- autoComplete="off"
422
- disabled={disabled}
423
- // eslint-disable-next-line jsx-a11y/no-autofocus -- a documented prop the caller opts into (off by default); the field never takes focus on its own.
424
- autoFocus={autoFocus}
425
- onBlur={onBlur}
426
- onMouseDown={primaryOnly.onMouseDown}
427
- onFocus={() => {
428
- // A back/forward mouse button focuses this field on its way to
429
- // navigating; it is not a request to open anything (live #309 rework).
430
- if (primaryOnly.fromAuxButton()) return;
431
- setOpen(true);
432
- setTyping(false);
433
- // The sheet carries its own input, so the field behind it must not also
434
- // pull up the keyboard and scroll the page under the dialog.
435
- if (isPhone) sheetInputRef.current?.focus();
436
- }}
437
- // A CLICK as well as focus — the other half of dev#549. Picking a suggestion
438
- // closes the list without moving focus (the rows suppress `mousedown` on
439
- // purpose, so the input never blurred), which means clicking the field again
440
- // fires no `focus` event at all and the list stayed shut. "Does not open
441
- // anything" was literally true.
442
- onClick={() => {
443
- setOpen(true);
444
- setTyping(false);
445
- }}
446
- // `inputMode="none"` rather than readOnly: the field must not look
447
- // uneditable (FIELD_BASE greys a read-only field since dev#468) and must
448
- // still take focus — it just has no keyboard of its own, the same trick
449
- // the amount field uses for the numpad.
450
- inputMode={isPhone ? "none" : undefined}
451
- onChange={(e) => {
452
- onChange(e.target.value);
453
- setOpen(true);
454
- setActive(-1);
455
- setTyping(true);
456
- }}
457
- onKeyDown={(e) => {
458
- if (e.key === "ArrowDown") {
459
- e.preventDefault();
405
+ <EndHintRow hint={label === undefined ? hintParts.labelHint : undefined}>
406
+ {/* The chevron centers against this inner wrapper, which hugs the input.
407
+ The outer div can be taller than the input (as a grid item it
408
+ stretches to the row height, e.g. next to the editor's category cell
409
+ with its split button), which used to drag a top-1/2 chevron down to
410
+ the input's bottom edge (feedback #248). */}
411
+ {/* Dimmed as a whole when disabled, the way EntityCombobox dims its trigger.
412
+ FIELD_BASE's grey alone left a disabled picker looking like a filled-in
413
+ one beside the pickers that do dim (the label is not the input's `peer`,
414
+ so it is dimmed by hand above). */}
415
+ <div ref={fieldRef} className={cn("relative", disabled && "cursor-not-allowed opacity-50")}>
416
+ <input
417
+ id={fieldId}
418
+ value={value}
419
+ placeholder={placeholder}
420
+ // The caller's name, over the <label for> above — which is what names the
421
+ // field otherwise (dev#477: a field with neither announces only its text).
422
+ aria-label={ariaLabel}
423
+ role="combobox"
424
+ aria-expanded={open}
425
+ // The list this field is the mouth of. Required by the role, and the half
426
+ // that was missing: the field said it was expanded and never said what it
427
+ // had expanded, so a reader had no way from the box to the options
428
+ // (ESLint's `role-has-required-aria-props`, the audit's §a11y).
429
+ aria-controls={listboxId}
430
+ aria-activedescendant={activeId}
431
+ aria-autocomplete="list"
432
+ aria-invalid={field.isInvalid || undefined}
433
+ aria-describedby={field.describedBy}
434
+ aria-required={ariaRequired}
435
+ autoComplete="off"
436
+ disabled={disabled}
437
+ // eslint-disable-next-line jsx-a11y/no-autofocus -- a documented prop the caller opts into (off by default); the field never takes focus on its own.
438
+ autoFocus={autoFocus}
439
+ onBlur={onBlur}
440
+ onMouseDown={primaryOnly.onMouseDown}
441
+ onFocus={() => {
442
+ // A back/forward mouse button focuses this field on its way to
443
+ // navigating; it is not a request to open anything (live #309 rework).
444
+ if (primaryOnly.fromAuxButton()) return;
460
445
  setOpen(true);
461
- setActive((i) => Math.min(i + 1, matches.length - 1));
462
- } else if (e.key === "ArrowUp") {
463
- e.preventDefault();
464
- setActive((i) => Math.max(i - 1, 0));
465
- } else if (e.key === "Enter") {
466
- if (open && active >= 0 && active < matches.length) {
446
+ setTyping(false);
447
+ // The sheet carries its own input, so the field behind it must not also
448
+ // pull up the keyboard and scroll the page under the dialog.
449
+ if (isPhone) sheetInputRef.current?.focus();
450
+ }}
451
+ // A CLICK as well as focus — the other half of dev#549. Picking a suggestion
452
+ // closes the list without moving focus (the rows suppress `mousedown` on
453
+ // purpose, so the input never blurred), which means clicking the field again
454
+ // fires no `focus` event at all and the list stayed shut. "Does not open
455
+ // anything" was literally true.
456
+ onClick={() => {
457
+ setOpen(true);
458
+ setTyping(false);
459
+ }}
460
+ // `inputMode="none"` rather than readOnly: the field must not look
461
+ // uneditable (FIELD_BASE greys a read-only field since dev#468) and must
462
+ // still take focus — it just has no keyboard of its own, the same trick
463
+ // the amount field uses for the numpad.
464
+ inputMode={isPhone ? "none" : undefined}
465
+ onChange={(e) => {
466
+ onChange(e.target.value);
467
+ setOpen(true);
468
+ setActive(-1);
469
+ setTyping(true);
470
+ }}
471
+ onKeyDown={(e) => {
472
+ if (e.key === "ArrowDown") {
473
+ e.preventDefault();
474
+ setOpen(true);
475
+ setActive((i) => Math.min(i + 1, matches.length - 1));
476
+ } else if (e.key === "ArrowUp") {
467
477
  e.preventDefault();
468
- commit(matches[active]);
469
- } else {
470
- // No row highlighted: the typed text is the answer. `preventDefault`
471
- // only when a caller is taking Enter, so a plain form submit is
472
- // otherwise left alone.
473
- if (onSubmit) e.preventDefault();
478
+ setActive((i) => Math.max(i - 1, 0));
479
+ } else if (e.key === "Enter") {
480
+ if (open && active >= 0 && active < matches.length) {
481
+ e.preventDefault();
482
+ commit(matches[active]);
483
+ } else {
484
+ // No row highlighted: the typed text is the answer. `preventDefault`
485
+ // only when a caller is taking Enter, so a plain form submit is
486
+ // otherwise left alone.
487
+ if (onSubmit) e.preventDefault();
488
+ setOpen(false);
489
+ onSubmit?.();
490
+ }
491
+ } else if (e.key === "Escape") {
492
+ setOpen(false);
493
+ setActive(-1);
494
+ } else if (e.key === "Tab") {
495
+ // Closes, and lets the browser take the Tab: focus is in THIS input,
496
+ // which is staying, so there is nothing to catch — unlike the pickers
497
+ // whose focus sits inside a portalled panel.
474
498
  setOpen(false);
475
- onSubmit?.();
499
+ setActive(-1);
476
500
  }
477
- } else if (e.key === "Escape") {
478
- setOpen(false);
479
- setActive(-1);
480
- } else if (e.key === "Tab") {
481
- // Closes, and lets the browser take the Tab: focus is in THIS input,
482
- // which is staying, so there is nothing to catch — unlike the pickers
483
- // whose focus sits inside a portalled panel.
484
- setOpen(false);
485
- setActive(-1);
486
- }
487
- // Home/End are deliberately absent. The APG gives them to the list only
488
- // where the combobox is not editable; here the text IS the value, a
489
- // payee name is long enough to want the caret moved to its start, and
490
- // the desktop list is capped at 8 rows — so jumping it would be worth
491
- // almost nothing and would cost the one gesture that field is used with.
492
- }}
493
- className={cn(
494
- FIELD_BASE,
495
- label !== undefined && FIELD_FLOATING_PAD,
496
- "pe-9",
497
- field.isInvalid && FIELD_INVALID,
498
- )}
499
- />
500
- <ChevronDown
501
- aria-hidden
502
- onMouseDown={(e) => {
503
- // Toggle on the chevron without stealing focus from the input.
504
- e.preventDefault();
505
- if (!disabled) setOpen((o) => !o);
506
- }}
507
- className={cn(
508
- "absolute end-2.5 top-1/2 size-4 -translate-y-1/2 text-[var(--text-placeholder)]",
509
- // The box above does the dimming; dimming here too would halve it again.
510
- !disabled && "cursor-pointer",
511
- )}
512
- />
513
- </div>
501
+ // Home/End are deliberately absent. The APG gives them to the list only
502
+ // where the combobox is not editable; here the text IS the value, a
503
+ // payee name is long enough to want the caret moved to its start, and
504
+ // the desktop list is capped at 8 rows — so jumping it would be worth
505
+ // almost nothing and would cost the one gesture that field is used with.
506
+ }}
507
+ className={cn(
508
+ FIELD_BASE,
509
+ label !== undefined && FIELD_FLOATING_PAD,
510
+ "pe-9",
511
+ field.isInvalid && FIELD_INVALID,
512
+ )}
513
+ />
514
+ <ChevronDown
515
+ aria-hidden
516
+ onMouseDown={(e) => {
517
+ // Toggle on the chevron without stealing focus from the input.
518
+ e.preventDefault();
519
+ if (!disabled) setOpen((o) => !o);
520
+ }}
521
+ className={cn(
522
+ "absolute end-2.5 top-1/2 size-4 -translate-y-1/2 text-[var(--text-placeholder)]",
523
+ // The box above does the dimming; dimming here too would halve it again.
524
+ !disabled && "cursor-pointer",
525
+ )}
526
+ />
527
+ </div>
528
+ </EndHintRow>
529
+ {/* The label and its "?" share the top strip — after the field in the DOM, so the
530
+ "?" follows the control in the tab order, as it does on Input and Select. */}
531
+ {label !== undefined && hintParts.labelHint !== undefined && (
532
+ <StaticLabelRow hint={hintParts.labelHint}>
533
+ <ComboboxFieldLabel htmlFor={fieldId} className={cn(LABEL_IN_ROW, disabled && "opacity-50")}>
534
+ {label}
535
+ </ComboboxFieldLabel>
536
+ </StaticLabelRow>
537
+ )}
538
+ <FieldCaption parts={hintParts} />
514
539
  {field.errorEl}
515
540
  {isPhone && (
516
541
  <PickerSheet
@@ -667,6 +692,8 @@ export interface InlineEntityComboboxProps<V extends string | number, C extends
667
692
  invalid?: boolean;
668
693
  /** What is wrong with the value — see {@link Combobox}'s `error`. */
669
694
  error?: ReactNode;
695
+ /** Standing advice — see {@link Combobox}'s `hint` (keksdose K4). */
696
+ hint?: ReactNode;
670
697
  /** A picked/typed option emits its id; emptying the field, or the "×", emits
671
698
  * `clearValue`. */
672
699
  onChange: (v: V | C) => void;
@@ -740,6 +767,7 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
740
767
  clearLabel,
741
768
  invalid,
742
769
  error,
770
+ hint,
743
771
  "aria-label": ariaLabel,
744
772
  // Off `rest` and onto the <input>, which is the combobox a reader meets: `Field`'s
745
773
  // render-prop spreads `{ id, aria-describedby, aria-invalid, aria-required }`, and
@@ -752,10 +780,11 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
752
780
  // The clear value reads as "nothing selected", whichever one the caller picked —
753
781
  // so from here down `value` is the id or `null`, as it always was.
754
782
  const value: V | null = rawValue == null || rawValue === clearValue ? null : (rawValue as V);
783
+ const hintParts = useFieldHint(hint, ariaDescribedBy);
755
784
  const field = useComboboxFieldError(
756
785
  error,
757
786
  invalid || ariaInvalid === true || ariaInvalid === "true",
758
- ariaDescribedBy,
787
+ hintParts.describedBy,
759
788
  );
760
789
  const generated = useId();
761
790
  const fieldId = id ?? generated;
@@ -880,149 +909,159 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
880
909
  // which is the combobox a reader meets. Spread FIRST, so the field's ARIA and the
881
910
  // handlers carrying live #309 and dev#549 cannot be replaced from outside.
882
911
  <div {...rest} ref={wrapperRef} className={cn("relative", className)}>
883
- {/* A real <label for> — see {@link Combobox}. */}
884
- {label !== undefined && (
912
+ {/* A real <label for> — see {@link Combobox}; with a "?", in a row after the field. */}
913
+ {label !== undefined && hintParts.labelHint === undefined && (
885
914
  <ComboboxFieldLabel htmlFor={fieldId} className={disabled ? "opacity-50" : undefined}>
886
915
  {label}
887
916
  </ComboboxFieldLabel>
888
917
  )}
889
- {/* Inner wrapper for chevron centering — same reasoning as Combobox above.
890
- It is also what the portalled list anchors to. */}
891
- {/* Dimmed as a whole when disabled, the way EntityCombobox dims its trigger.
892
- FIELD_BASE's grey alone left a disabled picker looking like a filled-in
893
- one beside the pickers that do dim (the label is not the input's `peer`,
894
- so it is dimmed by hand above). */}
895
- <div ref={fieldRef} className={cn("relative", disabled && "cursor-not-allowed opacity-50")}>
896
- <input
897
- id={fieldId}
898
- value={shown}
899
- placeholder={placeholder}
900
- // The caller's name, over the <label for> above. Just the label, never
901
- // "label: value" the way a trigger button has to compose it: an input
902
- // already exposes its value separately.
903
- aria-label={ariaLabel}
904
- role="combobox"
905
- aria-expanded={open}
906
- // Required by the role, and the half that was missing: the field said it
907
- // was expanded and never said what it had expanded (ESLint's
908
- // `role-has-required-aria-props`, the audit's §a11y).
909
- aria-controls={listboxId}
910
- aria-activedescendant={activeId}
911
- aria-autocomplete="list"
912
- aria-invalid={field.isInvalid || undefined}
913
- aria-describedby={field.describedBy}
914
- aria-required={ariaRequired}
915
- autoComplete="off"
916
- disabled={disabled}
917
- // eslint-disable-next-line jsx-a11y/no-autofocus -- a documented prop the caller opts into (off by default); the field never takes focus on its own.
918
- autoFocus={autoFocus}
919
- // `inputMode="none"` rather than readOnly, for the same reason Combobox
920
- // above gives: the sheet carries the keyboard, and a readOnly field would
921
- // take FIELD_BASE's settled look on a field that is perfectly editable.
922
- inputMode={isPhone ? "none" : undefined}
923
- onMouseDown={primaryOnly.onMouseDown}
924
- onFocus={(e) => {
925
- // See {@link usePrimaryPressOnly}: a back/forward button lands here on
926
- // its way to navigating, and neither the list nor the select-all is
927
- // anything it asked for (live #309 rework).
928
- if (primaryOnly.fromAuxButton()) return;
929
- setText(shown);
930
- e.currentTarget.select();
931
- setOpen(true);
932
- // The sheet has its own input; the field behind it must not also pull up
933
- // the keyboard and scroll the page under the dialog.
934
- if (isPhone) sheetInputRef.current?.focus();
935
- }}
936
- onBlur={() => {
937
- // On a phone the blur is the SHEET taking focus, not the user leaving the
938
- // field — reconciling there would close the sheet the instant it opened.
939
- if (!isPhone) reconcile();
940
- }}
941
- onChange={(e) => {
942
- setText(e.target.value);
943
- setOpen(true);
944
- setActive(-1);
945
- }}
946
- onKeyDown={(e) => {
947
- // Disabled rows are passed over; Up from "nothing highlighted" lands on
948
- // the first takeable row, as it always landed on row 0.
949
- if (e.key === "ArrowDown") {
950
- e.preventDefault();
918
+ <EndHintRow hint={label === undefined ? hintParts.labelHint : undefined}>
919
+ {/* Inner wrapper for chevron centering — same reasoning as Combobox above.
920
+ It is also what the portalled list anchors to. */}
921
+ {/* Dimmed as a whole when disabled, the way EntityCombobox dims its trigger.
922
+ FIELD_BASE's grey alone left a disabled picker looking like a filled-in
923
+ one beside the pickers that do dim (the label is not the input's `peer`,
924
+ so it is dimmed by hand above). */}
925
+ <div ref={fieldRef} className={cn("relative", disabled && "cursor-not-allowed opacity-50")}>
926
+ <input
927
+ id={fieldId}
928
+ value={shown}
929
+ placeholder={placeholder}
930
+ // The caller's name, over the <label for> above. Just the label, never
931
+ // "label: value" the way a trigger button has to compose it: an input
932
+ // already exposes its value separately.
933
+ aria-label={ariaLabel}
934
+ role="combobox"
935
+ aria-expanded={open}
936
+ // Required by the role, and the half that was missing: the field said it
937
+ // was expanded and never said what it had expanded (ESLint's
938
+ // `role-has-required-aria-props`, the audit's §a11y).
939
+ aria-controls={listboxId}
940
+ aria-activedescendant={activeId}
941
+ aria-autocomplete="list"
942
+ aria-invalid={field.isInvalid || undefined}
943
+ aria-describedby={field.describedBy}
944
+ aria-required={ariaRequired}
945
+ autoComplete="off"
946
+ disabled={disabled}
947
+ // eslint-disable-next-line jsx-a11y/no-autofocus -- a documented prop the caller opts into (off by default); the field never takes focus on its own.
948
+ autoFocus={autoFocus}
949
+ // `inputMode="none"` rather than readOnly, for the same reason Combobox
950
+ // above gives: the sheet carries the keyboard, and a readOnly field would
951
+ // take FIELD_BASE's settled look on a field that is perfectly editable.
952
+ inputMode={isPhone ? "none" : undefined}
953
+ onMouseDown={primaryOnly.onMouseDown}
954
+ onFocus={(e) => {
955
+ // See {@link usePrimaryPressOnly}: a back/forward button lands here on
956
+ // its way to navigating, and neither the list nor the select-all is
957
+ // anything it asked for (live #309 rework).
958
+ if (primaryOnly.fromAuxButton()) return;
959
+ setText(shown);
960
+ e.currentTarget.select();
951
961
  setOpen(true);
952
- setActive((i) => stepEnabled(matches, i, 1));
953
- } else if (e.key === "ArrowUp") {
954
- e.preventDefault();
955
- setActive((i) => (i < 0 ? stepEnabled(matches, -1, 1) : stepEnabled(matches, i, -1)));
956
- } else if (e.key === "Enter") {
957
- if (open && isOptionEnabled(matches[active])) {
962
+ // The sheet has its own input; the field behind it must not also pull up
963
+ // the keyboard and scroll the page under the dialog.
964
+ if (isPhone) sheetInputRef.current?.focus();
965
+ }}
966
+ onBlur={() => {
967
+ // On a phone the blur is the SHEET taking focus, not the user leaving the
968
+ // field — reconciling there would close the sheet the instant it opened.
969
+ if (!isPhone) reconcile();
970
+ }}
971
+ onChange={(e) => {
972
+ setText(e.target.value);
973
+ setOpen(true);
974
+ setActive(-1);
975
+ }}
976
+ onKeyDown={(e) => {
977
+ // Disabled rows are passed over; Up from "nothing highlighted" lands on
978
+ // the first takeable row, as it always landed on row 0.
979
+ if (e.key === "ArrowDown") {
980
+ e.preventDefault();
981
+ setOpen(true);
982
+ setActive((i) => stepEnabled(matches, i, 1));
983
+ } else if (e.key === "ArrowUp") {
958
984
  e.preventDefault();
959
- commit(matches[active]);
960
- } else {
985
+ setActive((i) => (i < 0 ? stepEnabled(matches, -1, 1) : stepEnabled(matches, i, -1)));
986
+ } else if (e.key === "Enter") {
987
+ if (open && isOptionEnabled(matches[active])) {
988
+ e.preventDefault();
989
+ commit(matches[active]);
990
+ } else {
991
+ reconcile();
992
+ }
993
+ } else if (e.key === "Escape") {
994
+ setText(null);
995
+ close();
996
+ } else if (e.key === "Tab") {
997
+ // Focus is in THIS input and stays there, so the browser's own Tab is
998
+ // left alone; all that is needed is that the list stop covering what
999
+ // the user is tabbing to. `reconcile` rather than `close`, because
1000
+ // leaving the field is exactly when loose text has to be judged.
961
1001
  reconcile();
962
1002
  }
963
- } else if (e.key === "Escape") {
964
- setText(null);
965
- close();
966
- } else if (e.key === "Tab") {
967
- // Focus is in THIS input and stays there, so the browser's own Tab is
968
- // left alone; all that is needed is that the list stop covering what
969
- // the user is tabbing to. `reconcile` rather than `close`, because
970
- // leaving the field is exactly when loose text has to be judged.
971
- reconcile();
972
- }
973
- // Home/End stay with the caret — see the note in {@link Combobox}: this
974
- // field is editable, and its text is what `reconcile` judges.
975
- }}
976
- className={cn(
977
- FIELD_BASE,
978
- label !== undefined && FIELD_FLOATING_PAD,
979
- "pe-9",
980
- field.isInvalid && FIELD_INVALID,
981
- )}
982
- />
983
- {showClear ? (
984
- <button
985
- type="button"
986
- // Out of the tab order, like the clear on `EntityCombobox`: the keyboard
987
- // already clears this field by selecting its text and deleting, and a
988
- // second stop between every picker and the next field is a worse trade
989
- // than the one gesture it saves.
990
- tabIndex={-1}
991
- aria-label={labels.clear}
992
- // preventDefault, exactly as the chevron does: without it the press
993
- // focuses the input, which on a phone opens the sheet over the field the
994
- // press was clearing.
995
- onMouseDown={(e) => e.preventDefault()}
996
- onClick={() => {
997
- setText(null);
998
- close();
999
- onChange(clearValue);
1000
- }}
1001
- className={cn(
1002
- "absolute end-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-[var(--text-placeholder)]",
1003
- "hover:text-[var(--text-secondary)]",
1004
- )}
1005
- >
1006
- <X aria-hidden className="size-4" />
1007
- </button>
1008
- ) : (
1009
- <ChevronDown
1010
- aria-hidden
1011
- onMouseDown={(e) => {
1012
- // Toggle on the chevron without stealing focus from the input — and
1013
- // not at all on a disabled field, which the input's own `disabled`
1014
- // does not stop from here.
1015
- e.preventDefault();
1016
- if (!disabled) setOpen((o) => !o);
1003
+ // Home/End stay with the caret — see the note in {@link Combobox}: this
1004
+ // field is editable, and its text is what `reconcile` judges.
1017
1005
  }}
1018
1006
  className={cn(
1019
- "absolute end-2.5 top-1/2 size-4 -translate-y-1/2 text-[var(--text-placeholder)]",
1020
- // The box above does the dimming; dimming here too would halve it again.
1021
- !disabled && "cursor-pointer",
1007
+ FIELD_BASE,
1008
+ label !== undefined && FIELD_FLOATING_PAD,
1009
+ "pe-9",
1010
+ field.isInvalid && FIELD_INVALID,
1022
1011
  )}
1023
1012
  />
1024
- )}
1025
- </div>
1013
+ {showClear ? (
1014
+ <button
1015
+ type="button"
1016
+ // Out of the tab order, like the clear on `EntityCombobox`: the keyboard
1017
+ // already clears this field by selecting its text and deleting, and a
1018
+ // second stop between every picker and the next field is a worse trade
1019
+ // than the one gesture it saves.
1020
+ tabIndex={-1}
1021
+ aria-label={labels.clear}
1022
+ // preventDefault, exactly as the chevron does: without it the press
1023
+ // focuses the input, which on a phone opens the sheet over the field the
1024
+ // press was clearing.
1025
+ onMouseDown={(e) => e.preventDefault()}
1026
+ onClick={() => {
1027
+ setText(null);
1028
+ close();
1029
+ onChange(clearValue);
1030
+ }}
1031
+ className={cn(
1032
+ "absolute end-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-[var(--text-placeholder)]",
1033
+ "hover:text-[var(--text-secondary)]",
1034
+ )}
1035
+ >
1036
+ <X aria-hidden className="size-4" />
1037
+ </button>
1038
+ ) : (
1039
+ <ChevronDown
1040
+ aria-hidden
1041
+ onMouseDown={(e) => {
1042
+ // Toggle on the chevron without stealing focus from the input — and
1043
+ // not at all on a disabled field, which the input's own `disabled`
1044
+ // does not stop from here.
1045
+ e.preventDefault();
1046
+ if (!disabled) setOpen((o) => !o);
1047
+ }}
1048
+ className={cn(
1049
+ "absolute end-2.5 top-1/2 size-4 -translate-y-1/2 text-[var(--text-placeholder)]",
1050
+ // The box above does the dimming; dimming here too would halve it again.
1051
+ !disabled && "cursor-pointer",
1052
+ )}
1053
+ />
1054
+ )}
1055
+ </div>
1056
+ </EndHintRow>
1057
+ {label !== undefined && hintParts.labelHint !== undefined && (
1058
+ <StaticLabelRow hint={hintParts.labelHint}>
1059
+ <ComboboxFieldLabel htmlFor={fieldId} className={cn(LABEL_IN_ROW, disabled && "opacity-50")}>
1060
+ {label}
1061
+ </ComboboxFieldLabel>
1062
+ </StaticLabelRow>
1063
+ )}
1064
+ <FieldCaption parts={hintParts} />
1026
1065
  {field.errorEl}
1027
1066
  {isPhone && (
1028
1067
  <PickerSheet