@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,17 +1,29 @@
1
- import { useId } from "react";
2
- import type { ComponentPropsWithoutRef, ReactNode } from "react";
1
+ import { useCallback, useId } from "react";
2
+ import type { ComponentPropsWithoutRef, ReactNode, Ref } from "react";
3
3
  import { X } from "lucide-react";
4
4
  import { cn } from "../lib/cn";
5
5
  import { FieldChevron, FieldLabel, FIELD_TRIGGER, FIELD_FLOATING_PAD, FIELD_INVALID } from "./ui";
6
6
  import {
7
7
  ComboboxPanel,
8
+ endHintRowProps,
9
+ offersCreate,
8
10
  useComboboxCore,
9
11
  useComboboxFieldError,
10
12
  type ComboClearValue,
11
13
  type ComboOption,
12
14
  } from "./combobox-core";
13
15
  import { DEFAULT_COMBOBOX_LABELS, DEFAULT_COMMON_LABELS, useKitLabels } from "../i18n/kit-labels";
14
- import { EndHintRow, FieldCaption, LABEL_IN_ROW, StaticLabelRow, useFieldHint } from "./field-parts";
16
+ import {
17
+ EndHintRow,
18
+ FieldCaption,
19
+ LABEL_IN_ROW,
20
+ LockedReason,
21
+ StaticLabelRow,
22
+ useFieldHint,
23
+ useLockReason,
24
+ } from "./field-parts";
25
+ import { assignRef, mergeDescribedBy } from "./choice-parts";
26
+ import { useCommitReason } from "./write-lock";
15
27
 
16
28
  export type { ComboClearValue, ComboOption } from "./combobox-core";
17
29
 
@@ -46,6 +58,10 @@ export interface EntityComboboxProps<V extends string | number, C extends ComboC
46
58
  placeholder?: string;
47
59
  searchPlaceholder?: string;
48
60
  emptyLabel?: string;
61
+ /** Offer a clear "×" in place of the chevron while something is chosen; it emits
62
+ * `clearValue`. A pointer shortcut inside the trigger, out of the tab order — the
63
+ * keyboard's way is Delete or Backspace on the CLOSED trigger (0.23, as
64
+ * {@link CountrySelect}). Not offered while `disabled` or locked. */
49
65
  clearable?: boolean;
50
66
  clearLabel?: string;
51
67
  /** The phone sheet's close button. Its own prop rather than a reuse of
@@ -53,10 +69,44 @@ export interface EntityComboboxProps<V extends string | number, C extends ComboC
53
69
  * actions, and on a full-screen sheet the close button is the only way out —
54
70
  * so it is the one control here that MUST be in the reader's language. */
55
71
  closeLabel?: string;
72
+ /** The phone sheet's heading (and, as a string, its accessible name) for a picker
73
+ * whose label is drawn by someone else — a form's `FormLabel`; `RhfCombobox`
74
+ * hands its label over here (0.24). Default: `label`, then `placeholder`. */
75
+ sheetTitle?: ReactNode;
56
76
  disabled?: boolean;
77
+ /**
78
+ * Why the choice cannot be changed — {@link Button}'s `disabledReason`, for a picker
79
+ * that SAVES on change (keksdose G2: the statement's account, where picking IS the
80
+ * write). The trigger stays focusable and `aria-disabled`, the list does not open,
81
+ * the clear "×" is gone and `onChange` never fires; the reason is in the kit
82
+ * {@link Tooltip} and on the trigger's `aria-describedby`. Wins over `disabled`.
83
+ * {@link CountrySelect}'s lock, which is built on the same core.
84
+ */
85
+ disabledReason?: ReactNode;
86
+ /**
87
+ * This picker COMMITS — choosing saves. Under a locked {@link WriteLockProvider} it is
88
+ * locked the `disabledReason` way with the lock's reason (which wins over its own).
89
+ * A picker inside a form with its own Save stays editable under the lock: leave this
90
+ * off there and put `commit` on the Save. No provider, or an unlocked one: no effect.
91
+ */
92
+ commit?: boolean;
57
93
  /** When set, a "create" row appears for a non-empty query with no exact match. */
58
94
  onCreate?: (query: string) => void;
59
95
  createLabel?: (query: string) => string;
96
+ /**
97
+ * Offer the create row with NOTHING typed too, worded as this — keksdose G9's
98
+ * "Create cash account", which mints a record under a default name and so needs no
99
+ * query. `onCreate` then receives `""`. Left out, the row needs a query, as before.
100
+ * See {@link InlineEntityCombobox}'s `createEmptyLabel`.
101
+ */
102
+ createEmptyLabel?: string;
103
+ /**
104
+ * The create row WRITES (it mints the record on the server) while picking does not.
105
+ * Under a locked {@link WriteLockProvider} the row stays in the list but cannot be
106
+ * taken, with the lock's reason as its second line — the picker itself stays live.
107
+ * keksdose dev#496: on the read-only demo choosing is draft state, minting is not.
108
+ */
109
+ createCommit?: boolean;
60
110
  /** Required and unanswered — {@link FIELD_INVALID}. See {@link Input}'s `invalid`. */
61
111
  invalid?: boolean;
62
112
  /** What is wrong with the value, as {@link Input}'s `error`: rendered under the
@@ -79,6 +129,14 @@ export interface EntityComboboxProps<V extends string | number, C extends ComboC
79
129
  debounceMs?: number;
80
130
  /** Shown when `loadOptions` rejects. Default: `combobox.loadError`. */
81
131
  loadErrorLabel?: string;
132
+ /**
133
+ * The TRIGGER — the focusable `<button>` a reader meets — as a React 19 ref prop, as
134
+ * on {@link CountrySelect}, which is built on the same core (0.24, kastlan: react-hook-
135
+ * form's focus-on-error calls `focus()` on whatever `field.ref` is handed, and a
136
+ * picker with no ref gave it nothing to focus). Not the wrapper: focus belongs on the
137
+ * trigger, which is also where the panel hands it back after a pick.
138
+ */
139
+ ref?: Ref<HTMLButtonElement>;
82
140
  }
83
141
 
84
142
  /**
@@ -88,42 +146,51 @@ export interface EntityComboboxProps<V extends string | number, C extends ComboC
88
146
  * the shared field/anchor/dismiss/search primitives (no cmdk/Radix). For picking
89
147
  * several entities use {@link MultiEntityCombobox}.
90
148
  */
91
- export function EntityCombobox<V extends string | number, C extends ComboClearValue = null>({
92
- value,
93
- onChange,
94
- clearValue = null as C,
95
- options,
96
- loadOptions,
97
- loading,
98
- label,
99
- placeholder,
100
- searchPlaceholder,
101
- emptyLabel,
102
- clearable,
103
- clearLabel,
104
- closeLabel,
105
- disabled,
106
- onCreate,
107
- createLabel,
108
- className,
109
- invalid,
110
- error,
111
- hint,
112
- filter,
113
- minChars,
114
- debounceMs,
115
- loadErrorLabel,
116
- "aria-label": ariaLabel,
117
- // The control's own wiring, taken off `rest` so it lands on the TRIGGER rather than
118
- // the wrapper — see MultiEntityCombobox: `Field`'s render-prop spreads `{ id,
119
- // aria-describedby, aria-invalid, aria-required }`, and on the role-less wrapper
120
- // div the label's `htmlFor` named nothing and the hint described nothing.
121
- id,
122
- "aria-describedby": ariaDescribedBy,
123
- "aria-invalid": ariaInvalid,
124
- "aria-required": ariaRequired,
125
- ...rest
126
- }: EntityComboboxProps<V, C>) {
149
+ export function EntityCombobox<V extends string | number, C extends ComboClearValue = null>(
150
+ props: EntityComboboxProps<V, C>,
151
+ ) {
152
+ const {
153
+ value,
154
+ onChange,
155
+ clearValue = null as C,
156
+ options,
157
+ loadOptions,
158
+ loading,
159
+ label,
160
+ placeholder,
161
+ searchPlaceholder,
162
+ emptyLabel,
163
+ clearable,
164
+ clearLabel,
165
+ closeLabel,
166
+ sheetTitle,
167
+ disabled,
168
+ disabledReason,
169
+ commit,
170
+ onCreate,
171
+ createLabel,
172
+ createEmptyLabel,
173
+ createCommit,
174
+ className,
175
+ invalid,
176
+ error,
177
+ hint,
178
+ filter,
179
+ minChars,
180
+ debounceMs,
181
+ loadErrorLabel,
182
+ ref,
183
+ "aria-label": ariaLabel,
184
+ // The control's own wiring, taken off `rest` so it lands on the TRIGGER rather than
185
+ // the wrapper — see MultiEntityCombobox: `Field`'s render-prop spreads `{ id,
186
+ // aria-describedby, aria-invalid, aria-required }`, and on the role-less wrapper
187
+ // div the label's `htmlFor` named nothing and the hint described nothing.
188
+ id,
189
+ "aria-describedby": ariaDescribedBy,
190
+ "aria-invalid": ariaInvalid,
191
+ "aria-required": ariaRequired,
192
+ ...rest
193
+ } = props;
127
194
  const core = useComboboxCore<V>({
128
195
  options,
129
196
  loadOptions,
@@ -148,6 +215,25 @@ export function EntityCombobox<V extends string | number, C extends ComboClearVa
148
215
  });
149
216
  const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
150
217
  const { open, results, resolve, setOpen, rememberOption, query, triggerRef } = core;
218
+ // The core anchors the panel to the trigger and hands focus back to it; the caller's
219
+ // `ref` wants the same element. One callback feeds both — CountrySelect's.
220
+ const setTrigger = useCallback(
221
+ (el: HTMLButtonElement | null) => {
222
+ triggerRef.current = el;
223
+ assignRef(ref, el);
224
+ },
225
+ [ref, triggerRef],
226
+ );
227
+ // The lock: this picker's own reason, or the provider's under `commit`.
228
+ const lock = useLockReason(commit, disabledReason);
229
+ const inert = lock.locked || Boolean(disabled);
230
+ // A lock arriving while the list is up closes it — adjusted while
231
+ // rendering, as the core adjusts its own query, so no frame offers a pick that would
232
+ // be thrown away.
233
+ if (lock.locked && open) setOpen(false);
234
+ // The create row's own lock, for a picker whose choosing is draft and whose create
235
+ // is the write (`createCommit`).
236
+ const createReason = useCommitReason(createCommit, undefined);
151
237
  // One id per instance, generated here rather than in the core: `aria-controls` on
152
238
  // the trigger has to name the list while the list is still closed, so the id
153
239
  // belongs to whoever renders both ends of it.
@@ -157,13 +243,28 @@ export function EntityCombobox<V extends string | number, C extends ComboClearVa
157
243
  const chosen: V | null = value == null || value === clearValue ? null : (value as V);
158
244
  const selectedOption = chosen == null ? null : resolve(chosen);
159
245
  const q = query.trim();
160
- const showCreate =
161
- Boolean(onCreate) && q.length > 0 && !results.some((o) => o.label.toLowerCase() === q.toLowerCase());
162
- const showClear = Boolean(clearable && chosen != null && !disabled);
246
+ const showCreate = offersCreate(
247
+ onCreate,
248
+ q,
249
+ results.map((o) => o.label),
250
+ createEmptyLabel,
251
+ );
252
+ const showClear = Boolean(clearable && chosen != null && !inert);
163
253
  /** What the closed control is showing — the second half of its accessible name. */
164
254
  const triggerText = selectedOption?.label ?? placeholder ?? "";
255
+ // While locked the trigger sits in the reason's Tooltip — in a fragment with a hidden
256
+ // copy of the reason, Button's anatomy — `block` so a full-width field stays full width.
257
+ const withLock = (trigger: ReactNode) =>
258
+ lock.locked ? (
259
+ <LockedReason lock={lock} className="block">
260
+ {trigger}
261
+ </LockedReason>
262
+ ) : (
263
+ trigger
264
+ );
165
265
 
166
266
  const choose = (o: ComboOption<V>) => {
267
+ if (lock.locked) return;
167
268
  rememberOption(o);
168
269
  onChange(o.value);
169
270
  // Back to the trigger, not to <body>: the panel that held focus is about to
@@ -179,94 +280,105 @@ export function EntityCombobox<V extends string | number, C extends ComboClearVa
179
280
  // handlers that open the panel cannot be clobbered from outside.
180
281
  <div {...rest} className={cn("relative", className)}>
181
282
  {label !== undefined && hintParts.labelHint === undefined && <FieldLabel>{label}</FieldLabel>}
182
- <EndHintRow hint={label === undefined ? hintParts.labelHint : undefined}>
183
- <button
184
- ref={triggerRef}
185
- id={id}
186
- type="button"
187
- // A combobox, not a button. The distinction is not pedantry: this control
188
- // carried `aria-invalid`, which `button` does not support, so a required
189
- // field left empty painted a rose border and told a reader nothing at all —
190
- // and ESLint flagged it as exactly that (`role-supports-aria-props`). The
191
- // fix the audit asked for is the role that describes what this IS: a closed
192
- // choice that expands into the list named below. `combobox` supports
193
- // `aria-invalid`, so the border and the announcement finally agree.
194
- role="combobox"
195
- aria-expanded={open}
196
- aria-controls={listboxId}
197
- aria-haspopup="listbox"
198
- // The label is a floating <span>, not a <label for>, so without this the
199
- // trigger's accessible name is whatever value happens to be selected —
200
- // "Checking" with nothing saying it is the account. The label AND the
201
- // value, because `aria-label` replaces the content rather than adding to
202
- // it, and a control that announces only its name has lost the answer.
203
- // A caller's own name wins over the composition: two fields labelled
204
- // "Account" on a transfer form are the from and the to, and only the
205
- // caller knows which is which.
206
- aria-label={
207
- ariaLabel ??
208
- (typeof label === "string" ? common.fieldValue(label, triggerText) : undefined)
209
- }
210
- disabled={disabled}
211
- aria-invalid={field.isInvalid || undefined}
212
- aria-describedby={field.describedBy}
213
- aria-required={ariaRequired}
214
- onClick={() => !disabled && setOpen((o) => !o)}
215
- // Down/Up opens the list from the closed trigger, per the APG. Enter and
216
- // Space already do it through the button's own click.
217
- onKeyDown={(e) => {
218
- if (disabled) return;
219
- if (e.key === "ArrowDown" || e.key === "ArrowUp") {
220
- e.preventDefault();
221
- setOpen(true);
283
+ <EndHintRow {...endHintRowProps("hint" in props, label !== undefined, hintParts.labelHint)}>
284
+ {withLock(
285
+ <button
286
+ ref={setTrigger}
287
+ id={id}
288
+ type="button"
289
+ // A combobox, not a button. The distinction is not pedantry: this control
290
+ // carried `aria-invalid`, which `button` does not support, so a required
291
+ // field left empty painted a rose border and told a reader nothing at all —
292
+ // and ESLint flagged it as exactly that (`role-supports-aria-props`). The
293
+ // fix the audit asked for is the role that describes what this IS: a closed
294
+ // choice that expands into the list named below. `combobox` supports
295
+ // `aria-invalid`, so the border and the announcement finally agree.
296
+ role="combobox"
297
+ aria-expanded={open}
298
+ aria-controls={listboxId}
299
+ aria-haspopup="listbox"
300
+ // The label is a floating <span>, not a <label for>, so without this the
301
+ // trigger's accessible name is whatever value happens to be selected —
302
+ // "Checking" with nothing saying it is the account. The label AND the
303
+ // value, because `aria-label` replaces the content rather than adding to
304
+ // it, and a control that announces only its name has lost the answer.
305
+ // A caller's own name wins over the composition: two fields labelled
306
+ // "Account" on a transfer form are the from and the to, and only the
307
+ // caller knows which is which.
308
+ aria-label={
309
+ ariaLabel ??
310
+ (typeof label === "string" ? common.fieldValue(label, triggerText) : undefined)
222
311
  }
223
- }}
224
- className={cn(
225
- FIELD_TRIGGER,
226
- "pe-9",
227
- label !== undefined && FIELD_FLOATING_PAD,
228
- disabled && "cursor-not-allowed opacity-50",
229
- field.isInvalid && FIELD_INVALID,
230
- )}
231
- >
232
- <span className="flex min-w-0 items-center gap-2">
233
- {selectedOption?.icon && <span className="shrink-0">{selectedOption.icon}</span>}
234
- <span
235
- className={cn(
236
- "truncate",
237
- // A chosen value is the field's VALUE, so it is set in the same ink an
238
- // <input>'s value is — FIELD_BASE's own text colour, inherited rather
239
- // than restated (Keksdose dev#477). It used to be one notch lighter
240
- // than the typeahead fields beside it, which is visible when a picker
241
- // and a text field share a form row. Nothing selected keeps the
242
- // placeholder tone (`--text-placeholder`), which every field here
243
- // agrees on.
244
- !selectedOption && "text-[var(--text-placeholder)]",
245
- )}
246
- >
247
- {selectedOption?.label ?? placeholder ?? ""}
248
- </span>
249
- </span>
250
- {showClear ? (
251
- /* eslint-disable-next-line jsx-a11y/click-events-have-key-events -- tabIndex -1 inside
252
- the trigger <button>, so it never holds focus and a key handler here could
253
- never fire; the keys go to the trigger. A pointer shortcut only. */
254
- <span
255
- role="button"
256
- tabIndex={-1}
257
- aria-label={labels.clear}
258
- onClick={(e) => {
259
- e.stopPropagation();
312
+ // The focusable kind of disabled while locked (Button's rule): a native
313
+ // `disabled` would take the field out of the tab order, and the reason with it.
314
+ disabled={lock.locked ? undefined : disabled}
315
+ aria-disabled={lock.locked || undefined}
316
+ aria-invalid={field.isInvalid || undefined}
317
+ aria-describedby={mergeDescribedBy(field.describedBy, lock.locked && lock.reasonId)}
318
+ aria-required={ariaRequired}
319
+ onClick={() => !inert && setOpen((o) => !o)}
320
+ // Down/Up opens the list from the closed trigger, per the APG. Enter and
321
+ // Space already do it through the button's own click.
322
+ onKeyDown={(e) => {
323
+ if (inert) return;
324
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
325
+ e.preventDefault();
326
+ setOpen(true);
327
+ } else if (showClear && !open && (e.key === "Delete" || e.key === "Backspace")) {
328
+ // The keyboard's clear: the "×" is a pointer shortcut out of the tab
329
+ // order, so without this a cleared-by-mouse field was set-only from the
330
+ // keyboard. The closed trigger only — CountrySelect's rule (0.23).
331
+ e.preventDefault();
260
332
  onChange(clearValue);
261
- }}
262
- className="absolute end-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-[var(--text-placeholder)] hover:text-[var(--text-secondary)]"
263
- >
264
- <X className="size-4" />
333
+ }
334
+ }}
335
+ className={cn(
336
+ FIELD_TRIGGER,
337
+ "pe-9",
338
+ label !== undefined && FIELD_FLOATING_PAD,
339
+ inert && "cursor-not-allowed opacity-50",
340
+ field.isInvalid && FIELD_INVALID,
341
+ )}
342
+ >
343
+ <span className="flex min-w-0 items-center gap-2">
344
+ {selectedOption?.icon && <span className="shrink-0">{selectedOption.icon}</span>}
345
+ <span
346
+ className={cn(
347
+ "truncate",
348
+ // A chosen value is the field's VALUE, so it is set in the same ink an
349
+ // <input>'s value is — FIELD_BASE's own text colour, inherited rather
350
+ // than restated (Keksdose dev#477). It used to be one notch lighter
351
+ // than the typeahead fields beside it, which is visible when a picker
352
+ // and a text field share a form row. Nothing selected keeps the
353
+ // placeholder tone (`--text-placeholder`), which every field here
354
+ // agrees on.
355
+ !selectedOption && "text-[var(--text-placeholder)]",
356
+ )}
357
+ >
358
+ {selectedOption?.label ?? placeholder ?? ""}
359
+ </span>
265
360
  </span>
266
- ) : (
267
- <FieldChevron />
268
- )}
269
- </button>
361
+ {showClear ? (
362
+ /* eslint-disable-next-line jsx-a11y/click-events-have-key-events -- tabIndex -1 inside
363
+ the trigger <button>, so it never holds focus and a key handler here could
364
+ never fire; the keys go to the trigger, where Delete and Backspace clear. */
365
+ <span
366
+ role="button"
367
+ tabIndex={-1}
368
+ aria-label={labels.clear}
369
+ onClick={(e) => {
370
+ e.stopPropagation();
371
+ onChange(clearValue);
372
+ }}
373
+ className="absolute end-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-[var(--text-placeholder)] hover:text-[var(--text-secondary)]"
374
+ >
375
+ <X className="size-4" />
376
+ </span>
377
+ ) : (
378
+ <FieldChevron />
379
+ )}
380
+ </button>,
381
+ )}
270
382
  </EndHintRow>
271
383
  {/* With a "?" the label shares the top strip with it — after the trigger, so the
272
384
  "?" follows the control in the tab order, as on Input and Select. */}
@@ -280,7 +392,7 @@ export function EntityCombobox<V extends string | number, C extends ComboClearVa
280
392
  listboxId={listboxId}
281
393
  // On a phone the panel becomes a full-screen sheet, which needs the field's
282
394
  // own label to say what it is asking for (live #200).
283
- sheetTitle={label ?? placeholder}
395
+ sheetTitle={sheetTitle ?? label ?? placeholder}
284
396
  searchPlaceholder={labels.search}
285
397
  emptyLabel={labels.noResults}
286
398
  closeLabel={closeLabel}
@@ -289,10 +401,12 @@ export function EntityCombobox<V extends string | number, C extends ComboClearVa
289
401
  onChoose={choose}
290
402
  showCreate={showCreate}
291
403
  onCreate={() => {
404
+ if (lock.locked) return;
292
405
  onCreate?.(q);
293
406
  core.closeToTrigger();
294
407
  }}
295
- createContent={labels.create(q)}
408
+ createContent={q ? labels.create(q) : createEmptyLabel}
409
+ createDisabledReason={createReason}
296
410
  />
297
411
  <FieldCaption parts={hintParts} />
298
412
  {field.errorEl}