@eifi1/ui-kit 0.22.0 → 0.24.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 (208) hide show
  1. package/README.md +42 -25
  2. package/dist/chart.d.ts +8 -7
  3. package/dist/components/amount-input.d.ts +34 -7
  4. package/dist/components/amount-input.js +14 -10
  5. package/dist/components/amount-input.js.map +1 -1
  6. package/dist/components/autocomplete.d.ts +26 -0
  7. package/dist/components/autocomplete.js +164 -137
  8. package/dist/components/autocomplete.js.map +1 -1
  9. package/dist/components/button-group.d.ts +8 -7
  10. package/dist/components/calculator.d.ts +8 -7
  11. package/dist/components/column-mapper.d.ts +235 -0
  12. package/dist/components/column-mapper.js +411 -0
  13. package/dist/components/column-mapper.js.map +1 -0
  14. package/dist/components/combobox-core.d.ts +39 -2
  15. package/dist/components/combobox-core.js +29 -6
  16. package/dist/components/combobox-core.js.map +1 -1
  17. package/dist/components/combobox.d.ts +113 -3
  18. package/dist/components/combobox.js +456 -313
  19. package/dist/components/combobox.js.map +1 -1
  20. package/dist/components/confirm-dialog.d.ts +8 -7
  21. package/dist/components/confirm-dialog.js +7 -8
  22. package/dist/components/confirm-dialog.js.map +1 -1
  23. package/dist/components/copy-button.d.ts +5 -4
  24. package/dist/components/country-select.d.ts +50 -7
  25. package/dist/components/country-select.js +65 -9
  26. package/dist/components/country-select.js.map +1 -1
  27. package/dist/components/danger-confirm.d.ts +8 -7
  28. package/dist/components/danger-confirm.js +64 -7
  29. package/dist/components/danger-confirm.js.map +1 -1
  30. package/dist/components/date-picker.d.ts +41 -0
  31. package/dist/components/date-picker.js +269 -8
  32. package/dist/components/date-picker.js.map +1 -1
  33. package/dist/components/entity-combobox.d.ts +48 -2
  34. package/dist/components/entity-combobox.js +159 -114
  35. package/dist/components/entity-combobox.js.map +1 -1
  36. package/dist/components/facing-pair.d.ts +8 -7
  37. package/dist/components/field-parts.d.ts +116 -6
  38. package/dist/components/field-parts.js +84 -0
  39. package/dist/components/field-parts.js.map +1 -1
  40. package/dist/components/field-strip.d.ts +105 -0
  41. package/dist/components/field-strip.js +46 -0
  42. package/dist/components/field-strip.js.map +1 -0
  43. package/dist/components/file-button.d.ts +8 -7
  44. package/dist/components/file-dropzone.d.ts +8 -7
  45. package/dist/components/form-actions.d.ts +8 -7
  46. package/dist/components/form-actions.js +23 -3
  47. package/dist/components/form-actions.js.map +1 -1
  48. package/dist/components/iban-input.d.ts +8 -7
  49. package/dist/components/icon-picker.d.ts +10 -1
  50. package/dist/components/icon-picker.js +12 -4
  51. package/dist/components/icon-picker.js.map +1 -1
  52. package/dist/components/language-select.d.ts +8 -7
  53. package/dist/components/money-field.d.ts +11 -7
  54. package/dist/components/money-field.js.map +1 -1
  55. package/dist/components/month-picker.js +2 -1
  56. package/dist/components/month-picker.js.map +1 -1
  57. package/dist/components/multi-entity-combobox.d.ts +31 -2
  58. package/dist/components/multi-entity-combobox.js +152 -103
  59. package/dist/components/multi-entity-combobox.js.map +1 -1
  60. package/dist/components/number-field.d.ts +8 -7
  61. package/dist/components/number-input.d.ts +8 -7
  62. package/dist/components/numpad-sheet.d.ts +8 -7
  63. package/dist/components/phone-input.d.ts +8 -7
  64. package/dist/components/reauth-dialog.d.ts +2 -2
  65. package/dist/components/reauth-dialog.js +6 -8
  66. package/dist/components/reauth-dialog.js.map +1 -1
  67. package/dist/components/series-chart.d.ts +8 -7
  68. package/dist/components/settings-fields.d.ts +8 -7
  69. package/dist/components/share-card.d.ts +8 -7
  70. package/dist/components/swatch-picker.d.ts +17 -1
  71. package/dist/components/swatch-picker.js +10 -4
  72. package/dist/components/swatch-picker.js.map +1 -1
  73. package/dist/components/text-link.d.ts +8 -7
  74. package/dist/components/tile-radio.d.ts +20 -0
  75. package/dist/components/tile-radio.js +25 -8
  76. package/dist/components/tile-radio.js.map +1 -1
  77. package/dist/components/time-input.d.ts +8 -7
  78. package/dist/components/toggle-group.js +2 -2
  79. package/dist/components/toggle-group.js.map +1 -1
  80. package/dist/components/trigger-aria.d.ts +3 -0
  81. package/dist/components/trigger-aria.js +3 -1
  82. package/dist/components/trigger-aria.js.map +1 -1
  83. package/dist/components/ui.d.ts +5 -4
  84. package/dist/components/ui.js +60 -28
  85. package/dist/components/ui.js.map +1 -1
  86. package/dist/feedback/feedback-attachment.d.ts +66 -1
  87. package/dist/feedback/feedback-attachment.js +185 -65
  88. package/dist/feedback/feedback-attachment.js.map +1 -1
  89. package/dist/feedback/feedback-dialog.d.ts +66 -1
  90. package/dist/feedback/feedback-dialog.js.map +1 -1
  91. package/dist/feedback/feedback-inbox.d.ts +67 -252
  92. package/dist/feedback/feedback-inbox.js.map +1 -1
  93. package/dist/feedback/feedback-thread.d.ts +66 -287
  94. package/dist/feedback/feedback-thread.js +16 -3
  95. package/dist/feedback/feedback-thread.js.map +1 -1
  96. package/dist/{kit-labels-R8mIAc9N.d.ts → feedback-DOwPu-Il.d.ts} +1273 -30
  97. package/dist/feedback.d.ts +65 -3
  98. package/dist/hooks/use-file-drop.d.ts +8 -7
  99. package/dist/i18n/defaults.d.ts +8 -7
  100. package/dist/i18n/defaults.js +3 -1
  101. package/dist/i18n/defaults.js.map +1 -1
  102. package/dist/i18n/german.d.ts +8 -7
  103. package/dist/i18n/german.js +36 -2
  104. package/dist/i18n/german.js.map +1 -1
  105. package/dist/i18n/kit-labels.d.ts +5 -4
  106. package/dist/i18n/kit-labels.js.map +1 -1
  107. package/dist/i18n/languages.d.ts +8 -7
  108. package/dist/i18n/locales/de-CH.d.ts +8 -7
  109. package/dist/i18n/locales/en.d.ts +8 -7
  110. package/dist/i18n/locales/en.js +7 -0
  111. package/dist/i18n/locales/en.js.map +1 -1
  112. package/dist/i18n/locales/es.d.ts +8 -7
  113. package/dist/i18n/locales/es.js +37 -2
  114. package/dist/i18n/locales/es.js.map +1 -1
  115. package/dist/i18n/locales/fr.d.ts +8 -7
  116. package/dist/i18n/locales/fr.js +36 -2
  117. package/dist/i18n/locales/fr.js.map +1 -1
  118. package/dist/i18n/locales/hu.d.ts +8 -7
  119. package/dist/i18n/locales/hu.js +38 -2
  120. package/dist/i18n/locales/hu.js.map +1 -1
  121. package/dist/i18n/locales/it.d.ts +8 -7
  122. package/dist/i18n/locales/it.js +37 -2
  123. package/dist/i18n/locales/it.js.map +1 -1
  124. package/dist/i18n/locales/zh.d.ts +8 -7
  125. package/dist/i18n/locales/zh.js +34 -2
  126. package/dist/i18n/locales/zh.js.map +1 -1
  127. package/dist/i18n/review.d.ts +8 -7
  128. package/dist/i18n/review.js +20 -1
  129. package/dist/i18n/review.js.map +1 -1
  130. package/dist/index.d.ts +6 -4
  131. package/dist/index.js +16 -0
  132. package/dist/index.js.map +1 -1
  133. package/dist/lib/column-mapping.d.ts +81 -0
  134. package/dist/lib/column-mapping.js +108 -0
  135. package/dist/lib/column-mapping.js.map +1 -0
  136. package/dist/lib/strip-fade.d.ts +2 -2
  137. package/dist/lib/strip-fade.js.map +1 -1
  138. package/dist/lib/table-text.d.ts +109 -1
  139. package/dist/lib/table-text.js +122 -1
  140. package/dist/lib/table-text.js.map +1 -1
  141. package/dist/rhf/fields.d.ts +142 -13
  142. package/dist/rhf/fields.js +185 -3
  143. package/dist/rhf/fields.js.map +1 -1
  144. package/dist/rhf/form.d.ts +8 -7
  145. package/dist/rhf.d.ts +9 -8
  146. package/dist/rhf.js.map +1 -1
  147. package/dist/shell/app-shell.d.ts +8 -7
  148. package/dist/shell/top-bar-brand.d.ts +8 -7
  149. package/dist/shell/topbar-action-menu.d.ts +36 -3
  150. package/dist/shell/topbar-action-menu.js +74 -33
  151. package/dist/shell/topbar-action-menu.js.map +1 -1
  152. package/dist/shell.d.ts +8 -7
  153. package/dist/table-text.d.ts +1 -1
  154. package/dist/wizard/stepper-nav.d.ts +38 -9
  155. package/dist/wizard/stepper-nav.js +4 -0
  156. package/dist/wizard/stepper-nav.js.map +1 -1
  157. package/dist/wizard.d.ts +8 -7
  158. package/package.json +1 -1
  159. package/src/components/amount-input.tsx +50 -14
  160. package/src/components/autocomplete.tsx +188 -116
  161. package/src/components/column-mapper.tsx +768 -0
  162. package/src/components/combobox-core.tsx +82 -9
  163. package/src/components/combobox.tsx +655 -335
  164. package/src/components/confirm-dialog.tsx +12 -8
  165. package/src/components/country-select.tsx +135 -22
  166. package/src/components/danger-confirm.tsx +274 -24
  167. package/src/components/date-picker.tsx +423 -10
  168. package/src/components/entity-combobox.tsx +243 -129
  169. package/src/components/field-parts.tsx +224 -5
  170. package/src/components/field-strip.tsx +149 -0
  171. package/src/components/form-actions.tsx +52 -5
  172. package/src/components/icon-picker.tsx +23 -4
  173. package/src/components/money-field.tsx +3 -0
  174. package/src/components/month-picker.tsx +2 -1
  175. package/src/components/multi-entity-combobox.tsx +221 -121
  176. package/src/components/reauth-dialog.tsx +17 -18
  177. package/src/components/swatch-picker.tsx +29 -5
  178. package/src/components/tile-radio.tsx +74 -13
  179. package/src/components/toggle-group.tsx +2 -2
  180. package/src/components/trigger-aria.ts +5 -0
  181. package/src/components/ui.tsx +114 -33
  182. package/src/feedback/feedback-attachment.tsx +372 -72
  183. package/src/feedback/feedback-dialog.tsx +7 -3
  184. package/src/feedback/feedback-inbox.tsx +3 -2
  185. package/src/feedback/feedback-thread.tsx +112 -9
  186. package/src/i18n/defaults.ts +2 -0
  187. package/src/i18n/german.ts +42 -0
  188. package/src/i18n/kit-labels.tsx +4 -0
  189. package/src/i18n/locales/en.ts +27 -5
  190. package/src/i18n/locales/es.ts +41 -0
  191. package/src/i18n/locales/fr.ts +42 -0
  192. package/src/i18n/locales/hu.ts +37 -0
  193. package/src/i18n/locales/it.ts +41 -0
  194. package/src/i18n/locales/zh.ts +32 -0
  195. package/src/i18n/review.ts +19 -0
  196. package/src/index.ts +18 -0
  197. package/src/lib/column-mapping.ts +234 -0
  198. package/src/lib/strip-fade.ts +2 -2
  199. package/src/lib/table-text.ts +271 -0
  200. package/src/rhf/fields.tsx +421 -6
  201. package/src/rhf.ts +2 -1
  202. package/src/shell/topbar-action-menu.tsx +134 -38
  203. package/src/wizard/stepper-nav.tsx +35 -1
  204. package/dist/components/field-anatomy.d.ts +0 -95
  205. package/dist/components/field-anatomy.js +0 -84
  206. package/dist/components/field-anatomy.js.map +0 -1
  207. package/dist/feedback-attachment-NhUm7Zga.d.ts +0 -367
  208. package/src/components/field-anatomy.tsx +0 -190
@@ -1,7 +1,7 @@
1
1
  import { Fragment, useId, useMemo, useRef, useState } from "react";
2
- import type { ComponentPropsWithoutRef, ReactNode, RefObject } from "react";
2
+ import type { ComponentPropsWithoutRef, ReactNode, Ref, 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,7 +24,17 @@ import {
22
24
  type ComboOption,
23
25
  } from "./combobox-core";
24
26
  import { DEFAULT_COMBOBOX_LABELS, useKitLabels } from "../i18n/kit-labels";
25
- import { EndHintRow, FieldCaption, LABEL_IN_ROW, StaticLabelRow, useFieldHint } from "./field-parts";
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";
26
38
 
27
39
  // One look for both combobox flavors below — the suggestion list and its rows
28
40
  // must stay pixel-identical between the free-text and the id-keyed variant (and
@@ -124,6 +136,27 @@ function SuggestionList({
124
136
 
125
137
  const rowClass = suggestionRowClass;
126
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
+
127
160
  /**
128
161
  * Four of the div's own attributes are omitted because this component already owns
129
162
  * the name, with a different meaning: `id` is the INPUT's (a caller labels or
@@ -163,6 +196,18 @@ export interface ComboboxProps
163
196
  * stops toggling with it — it is a mouse target the input's own `disabled` does
164
197
  * not reach. */
165
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;
166
211
  /** Heading an option belongs under. Supplying it makes this list read exactly
167
212
  * like {@link InlineEntityCombobox}'s — one heading per group with its rows
168
213
  * indented beneath — instead of a flat list (feedback #136 rework: the payee
@@ -199,6 +244,14 @@ export interface ComboboxProps
199
244
  maxSuggestions?: number;
200
245
  searchPlaceholder?: string;
201
246
  closeLabel?: string;
247
+ /**
248
+ * The phone sheet's heading — and, as a string, the dialog's accessible name — for a
249
+ * field whose label is drawn by someone else: a form's `FormLabel` above it,
250
+ * which `RhfTextCombobox` hands over here (0.24). Default: `label`. Without either, the
251
+ * full-screen sheet opened untitled and unnamed, the one screen on a phone that has to
252
+ * say what it is asking for (live #200).
253
+ */
254
+ sheetTitle?: ReactNode;
202
255
  /**
203
256
  * Label for the row that COMMITS a value the list does not contain — e.g.
204
257
  * `(v) => \`Add "${v}" as payee\``. Supplying it turns the free text into
@@ -215,6 +268,15 @@ export interface ComboboxProps
215
268
  * values are all supposed to already exist.
216
269
  */
217
270
  createLabel?: (value: string) => string;
271
+ /**
272
+ * The `<input>` — the control a reader types in and the one focus belongs on — as a
273
+ * React 19 ref prop, the way {@link CountrySelect} takes its trigger's (0.24, kastlan:
274
+ * react-hook-form's focus-on-error calls `focus()` on whatever `field.ref` is handed,
275
+ * and a combobox with no ref inside a hand-wired `RhfField` gave it nothing, so a
276
+ * failed submit could not take the user to it). Not the wrapper: a ref is for focus,
277
+ * measurement and selection, and all three are the input's. `RhfTextCombobox` passes it.
278
+ */
279
+ ref?: Ref<HTMLInputElement>;
218
280
  }
219
281
 
220
282
  /**
@@ -227,36 +289,41 @@ export interface ComboboxProps
227
289
  * visually and behaviourally consistent with the other dropdowns (feedback
228
290
  * #230 — the payee field looked/behaved differently from every other select).
229
291
  */
230
- export function Combobox({
231
- value,
232
- onChange,
233
- options,
234
- label,
235
- id,
236
- placeholder,
237
- className,
238
- groupBy,
239
- maxSuggestions,
240
- searchPlaceholder,
241
- closeLabel,
242
- createLabel,
243
- invalid,
244
- error,
245
- hint,
246
- disabled,
247
- optionAdornment,
248
- autoFocus,
249
- onBlur,
250
- onSubmit,
251
- "aria-label": ariaLabel,
252
- // Off `rest` and onto the <input>, which is the combobox a reader meets: `Field`'s
253
- // render-prop spreads `{ id, aria-describedby, aria-invalid, aria-required }`, and
254
- // on the wrapper div the hint and required state described nothing.
255
- "aria-describedby": ariaDescribedBy,
256
- "aria-invalid": ariaInvalid,
257
- "aria-required": ariaRequired,
258
- ...rest
259
- }: ComboboxProps) {
292
+ export function Combobox(props: ComboboxProps) {
293
+ const {
294
+ value,
295
+ onChange,
296
+ options,
297
+ label,
298
+ id,
299
+ placeholder,
300
+ className,
301
+ groupBy,
302
+ maxSuggestions,
303
+ searchPlaceholder,
304
+ closeLabel,
305
+ sheetTitle,
306
+ createLabel,
307
+ invalid,
308
+ error,
309
+ hint,
310
+ disabled,
311
+ disabledReason,
312
+ commit,
313
+ optionAdornment,
314
+ autoFocus,
315
+ onBlur,
316
+ onSubmit,
317
+ ref,
318
+ "aria-label": ariaLabel,
319
+ // Off `rest` and onto the <input>, which is the combobox a reader meets: `Field`'s
320
+ // render-prop spreads `{ id, aria-describedby, aria-invalid, aria-required }`, and
321
+ // on the wrapper div the hint and required state described nothing.
322
+ "aria-describedby": ariaDescribedBy,
323
+ "aria-invalid": ariaInvalid,
324
+ "aria-required": ariaRequired,
325
+ ...rest
326
+ } = props;
260
327
  const hintParts = useFieldHint(hint, ariaDescribedBy);
261
328
  const field = useComboboxFieldError(
262
329
  error,
@@ -276,6 +343,12 @@ export function Combobox({
276
343
  const { open, setOpen, wrapperRef, panelRef } = useDropdown({ backCloses: !isPhone });
277
344
  const primaryOnly = usePrimaryPressOnly();
278
345
  const [active, setActive] = useState(-1);
346
+ // The lock (see `lockedKeyDown`): a lock arriving while the list is up closes it,
347
+ // adjusted while rendering so no frame offers a row that can no longer be taken.
348
+ const lock = useLockReason(commit, disabledReason);
349
+ const locked = lock.locked;
350
+ const inert = locked || Boolean(disabled);
351
+ if (locked && open) setOpen(false);
279
352
  // A phone opens the list as a full-screen sheet with its own input, the way a
280
353
  // native <select> does — live #200: "Paid as full screen dialog with input.
281
354
  // Similar to the account select that already appears as full screen." The
@@ -361,7 +434,8 @@ export function Combobox({
361
434
  return heads;
362
435
  }, [matches, groupBy]);
363
436
 
364
- const commit = (v: string) => {
437
+ const take = (v: string) => {
438
+ if (locked) return;
365
439
  onChange(v);
366
440
  setOpen(false);
367
441
  setActive(-1);
@@ -381,6 +455,17 @@ export function Combobox({
381
455
  const activeId = !isPhone && active >= 0 && active < matches.length ? optionId(active) : undefined;
382
456
  useActiveOptionScroll(activeId);
383
457
 
458
+ // While locked the field sits in the reason's Tooltip — in a fragment with a hidden
459
+ // copy of the reason, Button's anatomy; `block` so a full-width field stays full width.
460
+ const withLock = (box: ReactNode) =>
461
+ locked ? (
462
+ <LockedReason lock={lock} className="block">
463
+ {box}
464
+ </LockedReason>
465
+ ) : (
466
+ box
467
+ );
468
+
384
469
  const typed = value.trim();
385
470
  const createRow =
386
471
  createLabel && typed.length > 0 && !options.some((o) => o.toLowerCase() === typed.toLowerCase())
@@ -398,139 +483,149 @@ export function Combobox({
398
483
  name a reader hears is the same words either way. With a "?" it moves into a
399
484
  row after the field, below. */}
400
485
  {label !== undefined && hintParts.labelHint === undefined && (
401
- <ComboboxFieldLabel htmlFor={fieldId} className={disabled ? "opacity-50" : undefined}>
486
+ <ComboboxFieldLabel htmlFor={fieldId} className={inert ? "opacity-50" : undefined}>
402
487
  {label}
403
488
  </ComboboxFieldLabel>
404
489
  )}
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;
445
- setOpen(true);
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();
490
+ <EndHintRow {...endHintRowProps("hint" in props, label !== undefined, hintParts.labelHint)}>
491
+ {withLock(
492
+ /* The chevron centers against this inner wrapper, which hugs the input.
493
+ The outer div can be taller than the input (as a grid item it
494
+ stretches to the row height, e.g. next to the editor's category cell
495
+ with its split button), which used to drag a top-1/2 chevron down to
496
+ the input's bottom edge (feedback #248).
497
+ Dimmed as a whole when disabled (or locked), the way EntityCombobox dims its
498
+ trigger. FIELD_BASE's grey alone left a disabled picker looking like a
499
+ filled-in one beside the pickers that do dim (the label is not the input's
500
+ `peer`, so it is dimmed by hand above). */
501
+ <div ref={fieldRef} className={cn("relative", inert && "cursor-not-allowed opacity-50")}>
502
+ <input
503
+ ref={ref}
504
+ id={fieldId}
505
+ value={value}
506
+ placeholder={placeholder}
507
+ // The caller's name, over the <label for> above — which is what names the
508
+ // field otherwise (dev#477: a field with neither announces only its text).
509
+ aria-label={ariaLabel}
510
+ role="combobox"
511
+ aria-expanded={open}
512
+ // The list this field is the mouth of. Required by the role, and the half
513
+ // that was missing: the field said it was expanded and never said what it
514
+ // had expanded, so a reader had no way from the box to the options
515
+ // (ESLint's `role-has-required-aria-props`, the audit's §a11y).
516
+ aria-controls={listboxId}
517
+ aria-activedescendant={activeId}
518
+ aria-autocomplete="list"
519
+ aria-invalid={field.isInvalid || undefined}
520
+ aria-describedby={mergeDescribedBy(field.describedBy, locked && lock.reasonId)}
521
+ aria-required={ariaRequired}
522
+ autoComplete="off"
523
+ // Locked: focusable, `readOnly`, `aria-disabled` — see `lockedKeyDown`.
524
+ disabled={locked ? undefined : disabled}
525
+ readOnly={locked || undefined}
526
+ aria-disabled={locked || undefined}
527
+ // 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.
528
+ autoFocus={autoFocus}
529
+ onBlur={onBlur}
530
+ onMouseDown={primaryOnly.onMouseDown}
531
+ onFocus={() => {
532
+ // A back/forward mouse button focuses this field on its way to
533
+ // navigating; it is not a request to open anything (live #309 rework).
534
+ // A locked field takes focus to say why, and opens nothing.
535
+ if (primaryOnly.fromAuxButton() || locked) return;
474
536
  setOpen(true);
475
- setActive((i) => Math.min(i + 1, matches.length - 1));
476
- } else if (e.key === "ArrowUp") {
477
- e.preventDefault();
478
- setActive((i) => Math.max(i - 1, 0));
479
- } else if (e.key === "Enter") {
480
- if (open && active >= 0 && active < matches.length) {
537
+ setTyping(false);
538
+ // The sheet carries its own input, so the field behind it must not also
539
+ // pull up the keyboard and scroll the page under the dialog.
540
+ if (isPhone) sheetInputRef.current?.focus();
541
+ }}
542
+ // A CLICK as well as focus — the other half of dev#549. Picking a suggestion
543
+ // closes the list without moving focus (the rows suppress `mousedown` on
544
+ // purpose, so the input never blurred), which means clicking the field again
545
+ // fires no `focus` event at all and the list stayed shut. "Does not open
546
+ // anything" was literally true.
547
+ onClick={() => {
548
+ if (locked) return;
549
+ setOpen(true);
550
+ setTyping(false);
551
+ }}
552
+ // `inputMode="none"` rather than readOnly: the field must not look
553
+ // uneditable (FIELD_BASE greys a read-only field since dev#468) and must
554
+ // still take focus — it just has no keyboard of its own, the same trick
555
+ // the amount field uses for the numpad.
556
+ inputMode={isPhone ? "none" : undefined}
557
+ onChange={(e) => {
558
+ if (locked) return;
559
+ onChange(e.target.value);
560
+ setOpen(true);
561
+ setActive(-1);
562
+ setTyping(true);
563
+ }}
564
+ onKeyDown={(e) => {
565
+ if (locked) return lockedKeyDown(e);
566
+ if (e.key === "ArrowDown") {
567
+ e.preventDefault();
568
+ setOpen(true);
569
+ setActive((i) => Math.min(i + 1, matches.length - 1));
570
+ } else if (e.key === "ArrowUp") {
481
571
  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();
572
+ setActive((i) => Math.max(i - 1, 0));
573
+ } else if (e.key === "Enter") {
574
+ if (open && active >= 0 && active < matches.length) {
575
+ e.preventDefault();
576
+ take(matches[active]);
577
+ } else {
578
+ // No row highlighted: the typed text is the answer. `preventDefault`
579
+ // only when a caller is taking Enter, so a plain form submit is
580
+ // otherwise left alone.
581
+ if (onSubmit) e.preventDefault();
582
+ setOpen(false);
583
+ onSubmit?.();
584
+ }
585
+ } else if (e.key === "Escape") {
586
+ setOpen(false);
587
+ setActive(-1);
588
+ } else if (e.key === "Tab") {
589
+ // Closes, and lets the browser take the Tab: focus is in THIS input,
590
+ // which is staying, so there is nothing to catch — unlike the pickers
591
+ // whose focus sits inside a portalled panel.
488
592
  setOpen(false);
489
- onSubmit?.();
593
+ setActive(-1);
490
594
  }
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.
498
- setOpen(false);
499
- setActive(-1);
500
- }
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>
595
+ // Home/End are deliberately absent. The APG gives them to the list only
596
+ // where the combobox is not editable; here the text IS the value, a
597
+ // payee name is long enough to want the caret moved to its start, and
598
+ // the desktop list is capped at 8 rows — so jumping it would be worth
599
+ // almost nothing and would cost the one gesture that field is used with.
600
+ }}
601
+ className={cn(
602
+ FIELD_BASE,
603
+ label !== undefined && FIELD_FLOATING_PAD,
604
+ "pe-9",
605
+ field.isInvalid && FIELD_INVALID,
606
+ )}
607
+ />
608
+ <ChevronDown
609
+ aria-hidden
610
+ onMouseDown={(e) => {
611
+ // Toggle on the chevron without stealing focus from the input.
612
+ e.preventDefault();
613
+ if (!inert) setOpen((o) => !o);
614
+ }}
615
+ className={cn(
616
+ "absolute end-2.5 top-1/2 size-4 -translate-y-1/2 text-[var(--text-placeholder)]",
617
+ // The box above does the dimming; dimming here too would halve it again.
618
+ !inert && "cursor-pointer",
619
+ )}
620
+ />
621
+ </div>,
622
+ )}
528
623
  </EndHintRow>
529
624
  {/* The label and its "?" share the top strip — after the field in the DOM, so the
530
625
  "?" follows the control in the tab order, as it does on Input and Select. */}
531
626
  {label !== undefined && hintParts.labelHint !== undefined && (
532
627
  <StaticLabelRow hint={hintParts.labelHint}>
533
- <ComboboxFieldLabel htmlFor={fieldId} className={cn(LABEL_IN_ROW, disabled && "opacity-50")}>
628
+ <ComboboxFieldLabel htmlFor={fieldId} className={cn(LABEL_IN_ROW, inert && "opacity-50")}>
534
629
  {label}
535
630
  </ComboboxFieldLabel>
536
631
  </StaticLabelRow>
@@ -539,9 +634,9 @@ export function Combobox({
539
634
  {field.errorEl}
540
635
  {isPhone && (
541
636
  <PickerSheet
542
- open={open && !disabled}
637
+ open={open && !inert}
543
638
  onClose={() => setOpen(false)}
544
- title={label}
639
+ title={sheetTitle ?? label}
545
640
  // The sheet's input IS the field: this is a free-text control, so what is
546
641
  // typed here is the value (a brand-new payee is just a name nothing
547
642
  // matches), and the list below narrows as it changes.
@@ -564,7 +659,7 @@ export function Combobox({
564
659
  type="button"
565
660
  role="option"
566
661
  aria-selected={false}
567
- onClick={() => commit(typed)}
662
+ onClick={() => take(typed)}
568
663
  className={cn(SHEET_ROW_CLASS, "font-medium text-[var(--brand)]")}
569
664
  >
570
665
  {createRow}
@@ -588,7 +683,7 @@ export function Combobox({
588
683
  type="button"
589
684
  role="option"
590
685
  aria-selected={o === value}
591
- onClick={() => commit(o)}
686
+ onClick={() => take(o)}
592
687
  className={cn(
593
688
  SHEET_ROW_CLASS,
594
689
  optionAdornment && "flex items-center justify-between gap-2",
@@ -604,7 +699,7 @@ export function Combobox({
604
699
  </ul>
605
700
  </PickerSheet>
606
701
  )}
607
- {!isPhone && open && !disabled && (matches.length > 0 || createRow) && (
702
+ {!isPhone && open && !inert && (matches.length > 0 || createRow) && (
608
703
  <SuggestionList id={listboxId} anchorRef={fieldRef} panelRef={panelRef}>
609
704
  {createRow && (
610
705
  <li role="presentation">
@@ -617,7 +712,7 @@ export function Combobox({
617
712
  // mousedown, like the rows below: the input's blur would otherwise
618
713
  // close the list before the click landed.
619
714
  e.preventDefault();
620
- commit(typed);
715
+ take(typed);
621
716
  }}
622
717
  className={cn(rowClass(false), "font-medium text-[var(--brand)]")}
623
718
  >
@@ -657,7 +752,7 @@ export function Combobox({
657
752
  // mousedown (not click) so the blur from the input firing first
658
753
  // doesn't close the list before the selection registers.
659
754
  e.preventDefault();
660
- commit(o);
755
+ take(o);
661
756
  }}
662
757
  onMouseEnter={() => setActive(i)}
663
758
  className={cn(
@@ -719,6 +814,11 @@ export interface InlineEntityComboboxProps<V extends string | number, C extends
719
814
  * list simply does not open. Default: `combobox.noResults` from the provider. */
720
815
  emptyLabel?: string;
721
816
  closeLabel?: string;
817
+ /** The phone sheet's heading (and, as a string, its accessible name) for a field
818
+ * whose label is drawn by someone else — a form's `FormLabel`;
819
+ * `RhfInlineEntityCombobox` hands its label over here (0.24). Default: `label`.
820
+ * See {@link Combobox}'s `sheetTitle`. */
821
+ sheetTitle?: ReactNode;
722
822
  /** Offer a clear "×" in place of the chevron whenever something is selected.
723
823
  *
724
824
  * Emptying the text already clears (see {@link reconcile}), and on a desktop that
@@ -729,6 +829,82 @@ export interface InlineEntityComboboxProps<V extends string | number, C extends
729
829
  * affordance both shells share, and it clears without opening anything. */
730
830
  clearable?: boolean;
731
831
  clearLabel?: string;
832
+ /**
833
+ * Why the choice cannot be changed — {@link Button}'s `disabledReason`, for a picker
834
+ * that SAVES on change. keksdose G2: the statement review's account picker IS the
835
+ * write (picking re-runs the duplicate check on the server), and on the read-only demo
836
+ * the app wrapped it in a hand-made Tooltip over a native `disabled` — out of the tab
837
+ * order, so the reason never reached a keyboard.
838
+ *
839
+ * With a reason the input stays focusable and `aria-disabled`, is `readOnly` (the
840
+ * settled grey), opens no list or sheet, offers no clear "×", and no pick, typed
841
+ * label or clear reaches `onChange`; the reason is in the kit {@link Tooltip} and on
842
+ * the input's `aria-describedby`. Wins over `disabled`. Use it CONTROLLED, as the
843
+ * component always is: the shown label is `value`'s.
844
+ */
845
+ disabledReason?: ReactNode;
846
+ /**
847
+ * This picker COMMITS — choosing saves. Under a locked {@link WriteLockProvider} it is
848
+ * locked the `disabledReason` way with the lock's reason (which wins over its own).
849
+ * A picker inside a form with its own Save stays editable under the lock — leave this
850
+ * off there and put `commit` on the Save. No provider, or an unlocked one: no effect.
851
+ */
852
+ commit?: boolean;
853
+ /**
854
+ * A last row that makes a new record from what was typed — keksdose G9, whose "Create
855
+ * cash account" had to become a separate button because this picker had no such row.
856
+ *
857
+ * Shown while the typed query is non-empty and names no option exactly
858
+ * (case-insensitive — an exact hit is a record the list already shows), worded
859
+ * `createLabel(query)`, default `combobox.create`: "Create “{query}”". The arrow keys
860
+ * reach it like an option; Enter or a click calls `onCreate(query)` and closes the
861
+ * list. Making the record, and then passing its id as `value`, is the caller's: the
862
+ * field shows the old selection until it does.
863
+ *
864
+ * The same rule as {@link EntityCombobox}'s `onCreate`, so the family agrees. It is a
865
+ * row of the LIST, not an option: it is never `aria-selected`, never matched by a
866
+ * typed label on blur, and never sorted among the records the way a sentinel option
867
+ * would be (the reason keksdose gave for the separate button).
868
+ */
869
+ onCreate?: (query: string) => void;
870
+ /** The create row's words for a query. Default: `combobox.create` from the
871
+ * {@link UiKitProvider}, else English `Create “{query}”`. */
872
+ createLabel?: (query: string) => string;
873
+ /**
874
+ * Offer the create row with NOTHING typed too, worded as this — keksdose's "Create cash
875
+ * account", which mints an account under a default name and so needs no query.
876
+ * `onCreate` then receives `""`, and the caller supplies the name. Left out, the row
877
+ * needs a query.
878
+ *
879
+ * One string rather than a switch: a row with no query has nothing to quote, so the
880
+ * kit cannot word it — "Create “”" says nothing — and naming it is what turns it on.
881
+ * With both, one handler serves both rows:
882
+ *
883
+ * ```tsx
884
+ * onCreate={(name) => createCash(name || t("cash_account_default_name"))}
885
+ * createEmptyLabel={t("create_cash_account")}
886
+ * ```
887
+ */
888
+ createEmptyLabel?: string;
889
+ /**
890
+ * The create row WRITES while picking does not — keksdose dev#496: on the read-only
891
+ * demo choosing the cash account is draft state and stays live, minting one is a
892
+ * write. Under a locked {@link WriteLockProvider} the row stays in the list, dimmed and
893
+ * passed over by the arrows, with the lock's reason as its second line (a hover is the
894
+ * one explanation a phone cannot show), and `onCreate` is not called. Not needed with
895
+ * `commit`: a locked field opens no list at all.
896
+ */
897
+ createCommit?: boolean;
898
+ /**
899
+ * The `<input>` — the focusable control, the one a reader types in — as a React 19
900
+ * ref prop (0.24, kastlan). Its account picker is this component inside an
901
+ * `RhfField` render, and react-hook-form's focus-on-error calls `focus()` on whatever
902
+ * `field.ref` is handed: with no ref there was nothing to hand it, so a line-item form
903
+ * whose account was missing failed its submit and left the caret where it was. Pass
904
+ * `field.ref` here, or use `RhfInlineEntityCombobox`, which does. The wrapper is not
905
+ * reachable this way — focus and selection are the input's. See {@link Combobox}'s `ref`.
906
+ */
907
+ ref?: Ref<HTMLInputElement>;
732
908
  }
733
909
 
734
910
  /**
@@ -749,34 +925,45 @@ export interface InlineEntityComboboxProps<V extends string | number, C extends
749
925
  * sheet that emptied it to show the full list would read as "the user cleared the
750
926
  * field" the moment it closed.
751
927
  */
752
- export function InlineEntityCombobox<V extends string | number, C extends ComboClearValue = null>({
753
- value: rawValue,
754
- onChange,
755
- clearValue = null as C,
756
- options,
757
- label,
758
- id,
759
- placeholder,
760
- className,
761
- disabled,
762
- autoFocus,
763
- searchPlaceholder,
764
- emptyLabel,
765
- closeLabel,
766
- clearable,
767
- clearLabel,
768
- invalid,
769
- error,
770
- hint,
771
- "aria-label": ariaLabel,
772
- // Off `rest` and onto the <input>, which is the combobox a reader meets: `Field`'s
773
- // render-prop spreads `{ id, aria-describedby, aria-invalid, aria-required }`, and
774
- // on the wrapper div the hint and required state described nothing.
775
- "aria-describedby": ariaDescribedBy,
776
- "aria-invalid": ariaInvalid,
777
- "aria-required": ariaRequired,
778
- ...rest
779
- }: InlineEntityComboboxProps<V, C>) {
928
+ export function InlineEntityCombobox<V extends string | number, C extends ComboClearValue = null>(
929
+ props: InlineEntityComboboxProps<V, C>,
930
+ ) {
931
+ const {
932
+ value: rawValue,
933
+ onChange,
934
+ clearValue = null as C,
935
+ options,
936
+ label,
937
+ id,
938
+ placeholder,
939
+ className,
940
+ disabled,
941
+ autoFocus,
942
+ searchPlaceholder,
943
+ emptyLabel,
944
+ closeLabel,
945
+ sheetTitle,
946
+ clearable,
947
+ clearLabel,
948
+ disabledReason,
949
+ commit,
950
+ onCreate,
951
+ createLabel,
952
+ createEmptyLabel,
953
+ createCommit,
954
+ invalid,
955
+ error,
956
+ hint,
957
+ ref,
958
+ "aria-label": ariaLabel,
959
+ // Off `rest` and onto the <input>, which is the combobox a reader meets: `Field`'s
960
+ // render-prop spreads `{ id, aria-describedby, aria-invalid, aria-required }`, and
961
+ // on the wrapper div the hint and required state described nothing.
962
+ "aria-describedby": ariaDescribedBy,
963
+ "aria-invalid": ariaInvalid,
964
+ "aria-required": ariaRequired,
965
+ ...rest
966
+ } = props;
780
967
  // The clear value reads as "nothing selected", whichever one the caller picked —
781
968
  // so from here down `value` is the id or `null`, as it always was.
782
969
  const value: V | null = rawValue == null || rawValue === clearValue ? null : (rawValue as V);
@@ -812,7 +999,21 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
812
999
  search: searchPlaceholder,
813
1000
  noResults: emptyLabel,
814
1001
  clear: clearLabel,
1002
+ create: createLabel,
815
1003
  });
1004
+ // The lock (see `lockedKeyDown` above). A lock arriving mid-edit closes the list and
1005
+ // drops the loose text, adjusted while rendering: nothing typed before it may be
1006
+ // judged by `reconcile` after it.
1007
+ const lock = useLockReason(commit, disabledReason);
1008
+ const locked = lock.locked;
1009
+ const inert = locked || Boolean(disabled);
1010
+ if (locked && (open || text !== null)) {
1011
+ setOpen(false);
1012
+ setText(null);
1013
+ }
1014
+ // The create row's own lock (`createCommit`): the list stays live, the row does not.
1015
+ const createReason = useCommitReason(createCommit, undefined);
1016
+ const createLocked = hasMessage(createReason);
816
1017
 
817
1018
  const selected = useMemo(
818
1019
  () => (value == null ? null : (options.find((o) => o.value === value) ?? null)),
@@ -821,7 +1022,7 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
821
1022
  const shown = text ?? selected?.label ?? "";
822
1023
  // Nothing selected has nothing to clear, and the chevron comes back — the field
823
1024
  // keeps exactly one trailing control, so the "×" never crowds the value it sits on.
824
- const showClear = Boolean(clearable && value != null && !disabled);
1025
+ const showClear = Boolean(clearable && value != null && !inert);
825
1026
 
826
1027
  // Focusing select-alls the current label; filtering only kicks in once the
827
1028
  // text actually differs from it, so an already-filled field still opens on
@@ -855,11 +1056,35 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
855
1056
  return [...blocks.values()].flat();
856
1057
  }, [options, query]);
857
1058
 
1059
+ // The create row (`onCreate`), on the query as typed — not lowercased: it becomes a
1060
+ // name. Compared against EVERY option, not `matches`: an exact hit anywhere is a
1061
+ // record that exists, disabled or not. Restated from `text` rather than derived from
1062
+ // `typedQuery`: this value is handed to the caller's `onCreate`, and the React
1063
+ // Compiler cannot then keep `matches` memoised on anything it was derived from.
1064
+ const createQuery = isPhone
1065
+ ? sheetQuery.trim()
1066
+ : text !== null && text !== (selected?.label ?? "")
1067
+ ? text.trim()
1068
+ : "";
1069
+ const showCreate = offersCreate(
1070
+ onCreate,
1071
+ createQuery,
1072
+ options.map((o) => o.label),
1073
+ createEmptyLabel,
1074
+ );
1075
+ const createText = createQuery ? labels.create(createQuery) : createEmptyLabel;
1076
+ // Every row the keyboard can land on: the options, then the create row — passed over
1077
+ // like a disabled option while a write lock holds it.
1078
+ const rows: readonly { disabled?: boolean }[] = showCreate
1079
+ ? [...matches, { disabled: createLocked }]
1080
+ : matches;
1081
+ const createIndex = matches.length;
1082
+
858
1083
  // Pointer-device only: the phone's rows live in a {@link PickerSheet} whose own
859
1084
  // search box holds focus, so the field behind it must not claim to be pointing at
860
1085
  // one of them.
861
1086
  // A disabled row is never the keyboard's, even if the list changed under it.
862
- const activeId = !isPhone && isOptionEnabled(matches[active]) ? optionId(active) : undefined;
1087
+ const activeId = !isPhone && isOptionEnabled(rows[active]) ? optionId(active) : undefined;
863
1088
  useActiveOptionScroll(activeId);
864
1089
 
865
1090
  const close = () => {
@@ -867,9 +1092,10 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
867
1092
  setActive(-1);
868
1093
  setSheetQuery("");
869
1094
  };
870
- const commit = (o: ComboOption<V>) => {
871
- // A `disabled` option is listed, never taken — by any path.
872
- if (o.disabled) return;
1095
+ const take = (o: ComboOption<V>) => {
1096
+ // A `disabled` option is listed, never taken — by any path; nor is anything while
1097
+ // the field is locked.
1098
+ if (o.disabled || locked) return;
873
1099
  if (o.value !== value) onChange(o.value);
874
1100
  setText(null);
875
1101
  close();
@@ -884,8 +1110,15 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
884
1110
  * used, then tabbing away, silently discarded it, and the more categories you used
885
1111
  * the more of them stopped working. Two rows naming the same id are not an ambiguity;
886
1112
  * two ids sharing a label are. */
1113
+ const create = () => {
1114
+ if (!onCreate || locked || createLocked) return;
1115
+ onCreate(createQuery);
1116
+ setText(null);
1117
+ close();
1118
+ };
887
1119
  const reconcile = () => {
888
- if (text !== null) {
1120
+ // Locked, loose text is dropped rather than judged: nothing reaches `onChange`.
1121
+ if (text !== null && !locked) {
889
1122
  const q = text.trim();
890
1123
  if (!q) {
891
1124
  if (value != null) onChange(clearValue);
@@ -898,11 +1131,36 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
898
1131
  const ids = new Set(hits.map((h) => h.value));
899
1132
  if (ids.size === 1 && hits[0].value !== value) onChange(hits[0].value);
900
1133
  }
901
- setText(null);
902
1134
  }
1135
+ setText(null);
903
1136
  close();
904
1137
  };
905
1138
 
1139
+ // The create row's inside, one for both shells: a "+" (it is an action, not a record),
1140
+ // the words, and — while a write lock holds it — the lock's reason as a second line.
1141
+ const createRowContent = (
1142
+ <>
1143
+ <Plus aria-hidden className="size-4 shrink-0" />
1144
+ <span className="min-w-0 flex-1">
1145
+ <span className="block truncate">{createText}</span>
1146
+ {createLocked && (
1147
+ <span className="block truncate text-xs font-normal text-[var(--text-placeholder)]">
1148
+ {createReason}
1149
+ </span>
1150
+ )}
1151
+ </span>
1152
+ </>
1153
+ );
1154
+ // While locked the field sits in the reason's Tooltip — see Combobox.
1155
+ const withLock = (box: ReactNode) =>
1156
+ locked ? (
1157
+ <LockedReason lock={lock} className="block">
1158
+ {box}
1159
+ </LockedReason>
1160
+ ) : (
1161
+ box
1162
+ );
1163
+
906
1164
  return (
907
1165
  // `rest` dresses the outer box — a `data-tour` anchor, a test id. Not the NAME,
908
1166
  // description, invalid or required state: those belong on the <input> below,
@@ -911,152 +1169,163 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
911
1169
  <div {...rest} ref={wrapperRef} className={cn("relative", className)}>
912
1170
  {/* A real <label for> — see {@link Combobox}; with a "?", in a row after the field. */}
913
1171
  {label !== undefined && hintParts.labelHint === undefined && (
914
- <ComboboxFieldLabel htmlFor={fieldId} className={disabled ? "opacity-50" : undefined}>
1172
+ <ComboboxFieldLabel htmlFor={fieldId} className={inert ? "opacity-50" : undefined}>
915
1173
  {label}
916
1174
  </ComboboxFieldLabel>
917
1175
  )}
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();
961
- setOpen(true);
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();
1176
+ <EndHintRow {...endHintRowProps("hint" in props, label !== undefined, hintParts.labelHint)}>
1177
+ {withLock(
1178
+ /* Inner wrapper for chevron centering — same reasoning as Combobox above.
1179
+ It is also what the portalled list anchors to.
1180
+ Dimmed as a whole when disabled (or locked), the way EntityCombobox dims its
1181
+ trigger. FIELD_BASE's grey alone left a disabled picker looking like a
1182
+ filled-in one beside the pickers that do dim (the label is not the input's
1183
+ `peer`, so it is dimmed by hand above). */
1184
+ <div ref={fieldRef} className={cn("relative", inert && "cursor-not-allowed opacity-50")}>
1185
+ <input
1186
+ ref={ref}
1187
+ id={fieldId}
1188
+ value={shown}
1189
+ placeholder={placeholder}
1190
+ // The caller's name, over the <label for> above. Just the label, never
1191
+ // "label: value" the way a trigger button has to compose it: an input
1192
+ // already exposes its value separately.
1193
+ aria-label={ariaLabel}
1194
+ role="combobox"
1195
+ aria-expanded={open}
1196
+ // Required by the role, and the half that was missing: the field said it
1197
+ // was expanded and never said what it had expanded (ESLint's
1198
+ // `role-has-required-aria-props`, the audit's §a11y).
1199
+ aria-controls={listboxId}
1200
+ aria-activedescendant={activeId}
1201
+ aria-autocomplete="list"
1202
+ aria-invalid={field.isInvalid || undefined}
1203
+ aria-describedby={mergeDescribedBy(field.describedBy, locked && lock.reasonId)}
1204
+ aria-required={ariaRequired}
1205
+ autoComplete="off"
1206
+ // Locked: focusable, `readOnly`, `aria-disabled` — see `lockedKeyDown`.
1207
+ disabled={locked ? undefined : disabled}
1208
+ readOnly={locked || undefined}
1209
+ aria-disabled={locked || undefined}
1210
+ // 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.
1211
+ autoFocus={autoFocus}
1212
+ // `inputMode="none"` rather than readOnly, for the same reason Combobox
1213
+ // above gives: the sheet carries the keyboard, and a readOnly field would
1214
+ // take FIELD_BASE's settled look on a field that is perfectly editable.
1215
+ inputMode={isPhone ? "none" : undefined}
1216
+ onMouseDown={primaryOnly.onMouseDown}
1217
+ onFocus={(e) => {
1218
+ // See {@link usePrimaryPressOnly}: a back/forward button lands here on
1219
+ // its way to navigating, and neither the list nor the select-all is
1220
+ // anything it asked for (live #309 rework). A locked field takes focus to
1221
+ // say why, and opens nothing.
1222
+ if (primaryOnly.fromAuxButton() || locked) return;
1223
+ setText(shown);
1224
+ e.currentTarget.select();
981
1225
  setOpen(true);
982
- setActive((i) => stepEnabled(matches, i, 1));
983
- } else if (e.key === "ArrowUp") {
984
- e.preventDefault();
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])) {
1226
+ // The sheet has its own input; the field behind it must not also pull up
1227
+ // the keyboard and scroll the page under the dialog.
1228
+ if (isPhone) sheetInputRef.current?.focus();
1229
+ }}
1230
+ onBlur={() => {
1231
+ // On a phone the blur is the SHEET taking focus, not the user leaving the
1232
+ // field — reconciling there would close the sheet the instant it opened.
1233
+ if (!isPhone) reconcile();
1234
+ }}
1235
+ onChange={(e) => {
1236
+ if (locked) return;
1237
+ setText(e.target.value);
1238
+ setOpen(true);
1239
+ setActive(-1);
1240
+ }}
1241
+ onKeyDown={(e) => {
1242
+ if (locked) return lockedKeyDown(e);
1243
+ // Disabled rows are passed over; Up from "nothing highlighted" lands on
1244
+ // the first takeable row, as it always landed on row 0. The create row is
1245
+ // the last row the arrows reach.
1246
+ if (e.key === "ArrowDown") {
988
1247
  e.preventDefault();
989
- commit(matches[active]);
990
- } else {
1248
+ setOpen(true);
1249
+ setActive((i) => stepEnabled(rows, i, 1));
1250
+ } else if (e.key === "ArrowUp") {
1251
+ e.preventDefault();
1252
+ setActive((i) => (i < 0 ? stepEnabled(rows, -1, 1) : stepEnabled(rows, i, -1)));
1253
+ } else if (e.key === "Enter") {
1254
+ if (open && isOptionEnabled(rows[active])) {
1255
+ e.preventDefault();
1256
+ if (active === createIndex && showCreate) create();
1257
+ else take(matches[active]);
1258
+ } else {
1259
+ reconcile();
1260
+ }
1261
+ } else if (e.key === "Escape") {
1262
+ setText(null);
1263
+ close();
1264
+ } else if (e.key === "Tab") {
1265
+ // Focus is in THIS input and stays there, so the browser's own Tab is
1266
+ // left alone; all that is needed is that the list stop covering what
1267
+ // the user is tabbing to. `reconcile` rather than `close`, because
1268
+ // leaving the field is exactly when loose text has to be judged.
991
1269
  reconcile();
992
1270
  }
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.
1001
- reconcile();
1002
- }
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.
1005
- }}
1006
- className={cn(
1007
- FIELD_BASE,
1008
- label !== undefined && FIELD_FLOATING_PAD,
1009
- "pe-9",
1010
- field.isInvalid && FIELD_INVALID,
1011
- )}
1012
- />
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);
1271
+ // Home/End stay with the caret — see the note in {@link Combobox}: this
1272
+ // field is editable, and its text is what `reconcile` judges.
1030
1273
  }}
1031
1274
  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",
1275
+ FIELD_BASE,
1276
+ label !== undefined && FIELD_FLOATING_PAD,
1277
+ "pe-9",
1278
+ field.isInvalid && FIELD_INVALID,
1052
1279
  )}
1053
1280
  />
1054
- )}
1055
- </div>
1281
+ {showClear ? (
1282
+ <button
1283
+ type="button"
1284
+ // Out of the tab order, like the clear on `EntityCombobox`: the keyboard
1285
+ // already clears this field by selecting its text and deleting, and a
1286
+ // second stop between every picker and the next field is a worse trade
1287
+ // than the one gesture it saves.
1288
+ tabIndex={-1}
1289
+ aria-label={labels.clear}
1290
+ // preventDefault, exactly as the chevron does: without it the press
1291
+ // focuses the input, which on a phone opens the sheet over the field the
1292
+ // press was clearing.
1293
+ onMouseDown={(e) => e.preventDefault()}
1294
+ onClick={() => {
1295
+ setText(null);
1296
+ close();
1297
+ if (!locked) onChange(clearValue);
1298
+ }}
1299
+ className={cn(
1300
+ "absolute end-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-[var(--text-placeholder)]",
1301
+ "hover:text-[var(--text-secondary)]",
1302
+ )}
1303
+ >
1304
+ <X aria-hidden className="size-4" />
1305
+ </button>
1306
+ ) : (
1307
+ <ChevronDown
1308
+ aria-hidden
1309
+ onMouseDown={(e) => {
1310
+ // Toggle on the chevron without stealing focus from the input — and
1311
+ // not at all on a disabled or locked field, which the input's own
1312
+ // `disabled`/`readOnly` does not stop from here.
1313
+ e.preventDefault();
1314
+ if (!inert) setOpen((o) => !o);
1315
+ }}
1316
+ className={cn(
1317
+ "absolute end-2.5 top-1/2 size-4 -translate-y-1/2 text-[var(--text-placeholder)]",
1318
+ // The box above does the dimming; dimming here too would halve it again.
1319
+ !inert && "cursor-pointer",
1320
+ )}
1321
+ />
1322
+ )}
1323
+ </div>,
1324
+ )}
1056
1325
  </EndHintRow>
1057
1326
  {label !== undefined && hintParts.labelHint !== undefined && (
1058
1327
  <StaticLabelRow hint={hintParts.labelHint}>
1059
- <ComboboxFieldLabel htmlFor={fieldId} className={cn(LABEL_IN_ROW, disabled && "opacity-50")}>
1328
+ <ComboboxFieldLabel htmlFor={fieldId} className={cn(LABEL_IN_ROW, inert && "opacity-50")}>
1060
1329
  {label}
1061
1330
  </ComboboxFieldLabel>
1062
1331
  </StaticLabelRow>
@@ -1065,11 +1334,11 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
1065
1334
  {field.errorEl}
1066
1335
  {isPhone && (
1067
1336
  <PickerSheet
1068
- open={open && !disabled}
1337
+ open={open && !inert}
1069
1338
  // Closing without choosing keeps the value: `text` was never emptied, so
1070
1339
  // reconcile has nothing to undo — it just puts the label back.
1071
1340
  onClose={reconcile}
1072
- title={label}
1341
+ title={sheetTitle ?? label}
1073
1342
  query={sheetQuery}
1074
1343
  onQueryChange={(v) => {
1075
1344
  setSheetQuery(v);
@@ -1099,7 +1368,7 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
1099
1368
  role="option"
1100
1369
  aria-selected={o.value === value}
1101
1370
  aria-disabled={o.disabled || undefined}
1102
- onClick={() => commit(o)}
1371
+ onClick={() => take(o)}
1103
1372
  className={cn(
1104
1373
  SHEET_ROW_CLASS,
1105
1374
  o.value === value && "font-medium",
@@ -1111,13 +1380,34 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
1111
1380
  </li>
1112
1381
  </Fragment>
1113
1382
  ))}
1114
- {matches.length === 0 && (
1383
+ {showCreate && (
1384
+ <li role="presentation">
1385
+ <button
1386
+ type="button"
1387
+ role="option"
1388
+ // An action, never chosen — see `onCreate`.
1389
+ aria-selected={false}
1390
+ aria-disabled={createLocked || undefined}
1391
+ onClick={create}
1392
+ className={cn(
1393
+ SHEET_ROW_CLASS,
1394
+ CREATE_ROW_CLASS,
1395
+ createLocked && DISABLED_ROW_CLASS,
1396
+ )}
1397
+ >
1398
+ {createRowContent}
1399
+ </button>
1400
+ </li>
1401
+ )}
1402
+ {/* "Nothing matched" only when there is nothing to do either: with a create
1403
+ row the list is not empty, it is offering to fill itself. */}
1404
+ {matches.length === 0 && !showCreate && (
1115
1405
  <li className="px-4 py-3 text-sm text-[var(--text-muted)]">{labels.noResults}</li>
1116
1406
  )}
1117
1407
  </ul>
1118
1408
  </PickerSheet>
1119
1409
  )}
1120
- {!isPhone && open && !disabled && matches.length > 0 && (
1410
+ {!isPhone && open && !inert && (matches.length > 0 || showCreate) && (
1121
1411
  <SuggestionList id={listboxId} anchorRef={fieldRef} panelRef={panelRef}>
1122
1412
  {matches.map((o, i) => (
1123
1413
  // A group heading is emitted at each group boundary rather than repeating
@@ -1151,7 +1441,7 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
1151
1441
  // list before the selection registers. Prevented on a disabled
1152
1442
  // row too, so pressing one leaves the field focused and open.
1153
1443
  e.preventDefault();
1154
- commit(o);
1444
+ take(o);
1155
1445
  }}
1156
1446
  onMouseEnter={() => {
1157
1447
  if (!o.disabled) setActive(i);
@@ -1175,6 +1465,36 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
1175
1465
  </li>
1176
1466
  </Fragment>
1177
1467
  ))}
1468
+ {showCreate && (
1469
+ <li role="presentation">
1470
+ <button
1471
+ type="button"
1472
+ id={optionId(createIndex)}
1473
+ role="option"
1474
+ // An action, never chosen; whether the keyboard is ON it is the field's
1475
+ // `aria-activedescendant`'s to say.
1476
+ aria-selected={false}
1477
+ aria-disabled={createLocked || undefined}
1478
+ tabIndex={-1}
1479
+ onMouseDown={(e) => {
1480
+ // mousedown, like the rows above: the input's blur would otherwise
1481
+ // reconcile and close the list before the click landed.
1482
+ e.preventDefault();
1483
+ create();
1484
+ }}
1485
+ onMouseEnter={() => {
1486
+ if (!createLocked) setActive(createIndex);
1487
+ }}
1488
+ className={cn(
1489
+ rowClass(active === createIndex && !createLocked),
1490
+ CREATE_ROW_CLASS,
1491
+ createLocked && DISABLED_ROW_CLASS,
1492
+ )}
1493
+ >
1494
+ {createRowContent}
1495
+ </button>
1496
+ </li>
1497
+ )}
1178
1498
  </SuggestionList>
1179
1499
  )}
1180
1500
  </div>