@eifi1/ui-kit 0.21.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (320) hide show
  1. package/README.md +42 -25
  2. package/dist/chart.d.ts +19 -14
  3. package/dist/components/account-chips.d.ts +10 -3
  4. package/dist/components/account-chips.js +5 -2
  5. package/dist/components/account-chips.js.map +1 -1
  6. package/dist/components/account-settings.d.ts +33 -2
  7. package/dist/components/account-settings.js +43 -21
  8. package/dist/components/account-settings.js.map +1 -1
  9. package/dist/components/amount-input.d.ts +53 -14
  10. package/dist/components/amount-input.js +17 -28
  11. package/dist/components/amount-input.js.map +1 -1
  12. package/dist/components/autocomplete.d.ts +29 -0
  13. package/dist/components/autocomplete.js +134 -104
  14. package/dist/components/autocomplete.js.map +1 -1
  15. package/dist/components/button-group.d.ts +19 -14
  16. package/dist/components/calculator.d.ts +19 -14
  17. package/dist/components/checkbox-group.d.ts +104 -0
  18. package/dist/components/checkbox-group.js +111 -0
  19. package/dist/components/checkbox-group.js.map +1 -0
  20. package/dist/components/checkbox.d.ts +19 -0
  21. package/dist/components/checkbox.js +30 -7
  22. package/dist/components/checkbox.js.map +1 -1
  23. package/dist/components/chip.d.ts +18 -0
  24. package/dist/components/chip.js +47 -10
  25. package/dist/components/chip.js.map +1 -1
  26. package/dist/components/column-mapper.d.ts +182 -0
  27. package/dist/components/column-mapper.js +375 -0
  28. package/dist/components/column-mapper.js.map +1 -0
  29. package/dist/components/combobox-core.d.ts +39 -2
  30. package/dist/components/combobox-core.js +29 -6
  31. package/dist/components/combobox-core.js.map +1 -1
  32. package/dist/components/combobox.d.ts +91 -2
  33. package/dist/components/combobox.js +454 -308
  34. package/dist/components/combobox.js.map +1 -1
  35. package/dist/components/confirm-dialog.d.ts +69 -161
  36. package/dist/components/confirm-dialog.js +7 -8
  37. package/dist/components/confirm-dialog.js.map +1 -1
  38. package/dist/components/copy-button.d.ts +16 -11
  39. package/dist/components/country-select.d.ts +167 -0
  40. package/dist/components/country-select.js +293 -0
  41. package/dist/components/country-select.js.map +1 -0
  42. package/dist/components/currency-select.d.ts +17 -1
  43. package/dist/components/currency-select.js +56 -23
  44. package/dist/components/currency-select.js.map +1 -1
  45. package/dist/components/danger-confirm.d.ts +69 -166
  46. package/dist/components/danger-confirm.js +144 -50
  47. package/dist/components/danger-confirm.js.map +1 -1
  48. package/dist/components/data-table-cells.d.ts +1 -2
  49. package/dist/components/data-table-filter-popover.d.ts +1 -2
  50. package/dist/components/data-table-filters.d.ts +1 -2
  51. package/dist/components/data-table-labels.d.ts +4 -97
  52. package/dist/components/data-table-labels.js +9 -1
  53. package/dist/components/data-table-labels.js.map +1 -1
  54. package/dist/components/data-table-pagination.d.ts +3 -1
  55. package/dist/components/data-table.d.ts +1 -2
  56. package/dist/components/data-table.js +98 -3
  57. package/dist/components/data-table.js.map +1 -1
  58. package/dist/components/date-picker.d.ts +66 -5
  59. package/dist/components/date-picker.js +378 -66
  60. package/dist/components/date-picker.js.map +1 -1
  61. package/dist/components/entity-combobox.d.ts +42 -1
  62. package/dist/components/entity-combobox.js +154 -113
  63. package/dist/components/entity-combobox.js.map +1 -1
  64. package/dist/components/facing-pair.d.ts +19 -14
  65. package/dist/components/field-parts.d.ts +224 -0
  66. package/dist/components/field-parts.js +156 -0
  67. package/dist/components/field-parts.js.map +1 -0
  68. package/dist/components/field-strip.d.ts +105 -0
  69. package/dist/components/field-strip.js +46 -0
  70. package/dist/components/field-strip.js.map +1 -0
  71. package/dist/components/file-button.d.ts +19 -14
  72. package/dist/components/file-button.js +13 -3
  73. package/dist/components/file-button.js.map +1 -1
  74. package/dist/components/file-dropzone.d.ts +41 -15
  75. package/dist/components/file-dropzone.js +57 -34
  76. package/dist/components/file-dropzone.js.map +1 -1
  77. package/dist/components/form-actions.d.ts +18 -13
  78. package/dist/components/form-actions.js +69 -5
  79. package/dist/components/form-actions.js.map +1 -1
  80. package/dist/components/iban-input.d.ts +69 -0
  81. package/dist/components/iban-input.js +125 -0
  82. package/dist/components/iban-input.js.map +1 -0
  83. package/dist/components/icon-picker.d.ts +21 -2
  84. package/dist/components/icon-picker.js +19 -4
  85. package/dist/components/icon-picker.js.map +1 -1
  86. package/dist/components/inline-edit-field.d.ts +167 -0
  87. package/dist/components/inline-edit-field.js +293 -0
  88. package/dist/components/inline-edit-field.js.map +1 -0
  89. package/dist/components/language-select.d.ts +130 -0
  90. package/dist/components/language-select.js +18 -0
  91. package/dist/components/language-select.js.map +1 -0
  92. package/dist/components/line-items.d.ts +25 -3
  93. package/dist/components/line-items.js +55 -46
  94. package/dist/components/line-items.js.map +1 -1
  95. package/dist/components/list.d.ts +25 -0
  96. package/dist/components/list.js +16 -5
  97. package/dist/components/list.js.map +1 -1
  98. package/dist/components/money-field.d.ts +32 -14
  99. package/dist/components/money-field.js.map +1 -1
  100. package/dist/components/month-picker.d.ts +52 -4
  101. package/dist/components/month-picker.js +291 -86
  102. package/dist/components/month-picker.js.map +1 -1
  103. package/dist/components/multi-entity-combobox.d.ts +26 -1
  104. package/dist/components/multi-entity-combobox.js +143 -102
  105. package/dist/components/multi-entity-combobox.js.map +1 -1
  106. package/dist/components/multi-select.d.ts +8 -1
  107. package/dist/components/multi-select.js +9 -4
  108. package/dist/components/multi-select.js.map +1 -1
  109. package/dist/components/number-field.d.ts +34 -19
  110. package/dist/components/number-field.js +6 -20
  111. package/dist/components/number-field.js.map +1 -1
  112. package/dist/components/number-input.d.ts +79 -19
  113. package/dist/components/number-input.js +90 -38
  114. package/dist/components/number-input.js.map +1 -1
  115. package/dist/components/numpad-sheet.d.ts +19 -14
  116. package/dist/components/one-time-code-input.d.ts +100 -0
  117. package/dist/components/one-time-code-input.js +137 -0
  118. package/dist/components/one-time-code-input.js.map +1 -0
  119. package/dist/components/phone-input.d.ts +69 -0
  120. package/dist/components/phone-input.js +171 -0
  121. package/dist/components/phone-input.js.map +1 -0
  122. package/dist/components/reauth-dialog.d.ts +2 -2
  123. package/dist/components/reauth-dialog.js +6 -8
  124. package/dist/components/reauth-dialog.js.map +1 -1
  125. package/dist/components/series-chart.d.ts +19 -14
  126. package/dist/components/settings-fields.d.ts +19 -14
  127. package/dist/components/share-card.d.ts +16 -11
  128. package/dist/components/sign-chip.d.ts +66 -0
  129. package/dist/components/sign-chip.js +39 -0
  130. package/dist/components/sign-chip.js.map +1 -0
  131. package/dist/components/swatch-picker.d.ts +43 -2
  132. package/dist/components/swatch-picker.js +80 -32
  133. package/dist/components/swatch-picker.js.map +1 -1
  134. package/dist/components/switch.d.ts +19 -0
  135. package/dist/components/switch.js +24 -6
  136. package/dist/components/switch.js.map +1 -1
  137. package/dist/components/text-link.d.ts +18 -13
  138. package/dist/components/tile-radio.d.ts +88 -13
  139. package/dist/components/tile-radio.js +28 -11
  140. package/dist/components/tile-radio.js.map +1 -1
  141. package/dist/components/time-input.d.ts +24 -15
  142. package/dist/components/time-input.js.map +1 -1
  143. package/dist/components/toggle-group.d.ts +47 -1
  144. package/dist/components/toggle-group.js +110 -69
  145. package/dist/components/toggle-group.js.map +1 -1
  146. package/dist/components/trigger-aria.d.ts +3 -0
  147. package/dist/components/trigger-aria.js +3 -1
  148. package/dist/components/trigger-aria.js.map +1 -1
  149. package/dist/components/ui.d.ts +16 -11
  150. package/dist/components/ui.js +284 -88
  151. package/dist/components/ui.js.map +1 -1
  152. package/dist/components/use-table-state.d.ts +1 -2
  153. package/dist/{data-table-3mi2R3so.d.ts → data-table-labels-B7OdnM0S.d.ts} +140 -3
  154. package/dist/data-table.d.ts +1 -2
  155. package/dist/data-table.js +2 -1
  156. package/dist/data-table.js.map +1 -1
  157. package/dist/feedback/feedback-attachment.d.ts +1 -1
  158. package/dist/feedback/feedback-attachment.js +348 -41
  159. package/dist/feedback/feedback-attachment.js.map +1 -1
  160. package/dist/feedback/feedback-dialog.d.ts +1 -1
  161. package/dist/feedback/feedback-dialog.js.map +1 -1
  162. package/dist/feedback/feedback-inbox.d.ts +3 -2
  163. package/dist/feedback/feedback-inbox.js.map +1 -1
  164. package/dist/feedback/feedback-thread.d.ts +68 -253
  165. package/dist/feedback/feedback-thread.js +46 -10
  166. package/dist/feedback/feedback-thread.js.map +1 -1
  167. package/dist/{kit-labels-D1J2_jyD.d.ts → feedback-BxeQVzwq.d.ts} +1206 -20
  168. package/dist/{feedback-attachment-WFiXJ8We.d.ts → feedback-attachment-fGAzZPf0.d.ts} +178 -11
  169. package/dist/feedback.d.ts +66 -2
  170. package/dist/hooks/use-file-drop.d.ts +19 -14
  171. package/dist/i18n/defaults.d.ts +19 -14
  172. package/dist/i18n/defaults.js +19 -2
  173. package/dist/i18n/defaults.js.map +1 -1
  174. package/dist/i18n/german.d.ts +19 -14
  175. package/dist/i18n/german.js +84 -5
  176. package/dist/i18n/german.js.map +1 -1
  177. package/dist/i18n/kit-labels.d.ts +15 -10
  178. package/dist/i18n/kit-labels.js +8 -2
  179. package/dist/i18n/kit-labels.js.map +1 -1
  180. package/dist/i18n/languages.d.ts +19 -14
  181. package/dist/i18n/locales/de-CH.d.ts +19 -14
  182. package/dist/i18n/locales/en.d.ts +19 -14
  183. package/dist/i18n/locales/en.js +16 -0
  184. package/dist/i18n/locales/en.js.map +1 -1
  185. package/dist/i18n/locales/es.d.ts +19 -14
  186. package/dist/i18n/locales/es.js +83 -5
  187. package/dist/i18n/locales/es.js.map +1 -1
  188. package/dist/i18n/locales/fr.d.ts +19 -14
  189. package/dist/i18n/locales/fr.js +82 -5
  190. package/dist/i18n/locales/fr.js.map +1 -1
  191. package/dist/i18n/locales/hu.d.ts +19 -14
  192. package/dist/i18n/locales/hu.js +85 -5
  193. package/dist/i18n/locales/hu.js.map +1 -1
  194. package/dist/i18n/locales/it.d.ts +19 -14
  195. package/dist/i18n/locales/it.js +84 -5
  196. package/dist/i18n/locales/it.js.map +1 -1
  197. package/dist/i18n/locales/zh.d.ts +19 -14
  198. package/dist/i18n/locales/zh.js +80 -5
  199. package/dist/i18n/locales/zh.js.map +1 -1
  200. package/dist/i18n/review.d.ts +19 -14
  201. package/dist/i18n/review.js +27 -1
  202. package/dist/i18n/review.js.map +1 -1
  203. package/dist/index.d.ts +21 -10
  204. package/dist/index.js +73 -2
  205. package/dist/index.js.map +1 -1
  206. package/dist/lib/column-mapping.d.ts +81 -0
  207. package/dist/lib/column-mapping.js +108 -0
  208. package/dist/lib/column-mapping.js.map +1 -0
  209. package/dist/lib/countries.d.ts +43 -0
  210. package/dist/lib/countries.js +39 -0
  211. package/dist/lib/countries.js.map +1 -0
  212. package/dist/lib/decimal-marks.d.ts +46 -0
  213. package/dist/lib/decimal-marks.js +40 -0
  214. package/dist/lib/decimal-marks.js.map +1 -0
  215. package/dist/lib/iban.d.ts +98 -0
  216. package/dist/lib/iban.js +154 -0
  217. package/dist/lib/iban.js.map +1 -0
  218. package/dist/lib/isin.d.ts +30 -0
  219. package/dist/lib/isin.js +36 -0
  220. package/dist/lib/isin.js.map +1 -0
  221. package/dist/lib/phone.d.ts +69 -0
  222. package/dist/lib/phone.js +121 -0
  223. package/dist/lib/phone.js.map +1 -0
  224. package/dist/lib/table-text.d.ts +109 -1
  225. package/dist/lib/table-text.js +122 -1
  226. package/dist/lib/table-text.js.map +1 -1
  227. package/dist/rhf/fields.d.ts +233 -18
  228. package/dist/rhf/fields.js +456 -3
  229. package/dist/rhf/fields.js.map +1 -1
  230. package/dist/rhf/form.d.ts +19 -14
  231. package/dist/rhf.d.ts +22 -15
  232. package/dist/rhf.js.map +1 -1
  233. package/dist/shell/app-shell.d.ts +18 -13
  234. package/dist/shell/top-bar-brand.d.ts +19 -14
  235. package/dist/shell/topbar-action-menu.d.ts +36 -3
  236. package/dist/shell/topbar-action-menu.js +74 -33
  237. package/dist/shell/topbar-action-menu.js.map +1 -1
  238. package/dist/shell.d.ts +18 -13
  239. package/dist/table-text.d.ts +1 -1
  240. package/dist/tour/tour.js +20 -24
  241. package/dist/tour/tour.js.map +1 -1
  242. package/dist/wizard/stepper-nav.d.ts +49 -16
  243. package/dist/wizard/stepper-nav.js +4 -0
  244. package/dist/wizard/stepper-nav.js.map +1 -1
  245. package/dist/wizard.d.ts +19 -14
  246. package/package.json +1 -1
  247. package/src/components/account-chips.tsx +17 -3
  248. package/src/components/account-settings.tsx +92 -26
  249. package/src/components/amount-input.tsx +68 -56
  250. package/src/components/autocomplete.tsx +198 -114
  251. package/src/components/checkbox-group.tsx +229 -0
  252. package/src/components/checkbox.tsx +72 -8
  253. package/src/components/chip.tsx +80 -10
  254. package/src/components/column-mapper.tsx +666 -0
  255. package/src/components/combobox-core.tsx +82 -9
  256. package/src/components/combobox.tsx +661 -340
  257. package/src/components/confirm-dialog.tsx +12 -8
  258. package/src/components/country-select.tsx +556 -0
  259. package/src/components/currency-select.tsx +109 -33
  260. package/src/components/danger-confirm.tsx +429 -82
  261. package/src/components/data-table-labels.ts +16 -1
  262. package/src/components/data-table.tsx +175 -6
  263. package/src/components/date-picker.tsx +595 -71
  264. package/src/components/entity-combobox.tsx +235 -124
  265. package/src/components/field-parts.tsx +403 -0
  266. package/src/components/field-strip.tsx +149 -0
  267. package/src/components/file-button.tsx +36 -3
  268. package/src/components/file-dropzone.tsx +106 -38
  269. package/src/components/form-actions.tsx +147 -6
  270. package/src/components/iban-input.tsx +231 -0
  271. package/src/components/icon-picker.tsx +47 -6
  272. package/src/components/inline-edit-field.tsx +533 -0
  273. package/src/components/language-select.tsx +78 -0
  274. package/src/components/line-items.tsx +35 -2
  275. package/src/components/list.tsx +55 -6
  276. package/src/components/money-field.tsx +13 -0
  277. package/src/components/month-picker.tsx +400 -63
  278. package/src/components/multi-entity-combobox.tsx +207 -120
  279. package/src/components/multi-select.tsx +80 -58
  280. package/src/components/number-field.tsx +28 -46
  281. package/src/components/number-input.tsx +210 -43
  282. package/src/components/one-time-code-input.tsx +288 -0
  283. package/src/components/phone-input.tsx +299 -0
  284. package/src/components/reauth-dialog.tsx +17 -18
  285. package/src/components/sign-chip.tsx +103 -0
  286. package/src/components/swatch-picker.tsx +143 -31
  287. package/src/components/switch.tsx +58 -7
  288. package/src/components/tile-radio.tsx +145 -29
  289. package/src/components/time-input.tsx +8 -1
  290. package/src/components/toggle-group.tsx +206 -73
  291. package/src/components/trigger-aria.ts +5 -0
  292. package/src/components/ui.tsx +479 -138
  293. package/src/data-table.ts +2 -1
  294. package/src/feedback/feedback-attachment.tsx +597 -46
  295. package/src/feedback/feedback-dialog.tsx +9 -3
  296. package/src/feedback/feedback-inbox.tsx +3 -2
  297. package/src/feedback/feedback-thread.tsx +113 -9
  298. package/src/i18n/defaults.ts +18 -1
  299. package/src/i18n/german.ts +89 -0
  300. package/src/i18n/kit-labels.tsx +82 -2
  301. package/src/i18n/locales/en.ts +36 -4
  302. package/src/i18n/locales/es.ts +87 -0
  303. package/src/i18n/locales/fr.ts +87 -0
  304. package/src/i18n/locales/hu.ts +83 -0
  305. package/src/i18n/locales/it.ts +88 -0
  306. package/src/i18n/locales/zh.ts +76 -0
  307. package/src/i18n/review.ts +26 -0
  308. package/src/index.ts +71 -3
  309. package/src/lib/column-mapping.ts +234 -0
  310. package/src/lib/countries.ts +91 -0
  311. package/src/lib/decimal-marks.ts +99 -0
  312. package/src/lib/iban.ts +171 -0
  313. package/src/lib/isin.ts +62 -0
  314. package/src/lib/phone.ts +211 -0
  315. package/src/lib/table-text.ts +271 -0
  316. package/src/rhf/fields.tsx +860 -9
  317. package/src/rhf.ts +3 -1
  318. package/src/shell/topbar-action-menu.tsx +134 -38
  319. package/src/tour/tour.tsx +66 -28
  320. package/src/wizard/stepper-nav.tsx +35 -1
@@ -1,7 +1,7 @@
1
1
  import { Fragment, useId, useMemo, useRef, useState } from "react";
2
2
  import type { ComponentPropsWithoutRef, ReactNode, RefObject } from "react";
3
3
  import { createPortal } from "react-dom";
4
- import { ChevronDown, X } from "lucide-react";
4
+ import { ChevronDown, Plus, X } from "lucide-react";
5
5
  import { FIELD_BASE, FIELD_FLOATING_PAD, FIELD_INVALID, PHONE_QUERY } from "./ui";
6
6
  import { cn } from "../lib/cn";
7
7
  import { useDropdown } from "./dropdown";
@@ -12,7 +12,9 @@ import { PickerSheet, SHEET_ROW_CLASS } from "./picker-sheet";
12
12
  import {
13
13
  ComboboxFieldLabel,
14
14
  DISABLED_ROW_CLASS,
15
+ endHintRowProps,
15
16
  isOptionEnabled,
17
+ offersCreate,
16
18
  stepEnabled,
17
19
  SUGGESTION_LIST_CLASS,
18
20
  suggestionRowClass,
@@ -22,6 +24,17 @@ import {
22
24
  type ComboOption,
23
25
  } from "./combobox-core";
24
26
  import { DEFAULT_COMBOBOX_LABELS, useKitLabels } from "../i18n/kit-labels";
27
+ import {
28
+ EndHintRow,
29
+ FieldCaption,
30
+ LABEL_IN_ROW,
31
+ LockedReason,
32
+ StaticLabelRow,
33
+ useFieldHint,
34
+ useLockReason,
35
+ } from "./field-parts";
36
+ import { hasMessage, mergeDescribedBy } from "./choice-parts";
37
+ import { useCommitReason } from "./write-lock";
25
38
 
26
39
  // One look for both combobox flavors below — the suggestion list and its rows
27
40
  // must stay pixel-identical between the free-text and the id-keyed variant (and
@@ -123,6 +136,27 @@ function SuggestionList({
123
136
 
124
137
  const rowClass = suggestionRowClass;
125
138
 
139
+ /**
140
+ * The write lock on the two TYPED fields below — {@link Button}'s `disabledReason`
141
+ * path, for an input (keksdose G2).
142
+ *
143
+ * `readOnly` rather than `disabled`, as everywhere the kit locks a field: still focusable,
144
+ * so the reason in the Tooltip reaches a keyboard; refusing keys, so nothing typed can
145
+ * become a value; and FIELD_BASE paints a `[readonly]` field in the settled grey a
146
+ * disabled one wears. No list opens, by focus, click, chevron or arrow — and Enter is
147
+ * swallowed, so a locked field does not submit the form around it either (Select's
148
+ * lock, `LOCKED_SELECT_KEYS`). Every other key is left alone: the arrows and Home/End
149
+ * still move the caret through a long value someone is trying to read.
150
+ */
151
+ function lockedKeyDown(e: { key: string; preventDefault: () => void }) {
152
+ if (e.key === "Enter") e.preventDefault();
153
+ }
154
+
155
+ /** {@link InlineEntityCombobox}'s create row over the shared row look: in the brand
156
+ * colour {@link Combobox}'s own create row wears, with a leading "+" as the panel
157
+ * pickers' has — an action at the foot of the list, not one more record. */
158
+ const CREATE_ROW_CLASS = "flex items-center gap-2 font-medium text-[var(--brand)]";
159
+
126
160
  /**
127
161
  * Four of the div's own attributes are omitted because this component already owns
128
162
  * the name, with a different meaning: `id` is the INPUT's (a caller labels or
@@ -149,10 +183,31 @@ export interface ComboboxProps
149
183
  * field, on the `<input>`'s `aria-describedby`, and implies `invalid` (lenkbank's
150
184
  * scope fields, which moved here off `<Input list>` + `<datalist>`). */
151
185
  error?: ReactNode;
186
+ /**
187
+ * Standing advice for the field — {@link Input}'s `hint`, so the combobox family
188
+ * reads like every other field (keksdose K4: the family had `error` and no `hint`, so
189
+ * the app's pickers carried their captions outside the component, attached to
190
+ * nothing). Plain TEXT is a caption under the field on the input's
191
+ * `aria-describedby`, before any error; a {@link FieldHint} "?" rides the label line,
192
+ * or with no label sits at the field's end edge, outside the box.
193
+ */
194
+ hint?: ReactNode;
152
195
  /** As `<input disabled>`: no focus, no list, the field's settled look. The chevron
153
196
  * stops toggling with it — it is a mouse target the input's own `disabled` does
154
197
  * not reach. */
155
198
  disabled?: boolean;
199
+ /**
200
+ * Why the value cannot be changed — {@link Button}'s `disabledReason`, for a field
201
+ * that SAVES itself (an inline editor whose blur writes). The input stays focusable
202
+ * and `aria-disabled`, is `readOnly`, opens no list and calls neither `onChange` nor
203
+ * `onSubmit`; the reason is in the kit {@link Tooltip} and on its `aria-describedby`.
204
+ * Wins over `disabled`. The family's lock (keksdose G2) — see {@link InlineEntityCombobox}.
205
+ */
206
+ disabledReason?: ReactNode;
207
+ /** This field COMMITS. Under a locked {@link WriteLockProvider} it is locked the
208
+ * `disabledReason` way with the lock's reason; inside a form with its own Save, leave
209
+ * it off and put `commit` on the Save. No provider, or an unlocked one: no effect. */
210
+ commit?: boolean;
156
211
  /** Heading an option belongs under. Supplying it makes this list read exactly
157
212
  * like {@link InlineEntityCombobox}'s — one heading per group with its rows
158
213
  * indented beneath — instead of a flat list (feedback #136 rework: the payee
@@ -217,39 +272,44 @@ export interface ComboboxProps
217
272
  * visually and behaviourally consistent with the other dropdowns (feedback
218
273
  * #230 — the payee field looked/behaved differently from every other select).
219
274
  */
220
- export function Combobox({
221
- value,
222
- onChange,
223
- options,
224
- label,
225
- id,
226
- placeholder,
227
- className,
228
- groupBy,
229
- maxSuggestions,
230
- searchPlaceholder,
231
- closeLabel,
232
- createLabel,
233
- invalid,
234
- error,
235
- disabled,
236
- optionAdornment,
237
- autoFocus,
238
- onBlur,
239
- onSubmit,
240
- "aria-label": ariaLabel,
241
- // Off `rest` and onto the <input>, which is the combobox a reader meets: `Field`'s
242
- // render-prop spreads `{ id, aria-describedby, aria-invalid, aria-required }`, and
243
- // on the wrapper div the hint and required state described nothing.
244
- "aria-describedby": ariaDescribedBy,
245
- "aria-invalid": ariaInvalid,
246
- "aria-required": ariaRequired,
247
- ...rest
248
- }: ComboboxProps) {
275
+ export function Combobox(props: ComboboxProps) {
276
+ const {
277
+ value,
278
+ onChange,
279
+ options,
280
+ label,
281
+ id,
282
+ placeholder,
283
+ className,
284
+ groupBy,
285
+ maxSuggestions,
286
+ searchPlaceholder,
287
+ closeLabel,
288
+ createLabel,
289
+ invalid,
290
+ error,
291
+ hint,
292
+ disabled,
293
+ disabledReason,
294
+ commit,
295
+ optionAdornment,
296
+ autoFocus,
297
+ onBlur,
298
+ onSubmit,
299
+ "aria-label": ariaLabel,
300
+ // Off `rest` and onto the <input>, which is the combobox a reader meets: `Field`'s
301
+ // render-prop spreads `{ id, aria-describedby, aria-invalid, aria-required }`, and
302
+ // on the wrapper div the hint and required state described nothing.
303
+ "aria-describedby": ariaDescribedBy,
304
+ "aria-invalid": ariaInvalid,
305
+ "aria-required": ariaRequired,
306
+ ...rest
307
+ } = props;
308
+ const hintParts = useFieldHint(hint, ariaDescribedBy);
249
309
  const field = useComboboxFieldError(
250
310
  error,
251
311
  invalid || ariaInvalid === true || ariaInvalid === "true",
252
- ariaDescribedBy,
312
+ hintParts.describedBy,
253
313
  );
254
314
  const generated = useId();
255
315
  const fieldId = id ?? generated;
@@ -264,6 +324,12 @@ export function Combobox({
264
324
  const { open, setOpen, wrapperRef, panelRef } = useDropdown({ backCloses: !isPhone });
265
325
  const primaryOnly = usePrimaryPressOnly();
266
326
  const [active, setActive] = useState(-1);
327
+ // The lock (see `lockedKeyDown`): a lock arriving while the list is up closes it,
328
+ // adjusted while rendering so no frame offers a row that can no longer be taken.
329
+ const lock = useLockReason(commit, disabledReason);
330
+ const locked = lock.locked;
331
+ const inert = locked || Boolean(disabled);
332
+ if (locked && open) setOpen(false);
267
333
  // A phone opens the list as a full-screen sheet with its own input, the way a
268
334
  // native <select> does — live #200: "Paid as full screen dialog with input.
269
335
  // Similar to the account select that already appears as full screen." The
@@ -349,7 +415,8 @@ export function Combobox({
349
415
  return heads;
350
416
  }, [matches, groupBy]);
351
417
 
352
- const commit = (v: string) => {
418
+ const take = (v: string) => {
419
+ if (locked) return;
353
420
  onChange(v);
354
421
  setOpen(false);
355
422
  setActive(-1);
@@ -369,6 +436,17 @@ export function Combobox({
369
436
  const activeId = !isPhone && active >= 0 && active < matches.length ? optionId(active) : undefined;
370
437
  useActiveOptionScroll(activeId);
371
438
 
439
+ // While locked the field sits in the reason's Tooltip — in a fragment with a hidden
440
+ // copy of the reason, Button's anatomy; `block` so a full-width field stays full width.
441
+ const withLock = (box: ReactNode) =>
442
+ locked ? (
443
+ <LockedReason lock={lock} className="block">
444
+ {box}
445
+ </LockedReason>
446
+ ) : (
447
+ box
448
+ );
449
+
372
450
  const typed = value.trim();
373
451
  const createRow =
374
452
  createLabel && typed.length > 0 && !options.some((o) => o.toLowerCase() === typed.toLowerCase())
@@ -383,138 +461,160 @@ export function Combobox({
383
461
  <div {...rest} ref={wrapperRef} className={cn("relative", className)}>
384
462
  {/* A real <label for> since lenkbank's tests met a field `getByLabelText` could
385
463
  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 && (
388
- <ComboboxFieldLabel htmlFor={fieldId} className={disabled ? "opacity-50" : undefined}>
464
+ name a reader hears is the same words either way. With a "?" it moves into a
465
+ row after the field, below. */}
466
+ {label !== undefined && hintParts.labelHint === undefined && (
467
+ <ComboboxFieldLabel htmlFor={fieldId} className={inert ? "opacity-50" : undefined}>
389
468
  {label}
390
469
  </ComboboxFieldLabel>
391
470
  )}
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();
460
- 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) {
471
+ <EndHintRow {...endHintRowProps("hint" in props, label !== undefined, hintParts.labelHint)}>
472
+ {withLock(
473
+ /* The chevron centers against this inner wrapper, which hugs the input.
474
+ The outer div can be taller than the input (as a grid item it
475
+ stretches to the row height, e.g. next to the editor's category cell
476
+ with its split button), which used to drag a top-1/2 chevron down to
477
+ the input's bottom edge (feedback #248).
478
+ Dimmed as a whole when disabled (or locked), the way EntityCombobox dims its
479
+ trigger. FIELD_BASE's grey alone left a disabled picker looking like a
480
+ filled-in one beside the pickers that do dim (the label is not the input's
481
+ `peer`, so it is dimmed by hand above). */
482
+ <div ref={fieldRef} className={cn("relative", inert && "cursor-not-allowed opacity-50")}>
483
+ <input
484
+ id={fieldId}
485
+ value={value}
486
+ placeholder={placeholder}
487
+ // The caller's name, over the <label for> above — which is what names the
488
+ // field otherwise (dev#477: a field with neither announces only its text).
489
+ aria-label={ariaLabel}
490
+ role="combobox"
491
+ aria-expanded={open}
492
+ // The list this field is the mouth of. Required by the role, and the half
493
+ // that was missing: the field said it was expanded and never said what it
494
+ // had expanded, so a reader had no way from the box to the options
495
+ // (ESLint's `role-has-required-aria-props`, the audit's §a11y).
496
+ aria-controls={listboxId}
497
+ aria-activedescendant={activeId}
498
+ aria-autocomplete="list"
499
+ aria-invalid={field.isInvalid || undefined}
500
+ aria-describedby={mergeDescribedBy(field.describedBy, locked && lock.reasonId)}
501
+ aria-required={ariaRequired}
502
+ autoComplete="off"
503
+ // Locked: focusable, `readOnly`, `aria-disabled` — see `lockedKeyDown`.
504
+ disabled={locked ? undefined : disabled}
505
+ readOnly={locked || undefined}
506
+ aria-disabled={locked || undefined}
507
+ // 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.
508
+ autoFocus={autoFocus}
509
+ onBlur={onBlur}
510
+ onMouseDown={primaryOnly.onMouseDown}
511
+ onFocus={() => {
512
+ // A back/forward mouse button focuses this field on its way to
513
+ // navigating; it is not a request to open anything (live #309 rework).
514
+ // A locked field takes focus to say why, and opens nothing.
515
+ if (primaryOnly.fromAuxButton() || locked) return;
516
+ setOpen(true);
517
+ setTyping(false);
518
+ // The sheet carries its own input, so the field behind it must not also
519
+ // pull up the keyboard and scroll the page under the dialog.
520
+ if (isPhone) sheetInputRef.current?.focus();
521
+ }}
522
+ // A CLICK as well as focus — the other half of dev#549. Picking a suggestion
523
+ // closes the list without moving focus (the rows suppress `mousedown` on
524
+ // purpose, so the input never blurred), which means clicking the field again
525
+ // fires no `focus` event at all and the list stayed shut. "Does not open
526
+ // anything" was literally true.
527
+ onClick={() => {
528
+ if (locked) return;
529
+ setOpen(true);
530
+ setTyping(false);
531
+ }}
532
+ // `inputMode="none"` rather than readOnly: the field must not look
533
+ // uneditable (FIELD_BASE greys a read-only field since dev#468) and must
534
+ // still take focus — it just has no keyboard of its own, the same trick
535
+ // the amount field uses for the numpad.
536
+ inputMode={isPhone ? "none" : undefined}
537
+ onChange={(e) => {
538
+ if (locked) return;
539
+ onChange(e.target.value);
540
+ setOpen(true);
541
+ setActive(-1);
542
+ setTyping(true);
543
+ }}
544
+ onKeyDown={(e) => {
545
+ if (locked) return lockedKeyDown(e);
546
+ if (e.key === "ArrowDown") {
547
+ e.preventDefault();
548
+ setOpen(true);
549
+ setActive((i) => Math.min(i + 1, matches.length - 1));
550
+ } else if (e.key === "ArrowUp") {
551
+ e.preventDefault();
552
+ setActive((i) => Math.max(i - 1, 0));
553
+ } else if (e.key === "Enter") {
554
+ if (open && active >= 0 && active < matches.length) {
555
+ e.preventDefault();
556
+ take(matches[active]);
557
+ } else {
558
+ // No row highlighted: the typed text is the answer. `preventDefault`
559
+ // only when a caller is taking Enter, so a plain form submit is
560
+ // otherwise left alone.
561
+ if (onSubmit) e.preventDefault();
562
+ setOpen(false);
563
+ onSubmit?.();
564
+ }
565
+ } else if (e.key === "Escape") {
566
+ setOpen(false);
567
+ setActive(-1);
568
+ } else if (e.key === "Tab") {
569
+ // Closes, and lets the browser take the Tab: focus is in THIS input,
570
+ // which is staying, so there is nothing to catch — unlike the pickers
571
+ // whose focus sits inside a portalled panel.
572
+ setOpen(false);
573
+ setActive(-1);
574
+ }
575
+ // Home/End are deliberately absent. The APG gives them to the list only
576
+ // where the combobox is not editable; here the text IS the value, a
577
+ // payee name is long enough to want the caret moved to its start, and
578
+ // the desktop list is capped at 8 rows — so jumping it would be worth
579
+ // almost nothing and would cost the one gesture that field is used with.
580
+ }}
581
+ className={cn(
582
+ FIELD_BASE,
583
+ label !== undefined && FIELD_FLOATING_PAD,
584
+ "pe-9",
585
+ field.isInvalid && FIELD_INVALID,
586
+ )}
587
+ />
588
+ <ChevronDown
589
+ aria-hidden
590
+ onMouseDown={(e) => {
591
+ // Toggle on the chevron without stealing focus from the input.
467
592
  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();
474
- setOpen(false);
475
- onSubmit?.();
476
- }
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>
593
+ if (!inert) setOpen((o) => !o);
594
+ }}
595
+ className={cn(
596
+ "absolute end-2.5 top-1/2 size-4 -translate-y-1/2 text-[var(--text-placeholder)]",
597
+ // The box above does the dimming; dimming here too would halve it again.
598
+ !inert && "cursor-pointer",
599
+ )}
600
+ />
601
+ </div>,
602
+ )}
603
+ </EndHintRow>
604
+ {/* The label and its "?" share the top strip — after the field in the DOM, so the
605
+ "?" follows the control in the tab order, as it does on Input and Select. */}
606
+ {label !== undefined && hintParts.labelHint !== undefined && (
607
+ <StaticLabelRow hint={hintParts.labelHint}>
608
+ <ComboboxFieldLabel htmlFor={fieldId} className={cn(LABEL_IN_ROW, inert && "opacity-50")}>
609
+ {label}
610
+ </ComboboxFieldLabel>
611
+ </StaticLabelRow>
612
+ )}
613
+ <FieldCaption parts={hintParts} />
514
614
  {field.errorEl}
515
615
  {isPhone && (
516
616
  <PickerSheet
517
- open={open && !disabled}
617
+ open={open && !inert}
518
618
  onClose={() => setOpen(false)}
519
619
  title={label}
520
620
  // The sheet's input IS the field: this is a free-text control, so what is
@@ -539,7 +639,7 @@ export function Combobox({
539
639
  type="button"
540
640
  role="option"
541
641
  aria-selected={false}
542
- onClick={() => commit(typed)}
642
+ onClick={() => take(typed)}
543
643
  className={cn(SHEET_ROW_CLASS, "font-medium text-[var(--brand)]")}
544
644
  >
545
645
  {createRow}
@@ -563,7 +663,7 @@ export function Combobox({
563
663
  type="button"
564
664
  role="option"
565
665
  aria-selected={o === value}
566
- onClick={() => commit(o)}
666
+ onClick={() => take(o)}
567
667
  className={cn(
568
668
  SHEET_ROW_CLASS,
569
669
  optionAdornment && "flex items-center justify-between gap-2",
@@ -579,7 +679,7 @@ export function Combobox({
579
679
  </ul>
580
680
  </PickerSheet>
581
681
  )}
582
- {!isPhone && open && !disabled && (matches.length > 0 || createRow) && (
682
+ {!isPhone && open && !inert && (matches.length > 0 || createRow) && (
583
683
  <SuggestionList id={listboxId} anchorRef={fieldRef} panelRef={panelRef}>
584
684
  {createRow && (
585
685
  <li role="presentation">
@@ -592,7 +692,7 @@ export function Combobox({
592
692
  // mousedown, like the rows below: the input's blur would otherwise
593
693
  // close the list before the click landed.
594
694
  e.preventDefault();
595
- commit(typed);
695
+ take(typed);
596
696
  }}
597
697
  className={cn(rowClass(false), "font-medium text-[var(--brand)]")}
598
698
  >
@@ -632,7 +732,7 @@ export function Combobox({
632
732
  // mousedown (not click) so the blur from the input firing first
633
733
  // doesn't close the list before the selection registers.
634
734
  e.preventDefault();
635
- commit(o);
735
+ take(o);
636
736
  }}
637
737
  onMouseEnter={() => setActive(i)}
638
738
  className={cn(
@@ -667,6 +767,8 @@ export interface InlineEntityComboboxProps<V extends string | number, C extends
667
767
  invalid?: boolean;
668
768
  /** What is wrong with the value — see {@link Combobox}'s `error`. */
669
769
  error?: ReactNode;
770
+ /** Standing advice — see {@link Combobox}'s `hint` (keksdose K4). */
771
+ hint?: ReactNode;
670
772
  /** A picked/typed option emits its id; emptying the field, or the "×", emits
671
773
  * `clearValue`. */
672
774
  onChange: (v: V | C) => void;
@@ -702,6 +804,72 @@ export interface InlineEntityComboboxProps<V extends string | number, C extends
702
804
  * affordance both shells share, and it clears without opening anything. */
703
805
  clearable?: boolean;
704
806
  clearLabel?: string;
807
+ /**
808
+ * Why the choice cannot be changed — {@link Button}'s `disabledReason`, for a picker
809
+ * that SAVES on change. keksdose G2: the statement review's account picker IS the
810
+ * write (picking re-runs the duplicate check on the server), and on the read-only demo
811
+ * the app wrapped it in a hand-made Tooltip over a native `disabled` — out of the tab
812
+ * order, so the reason never reached a keyboard.
813
+ *
814
+ * With a reason the input stays focusable and `aria-disabled`, is `readOnly` (the
815
+ * settled grey), opens no list or sheet, offers no clear "×", and no pick, typed
816
+ * label or clear reaches `onChange`; the reason is in the kit {@link Tooltip} and on
817
+ * the input's `aria-describedby`. Wins over `disabled`. Use it CONTROLLED, as the
818
+ * component always is: the shown label is `value`'s.
819
+ */
820
+ disabledReason?: ReactNode;
821
+ /**
822
+ * This picker COMMITS — choosing saves. Under a locked {@link WriteLockProvider} it is
823
+ * locked the `disabledReason` way with the lock's reason (which wins over its own).
824
+ * A picker inside a form with its own Save stays editable under the lock — leave this
825
+ * off there and put `commit` on the Save. No provider, or an unlocked one: no effect.
826
+ */
827
+ commit?: boolean;
828
+ /**
829
+ * A last row that makes a new record from what was typed — keksdose G9, whose "Create
830
+ * cash account" had to become a separate button because this picker had no such row.
831
+ *
832
+ * Shown while the typed query is non-empty and names no option exactly
833
+ * (case-insensitive — an exact hit is a record the list already shows), worded
834
+ * `createLabel(query)`, default `combobox.create`: "Create “{query}”". The arrow keys
835
+ * reach it like an option; Enter or a click calls `onCreate(query)` and closes the
836
+ * list. Making the record, and then passing its id as `value`, is the caller's: the
837
+ * field shows the old selection until it does.
838
+ *
839
+ * The same rule as {@link EntityCombobox}'s `onCreate`, so the family agrees. It is a
840
+ * row of the LIST, not an option: it is never `aria-selected`, never matched by a
841
+ * typed label on blur, and never sorted among the records the way a sentinel option
842
+ * would be (the reason keksdose gave for the separate button).
843
+ */
844
+ onCreate?: (query: string) => void;
845
+ /** The create row's words for a query. Default: `combobox.create` from the
846
+ * {@link UiKitProvider}, else English `Create “{query}”`. */
847
+ createLabel?: (query: string) => string;
848
+ /**
849
+ * Offer the create row with NOTHING typed too, worded as this — keksdose's "Create cash
850
+ * account", which mints an account under a default name and so needs no query.
851
+ * `onCreate` then receives `""`, and the caller supplies the name. Left out, the row
852
+ * needs a query.
853
+ *
854
+ * One string rather than a switch: a row with no query has nothing to quote, so the
855
+ * kit cannot word it — "Create “”" says nothing — and naming it is what turns it on.
856
+ * With both, one handler serves both rows:
857
+ *
858
+ * ```tsx
859
+ * onCreate={(name) => createCash(name || t("cash_account_default_name"))}
860
+ * createEmptyLabel={t("create_cash_account")}
861
+ * ```
862
+ */
863
+ createEmptyLabel?: string;
864
+ /**
865
+ * The create row WRITES while picking does not — keksdose dev#496: on the read-only
866
+ * demo choosing the cash account is draft state and stays live, minting one is a
867
+ * write. Under a locked {@link WriteLockProvider} the row stays in the list, dimmed and
868
+ * passed over by the arrows, with the lock's reason as its second line (a hover is the
869
+ * one explanation a phone cannot show), and `onCreate` is not called. Not needed with
870
+ * `commit`: a locked field opens no list at all.
871
+ */
872
+ createCommit?: boolean;
705
873
  }
706
874
 
707
875
  /**
@@ -722,40 +890,51 @@ export interface InlineEntityComboboxProps<V extends string | number, C extends
722
890
  * sheet that emptied it to show the full list would read as "the user cleared the
723
891
  * field" the moment it closed.
724
892
  */
725
- export function InlineEntityCombobox<V extends string | number, C extends ComboClearValue = null>({
726
- value: rawValue,
727
- onChange,
728
- clearValue = null as C,
729
- options,
730
- label,
731
- id,
732
- placeholder,
733
- className,
734
- disabled,
735
- autoFocus,
736
- searchPlaceholder,
737
- emptyLabel,
738
- closeLabel,
739
- clearable,
740
- clearLabel,
741
- invalid,
742
- error,
743
- "aria-label": ariaLabel,
744
- // Off `rest` and onto the <input>, which is the combobox a reader meets: `Field`'s
745
- // render-prop spreads `{ id, aria-describedby, aria-invalid, aria-required }`, and
746
- // on the wrapper div the hint and required state described nothing.
747
- "aria-describedby": ariaDescribedBy,
748
- "aria-invalid": ariaInvalid,
749
- "aria-required": ariaRequired,
750
- ...rest
751
- }: InlineEntityComboboxProps<V, C>) {
893
+ export function InlineEntityCombobox<V extends string | number, C extends ComboClearValue = null>(
894
+ props: InlineEntityComboboxProps<V, C>,
895
+ ) {
896
+ const {
897
+ value: rawValue,
898
+ onChange,
899
+ clearValue = null as C,
900
+ options,
901
+ label,
902
+ id,
903
+ placeholder,
904
+ className,
905
+ disabled,
906
+ autoFocus,
907
+ searchPlaceholder,
908
+ emptyLabel,
909
+ closeLabel,
910
+ clearable,
911
+ clearLabel,
912
+ disabledReason,
913
+ commit,
914
+ onCreate,
915
+ createLabel,
916
+ createEmptyLabel,
917
+ createCommit,
918
+ invalid,
919
+ error,
920
+ hint,
921
+ "aria-label": ariaLabel,
922
+ // Off `rest` and onto the <input>, which is the combobox a reader meets: `Field`'s
923
+ // render-prop spreads `{ id, aria-describedby, aria-invalid, aria-required }`, and
924
+ // on the wrapper div the hint and required state described nothing.
925
+ "aria-describedby": ariaDescribedBy,
926
+ "aria-invalid": ariaInvalid,
927
+ "aria-required": ariaRequired,
928
+ ...rest
929
+ } = props;
752
930
  // The clear value reads as "nothing selected", whichever one the caller picked —
753
931
  // so from here down `value` is the id or `null`, as it always was.
754
932
  const value: V | null = rawValue == null || rawValue === clearValue ? null : (rawValue as V);
933
+ const hintParts = useFieldHint(hint, ariaDescribedBy);
755
934
  const field = useComboboxFieldError(
756
935
  error,
757
936
  invalid || ariaInvalid === true || ariaInvalid === "true",
758
- ariaDescribedBy,
937
+ hintParts.describedBy,
759
938
  );
760
939
  const generated = useId();
761
940
  const fieldId = id ?? generated;
@@ -783,7 +962,21 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
783
962
  search: searchPlaceholder,
784
963
  noResults: emptyLabel,
785
964
  clear: clearLabel,
965
+ create: createLabel,
786
966
  });
967
+ // The lock (see `lockedKeyDown` above). A lock arriving mid-edit closes the list and
968
+ // drops the loose text, adjusted while rendering: nothing typed before it may be
969
+ // judged by `reconcile` after it.
970
+ const lock = useLockReason(commit, disabledReason);
971
+ const locked = lock.locked;
972
+ const inert = locked || Boolean(disabled);
973
+ if (locked && (open || text !== null)) {
974
+ setOpen(false);
975
+ setText(null);
976
+ }
977
+ // The create row's own lock (`createCommit`): the list stays live, the row does not.
978
+ const createReason = useCommitReason(createCommit, undefined);
979
+ const createLocked = hasMessage(createReason);
787
980
 
788
981
  const selected = useMemo(
789
982
  () => (value == null ? null : (options.find((o) => o.value === value) ?? null)),
@@ -792,7 +985,7 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
792
985
  const shown = text ?? selected?.label ?? "";
793
986
  // Nothing selected has nothing to clear, and the chevron comes back — the field
794
987
  // keeps exactly one trailing control, so the "×" never crowds the value it sits on.
795
- const showClear = Boolean(clearable && value != null && !disabled);
988
+ const showClear = Boolean(clearable && value != null && !inert);
796
989
 
797
990
  // Focusing select-alls the current label; filtering only kicks in once the
798
991
  // text actually differs from it, so an already-filled field still opens on
@@ -826,11 +1019,35 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
826
1019
  return [...blocks.values()].flat();
827
1020
  }, [options, query]);
828
1021
 
1022
+ // The create row (`onCreate`), on the query as typed — not lowercased: it becomes a
1023
+ // name. Compared against EVERY option, not `matches`: an exact hit anywhere is a
1024
+ // record that exists, disabled or not. Restated from `text` rather than derived from
1025
+ // `typedQuery`: this value is handed to the caller's `onCreate`, and the React
1026
+ // Compiler cannot then keep `matches` memoised on anything it was derived from.
1027
+ const createQuery = isPhone
1028
+ ? sheetQuery.trim()
1029
+ : text !== null && text !== (selected?.label ?? "")
1030
+ ? text.trim()
1031
+ : "";
1032
+ const showCreate = offersCreate(
1033
+ onCreate,
1034
+ createQuery,
1035
+ options.map((o) => o.label),
1036
+ createEmptyLabel,
1037
+ );
1038
+ const createText = createQuery ? labels.create(createQuery) : createEmptyLabel;
1039
+ // Every row the keyboard can land on: the options, then the create row — passed over
1040
+ // like a disabled option while a write lock holds it.
1041
+ const rows: readonly { disabled?: boolean }[] = showCreate
1042
+ ? [...matches, { disabled: createLocked }]
1043
+ : matches;
1044
+ const createIndex = matches.length;
1045
+
829
1046
  // Pointer-device only: the phone's rows live in a {@link PickerSheet} whose own
830
1047
  // search box holds focus, so the field behind it must not claim to be pointing at
831
1048
  // one of them.
832
1049
  // A disabled row is never the keyboard's, even if the list changed under it.
833
- const activeId = !isPhone && isOptionEnabled(matches[active]) ? optionId(active) : undefined;
1050
+ const activeId = !isPhone && isOptionEnabled(rows[active]) ? optionId(active) : undefined;
834
1051
  useActiveOptionScroll(activeId);
835
1052
 
836
1053
  const close = () => {
@@ -838,9 +1055,10 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
838
1055
  setActive(-1);
839
1056
  setSheetQuery("");
840
1057
  };
841
- const commit = (o: ComboOption<V>) => {
842
- // A `disabled` option is listed, never taken — by any path.
843
- if (o.disabled) return;
1058
+ const take = (o: ComboOption<V>) => {
1059
+ // A `disabled` option is listed, never taken — by any path; nor is anything while
1060
+ // the field is locked.
1061
+ if (o.disabled || locked) return;
844
1062
  if (o.value !== value) onChange(o.value);
845
1063
  setText(null);
846
1064
  close();
@@ -855,8 +1073,15 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
855
1073
  * used, then tabbing away, silently discarded it, and the more categories you used
856
1074
  * the more of them stopped working. Two rows naming the same id are not an ambiguity;
857
1075
  * two ids sharing a label are. */
1076
+ const create = () => {
1077
+ if (!onCreate || locked || createLocked) return;
1078
+ onCreate(createQuery);
1079
+ setText(null);
1080
+ close();
1081
+ };
858
1082
  const reconcile = () => {
859
- if (text !== null) {
1083
+ // Locked, loose text is dropped rather than judged: nothing reaches `onChange`.
1084
+ if (text !== null && !locked) {
860
1085
  const q = text.trim();
861
1086
  if (!q) {
862
1087
  if (value != null) onChange(clearValue);
@@ -869,164 +1094,209 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
869
1094
  const ids = new Set(hits.map((h) => h.value));
870
1095
  if (ids.size === 1 && hits[0].value !== value) onChange(hits[0].value);
871
1096
  }
872
- setText(null);
873
1097
  }
1098
+ setText(null);
874
1099
  close();
875
1100
  };
876
1101
 
1102
+ // The create row's inside, one for both shells: a "+" (it is an action, not a record),
1103
+ // the words, and — while a write lock holds it — the lock's reason as a second line.
1104
+ const createRowContent = (
1105
+ <>
1106
+ <Plus aria-hidden className="size-4 shrink-0" />
1107
+ <span className="min-w-0 flex-1">
1108
+ <span className="block truncate">{createText}</span>
1109
+ {createLocked && (
1110
+ <span className="block truncate text-xs font-normal text-[var(--text-placeholder)]">
1111
+ {createReason}
1112
+ </span>
1113
+ )}
1114
+ </span>
1115
+ </>
1116
+ );
1117
+ // While locked the field sits in the reason's Tooltip — see Combobox.
1118
+ const withLock = (box: ReactNode) =>
1119
+ locked ? (
1120
+ <LockedReason lock={lock} className="block">
1121
+ {box}
1122
+ </LockedReason>
1123
+ ) : (
1124
+ box
1125
+ );
1126
+
877
1127
  return (
878
1128
  // `rest` dresses the outer box — a `data-tour` anchor, a test id. Not the NAME,
879
1129
  // description, invalid or required state: those belong on the <input> below,
880
1130
  // which is the combobox a reader meets. Spread FIRST, so the field's ARIA and the
881
1131
  // handlers carrying live #309 and dev#549 cannot be replaced from outside.
882
1132
  <div {...rest} ref={wrapperRef} className={cn("relative", className)}>
883
- {/* A real <label for> — see {@link Combobox}. */}
884
- {label !== undefined && (
885
- <ComboboxFieldLabel htmlFor={fieldId} className={disabled ? "opacity-50" : undefined}>
1133
+ {/* A real <label for> — see {@link Combobox}; with a "?", in a row after the field. */}
1134
+ {label !== undefined && hintParts.labelHint === undefined && (
1135
+ <ComboboxFieldLabel htmlFor={fieldId} className={inert ? "opacity-50" : undefined}>
886
1136
  {label}
887
1137
  </ComboboxFieldLabel>
888
1138
  )}
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();
951
- 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])) {
958
- e.preventDefault();
959
- commit(matches[active]);
960
- } else {
961
- reconcile();
962
- }
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);
1017
- }}
1018
- 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",
1139
+ <EndHintRow {...endHintRowProps("hint" in props, label !== undefined, hintParts.labelHint)}>
1140
+ {withLock(
1141
+ /* Inner wrapper for chevron centering — same reasoning as Combobox above.
1142
+ It is also what the portalled list anchors to.
1143
+ Dimmed as a whole when disabled (or locked), the way EntityCombobox dims its
1144
+ trigger. FIELD_BASE's grey alone left a disabled picker looking like a
1145
+ filled-in one beside the pickers that do dim (the label is not the input's
1146
+ `peer`, so it is dimmed by hand above). */
1147
+ <div ref={fieldRef} className={cn("relative", inert && "cursor-not-allowed opacity-50")}>
1148
+ <input
1149
+ id={fieldId}
1150
+ value={shown}
1151
+ placeholder={placeholder}
1152
+ // The caller's name, over the <label for> above. Just the label, never
1153
+ // "label: value" the way a trigger button has to compose it: an input
1154
+ // already exposes its value separately.
1155
+ aria-label={ariaLabel}
1156
+ role="combobox"
1157
+ aria-expanded={open}
1158
+ // Required by the role, and the half that was missing: the field said it
1159
+ // was expanded and never said what it had expanded (ESLint's
1160
+ // `role-has-required-aria-props`, the audit's §a11y).
1161
+ aria-controls={listboxId}
1162
+ aria-activedescendant={activeId}
1163
+ aria-autocomplete="list"
1164
+ aria-invalid={field.isInvalid || undefined}
1165
+ aria-describedby={mergeDescribedBy(field.describedBy, locked && lock.reasonId)}
1166
+ aria-required={ariaRequired}
1167
+ autoComplete="off"
1168
+ // Locked: focusable, `readOnly`, `aria-disabled` — see `lockedKeyDown`.
1169
+ disabled={locked ? undefined : disabled}
1170
+ readOnly={locked || undefined}
1171
+ aria-disabled={locked || undefined}
1172
+ // 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.
1173
+ autoFocus={autoFocus}
1174
+ // `inputMode="none"` rather than readOnly, for the same reason Combobox
1175
+ // above gives: the sheet carries the keyboard, and a readOnly field would
1176
+ // take FIELD_BASE's settled look on a field that is perfectly editable.
1177
+ inputMode={isPhone ? "none" : undefined}
1178
+ onMouseDown={primaryOnly.onMouseDown}
1179
+ onFocus={(e) => {
1180
+ // See {@link usePrimaryPressOnly}: a back/forward button lands here on
1181
+ // its way to navigating, and neither the list nor the select-all is
1182
+ // anything it asked for (live #309 rework). A locked field takes focus to
1183
+ // say why, and opens nothing.
1184
+ if (primaryOnly.fromAuxButton() || locked) return;
1185
+ setText(shown);
1186
+ e.currentTarget.select();
1187
+ setOpen(true);
1188
+ // The sheet has its own input; the field behind it must not also pull up
1189
+ // the keyboard and scroll the page under the dialog.
1190
+ if (isPhone) sheetInputRef.current?.focus();
1191
+ }}
1192
+ onBlur={() => {
1193
+ // On a phone the blur is the SHEET taking focus, not the user leaving the
1194
+ // field — reconciling there would close the sheet the instant it opened.
1195
+ if (!isPhone) reconcile();
1196
+ }}
1197
+ onChange={(e) => {
1198
+ if (locked) return;
1199
+ setText(e.target.value);
1200
+ setOpen(true);
1201
+ setActive(-1);
1202
+ }}
1203
+ onKeyDown={(e) => {
1204
+ if (locked) return lockedKeyDown(e);
1205
+ // Disabled rows are passed over; Up from "nothing highlighted" lands on
1206
+ // the first takeable row, as it always landed on row 0. The create row is
1207
+ // the last row the arrows reach.
1208
+ if (e.key === "ArrowDown") {
1209
+ e.preventDefault();
1210
+ setOpen(true);
1211
+ setActive((i) => stepEnabled(rows, i, 1));
1212
+ } else if (e.key === "ArrowUp") {
1213
+ e.preventDefault();
1214
+ setActive((i) => (i < 0 ? stepEnabled(rows, -1, 1) : stepEnabled(rows, i, -1)));
1215
+ } else if (e.key === "Enter") {
1216
+ if (open && isOptionEnabled(rows[active])) {
1217
+ e.preventDefault();
1218
+ if (active === createIndex && showCreate) create();
1219
+ else take(matches[active]);
1220
+ } else {
1221
+ reconcile();
1222
+ }
1223
+ } else if (e.key === "Escape") {
1224
+ setText(null);
1225
+ close();
1226
+ } else if (e.key === "Tab") {
1227
+ // Focus is in THIS input and stays there, so the browser's own Tab is
1228
+ // left alone; all that is needed is that the list stop covering what
1229
+ // the user is tabbing to. `reconcile` rather than `close`, because
1230
+ // leaving the field is exactly when loose text has to be judged.
1231
+ reconcile();
1232
+ }
1233
+ // Home/End stay with the caret — see the note in {@link Combobox}: this
1234
+ // field is editable, and its text is what `reconcile` judges.
1235
+ }}
1236
+ className={cn(
1237
+ FIELD_BASE,
1238
+ label !== undefined && FIELD_FLOATING_PAD,
1239
+ "pe-9",
1240
+ field.isInvalid && FIELD_INVALID,
1241
+ )}
1242
+ />
1243
+ {showClear ? (
1244
+ <button
1245
+ type="button"
1246
+ // Out of the tab order, like the clear on `EntityCombobox`: the keyboard
1247
+ // already clears this field by selecting its text and deleting, and a
1248
+ // second stop between every picker and the next field is a worse trade
1249
+ // than the one gesture it saves.
1250
+ tabIndex={-1}
1251
+ aria-label={labels.clear}
1252
+ // preventDefault, exactly as the chevron does: without it the press
1253
+ // focuses the input, which on a phone opens the sheet over the field the
1254
+ // press was clearing.
1255
+ onMouseDown={(e) => e.preventDefault()}
1256
+ onClick={() => {
1257
+ setText(null);
1258
+ close();
1259
+ if (!locked) onChange(clearValue);
1260
+ }}
1261
+ className={cn(
1262
+ "absolute end-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-[var(--text-placeholder)]",
1263
+ "hover:text-[var(--text-secondary)]",
1264
+ )}
1265
+ >
1266
+ <X aria-hidden className="size-4" />
1267
+ </button>
1268
+ ) : (
1269
+ <ChevronDown
1270
+ aria-hidden
1271
+ onMouseDown={(e) => {
1272
+ // Toggle on the chevron without stealing focus from the input — and
1273
+ // not at all on a disabled or locked field, which the input's own
1274
+ // `disabled`/`readOnly` does not stop from here.
1275
+ e.preventDefault();
1276
+ if (!inert) setOpen((o) => !o);
1277
+ }}
1278
+ className={cn(
1279
+ "absolute end-2.5 top-1/2 size-4 -translate-y-1/2 text-[var(--text-placeholder)]",
1280
+ // The box above does the dimming; dimming here too would halve it again.
1281
+ !inert && "cursor-pointer",
1282
+ )}
1283
+ />
1022
1284
  )}
1023
- />
1285
+ </div>,
1024
1286
  )}
1025
- </div>
1287
+ </EndHintRow>
1288
+ {label !== undefined && hintParts.labelHint !== undefined && (
1289
+ <StaticLabelRow hint={hintParts.labelHint}>
1290
+ <ComboboxFieldLabel htmlFor={fieldId} className={cn(LABEL_IN_ROW, inert && "opacity-50")}>
1291
+ {label}
1292
+ </ComboboxFieldLabel>
1293
+ </StaticLabelRow>
1294
+ )}
1295
+ <FieldCaption parts={hintParts} />
1026
1296
  {field.errorEl}
1027
1297
  {isPhone && (
1028
1298
  <PickerSheet
1029
- open={open && !disabled}
1299
+ open={open && !inert}
1030
1300
  // Closing without choosing keeps the value: `text` was never emptied, so
1031
1301
  // reconcile has nothing to undo — it just puts the label back.
1032
1302
  onClose={reconcile}
@@ -1060,7 +1330,7 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
1060
1330
  role="option"
1061
1331
  aria-selected={o.value === value}
1062
1332
  aria-disabled={o.disabled || undefined}
1063
- onClick={() => commit(o)}
1333
+ onClick={() => take(o)}
1064
1334
  className={cn(
1065
1335
  SHEET_ROW_CLASS,
1066
1336
  o.value === value && "font-medium",
@@ -1072,13 +1342,34 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
1072
1342
  </li>
1073
1343
  </Fragment>
1074
1344
  ))}
1075
- {matches.length === 0 && (
1345
+ {showCreate && (
1346
+ <li role="presentation">
1347
+ <button
1348
+ type="button"
1349
+ role="option"
1350
+ // An action, never chosen — see `onCreate`.
1351
+ aria-selected={false}
1352
+ aria-disabled={createLocked || undefined}
1353
+ onClick={create}
1354
+ className={cn(
1355
+ SHEET_ROW_CLASS,
1356
+ CREATE_ROW_CLASS,
1357
+ createLocked && DISABLED_ROW_CLASS,
1358
+ )}
1359
+ >
1360
+ {createRowContent}
1361
+ </button>
1362
+ </li>
1363
+ )}
1364
+ {/* "Nothing matched" only when there is nothing to do either: with a create
1365
+ row the list is not empty, it is offering to fill itself. */}
1366
+ {matches.length === 0 && !showCreate && (
1076
1367
  <li className="px-4 py-3 text-sm text-[var(--text-muted)]">{labels.noResults}</li>
1077
1368
  )}
1078
1369
  </ul>
1079
1370
  </PickerSheet>
1080
1371
  )}
1081
- {!isPhone && open && !disabled && matches.length > 0 && (
1372
+ {!isPhone && open && !inert && (matches.length > 0 || showCreate) && (
1082
1373
  <SuggestionList id={listboxId} anchorRef={fieldRef} panelRef={panelRef}>
1083
1374
  {matches.map((o, i) => (
1084
1375
  // A group heading is emitted at each group boundary rather than repeating
@@ -1112,7 +1403,7 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
1112
1403
  // list before the selection registers. Prevented on a disabled
1113
1404
  // row too, so pressing one leaves the field focused and open.
1114
1405
  e.preventDefault();
1115
- commit(o);
1406
+ take(o);
1116
1407
  }}
1117
1408
  onMouseEnter={() => {
1118
1409
  if (!o.disabled) setActive(i);
@@ -1136,6 +1427,36 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
1136
1427
  </li>
1137
1428
  </Fragment>
1138
1429
  ))}
1430
+ {showCreate && (
1431
+ <li role="presentation">
1432
+ <button
1433
+ type="button"
1434
+ id={optionId(createIndex)}
1435
+ role="option"
1436
+ // An action, never chosen; whether the keyboard is ON it is the field's
1437
+ // `aria-activedescendant`'s to say.
1438
+ aria-selected={false}
1439
+ aria-disabled={createLocked || undefined}
1440
+ tabIndex={-1}
1441
+ onMouseDown={(e) => {
1442
+ // mousedown, like the rows above: the input's blur would otherwise
1443
+ // reconcile and close the list before the click landed.
1444
+ e.preventDefault();
1445
+ create();
1446
+ }}
1447
+ onMouseEnter={() => {
1448
+ if (!createLocked) setActive(createIndex);
1449
+ }}
1450
+ className={cn(
1451
+ rowClass(active === createIndex && !createLocked),
1452
+ CREATE_ROW_CLASS,
1453
+ createLocked && DISABLED_ROW_CLASS,
1454
+ )}
1455
+ >
1456
+ {createRowContent}
1457
+ </button>
1458
+ </li>
1459
+ )}
1139
1460
  </SuggestionList>
1140
1461
  )}
1141
1462
  </div>