@eifi1/ui-kit 0.5.0 → 0.6.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 (144) hide show
  1. package/README.md +50 -1
  2. package/dist/components/amount-input.d.ts +7 -0
  3. package/dist/components/autocomplete.d.ts +101 -0
  4. package/dist/components/autocomplete.js +260 -0
  5. package/dist/components/autocomplete.js.map +1 -0
  6. package/dist/components/calculator.d.ts +7 -0
  7. package/dist/components/chip.d.ts +22 -7
  8. package/dist/components/chip.js +14 -1
  9. package/dist/components/chip.js.map +1 -1
  10. package/dist/components/choice-card.d.ts +100 -0
  11. package/dist/components/choice-card.js +170 -0
  12. package/dist/components/choice-card.js.map +1 -0
  13. package/dist/components/combobox-core.d.ts +76 -6
  14. package/dist/components/combobox-core.js +119 -49
  15. package/dist/components/combobox-core.js.map +1 -1
  16. package/dist/components/combobox.d.ts +12 -2
  17. package/dist/components/combobox.js +42 -17
  18. package/dist/components/combobox.js.map +1 -1
  19. package/dist/components/currency-select.js +10 -2
  20. package/dist/components/currency-select.js.map +1 -1
  21. package/dist/components/danger-confirm.d.ts +91 -0
  22. package/dist/components/danger-confirm.js +181 -0
  23. package/dist/components/danger-confirm.js.map +1 -0
  24. package/dist/components/date-picker.js +19 -7
  25. package/dist/components/date-picker.js.map +1 -1
  26. package/dist/components/dialog-frame.d.ts +84 -0
  27. package/dist/components/dialog-frame.js +86 -0
  28. package/dist/components/dialog-frame.js.map +1 -0
  29. package/dist/components/disclosure.d.ts +108 -0
  30. package/dist/components/disclosure.js +127 -0
  31. package/dist/components/disclosure.js.map +1 -0
  32. package/dist/components/entity-combobox.d.ts +17 -3
  33. package/dist/components/entity-combobox.js +25 -5
  34. package/dist/components/entity-combobox.js.map +1 -1
  35. package/dist/components/file-button.d.ts +161 -0
  36. package/dist/components/file-button.js +225 -0
  37. package/dist/components/file-button.js.map +1 -0
  38. package/dist/components/file-dropzone.d.ts +72 -23
  39. package/dist/components/file-dropzone.js +219 -94
  40. package/dist/components/file-dropzone.js.map +1 -1
  41. package/dist/components/icon-picker.d.ts +72 -0
  42. package/dist/components/icon-picker.js +104 -0
  43. package/dist/components/icon-picker.js.map +1 -0
  44. package/dist/components/mini-calendar.d.ts +3 -0
  45. package/dist/components/mini-calendar.js +4 -3
  46. package/dist/components/mini-calendar.js.map +1 -1
  47. package/dist/components/modal.d.ts +8 -1
  48. package/dist/components/modal.js +4 -2
  49. package/dist/components/modal.js.map +1 -1
  50. package/dist/components/month-picker.js +16 -5
  51. package/dist/components/month-picker.js.map +1 -1
  52. package/dist/components/multi-entity-combobox.d.ts +16 -3
  53. package/dist/components/multi-entity-combobox.js +25 -5
  54. package/dist/components/multi-entity-combobox.js.map +1 -1
  55. package/dist/components/number-field.d.ts +41 -1
  56. package/dist/components/number-field.js +42 -10
  57. package/dist/components/number-field.js.map +1 -1
  58. package/dist/components/number-input.d.ts +35 -2
  59. package/dist/components/number-input.js +35 -4
  60. package/dist/components/number-input.js.map +1 -1
  61. package/dist/components/numpad-sheet.d.ts +7 -0
  62. package/dist/components/popover.js +4 -1
  63. package/dist/components/popover.js.map +1 -1
  64. package/dist/components/search-field.d.ts +16 -0
  65. package/dist/components/search-field.js +29 -7
  66. package/dist/components/search-field.js.map +1 -1
  67. package/dist/components/signature-pad.d.ts +43 -1
  68. package/dist/components/signature-pad.js +74 -2
  69. package/dist/components/signature-pad.js.map +1 -1
  70. package/dist/components/swatch-picker.d.ts +69 -0
  71. package/dist/components/swatch-picker.js +75 -0
  72. package/dist/components/swatch-picker.js.map +1 -0
  73. package/dist/components/tile-radio.d.ts +50 -0
  74. package/dist/components/tile-radio.js +140 -0
  75. package/dist/components/tile-radio.js.map +1 -0
  76. package/dist/components/toggle-group.d.ts +27 -5
  77. package/dist/components/toggle-group.js +22 -15
  78. package/dist/components/toggle-group.js.map +1 -1
  79. package/dist/components/trigger-aria.d.ts +18 -0
  80. package/dist/components/trigger-aria.js +25 -0
  81. package/dist/components/trigger-aria.js.map +1 -0
  82. package/dist/components/ui.d.ts +164 -18
  83. package/dist/components/ui.js +207 -34
  84. package/dist/components/ui.js.map +1 -1
  85. package/dist/i18n/defaults.d.ts +7 -0
  86. package/dist/i18n/defaults.js +13 -2
  87. package/dist/i18n/defaults.js.map +1 -1
  88. package/dist/i18n/kit-labels.d.ts +38 -5
  89. package/dist/i18n/kit-labels.js +12 -4
  90. package/dist/i18n/kit-labels.js.map +1 -1
  91. package/dist/index.d.ts +16 -7
  92. package/dist/index.js +12 -0
  93. package/dist/index.js.map +1 -1
  94. package/dist/lib/table-text.d.ts +127 -0
  95. package/dist/lib/table-text.js +82 -0
  96. package/dist/lib/table-text.js.map +1 -0
  97. package/dist/rhf/form.d.ts +79 -0
  98. package/dist/rhf/form.js +143 -0
  99. package/dist/rhf/form.js.map +1 -0
  100. package/dist/rhf.d.ts +4 -0
  101. package/dist/rhf.js +3 -0
  102. package/dist/rhf.js.map +1 -0
  103. package/dist/shell/app-shell.js +3 -1
  104. package/dist/shell/app-shell.js.map +1 -1
  105. package/dist/table-text.d.ts +1 -0
  106. package/dist/table-text.js +3 -0
  107. package/dist/table-text.js.map +1 -0
  108. package/package.json +14 -1
  109. package/src/components/autocomplete.tsx +425 -0
  110. package/src/components/chip.tsx +43 -7
  111. package/src/components/choice-card.tsx +305 -0
  112. package/src/components/combobox-core.tsx +228 -58
  113. package/src/components/combobox.tsx +58 -21
  114. package/src/components/currency-select.tsx +16 -2
  115. package/src/components/danger-confirm.tsx +286 -0
  116. package/src/components/date-picker.tsx +31 -6
  117. package/src/components/dialog-frame.tsx +179 -0
  118. package/src/components/disclosure.tsx +259 -0
  119. package/src/components/entity-combobox.tsx +41 -6
  120. package/src/components/file-button.tsx +431 -0
  121. package/src/components/file-dropzone.tsx +323 -117
  122. package/src/components/icon-picker.tsx +181 -0
  123. package/src/components/mini-calendar.tsx +7 -3
  124. package/src/components/modal.tsx +10 -2
  125. package/src/components/month-picker.tsx +22 -4
  126. package/src/components/multi-entity-combobox.tsx +40 -6
  127. package/src/components/number-field.tsx +86 -10
  128. package/src/components/number-input.tsx +79 -2
  129. package/src/components/popover.tsx +4 -1
  130. package/src/components/search-field.tsx +49 -6
  131. package/src/components/signature-pad.tsx +112 -0
  132. package/src/components/swatch-picker.tsx +141 -0
  133. package/src/components/tile-radio.tsx +228 -0
  134. package/src/components/toggle-group.tsx +54 -18
  135. package/src/components/trigger-aria.ts +42 -0
  136. package/src/components/ui.tsx +427 -40
  137. package/src/i18n/defaults.ts +12 -1
  138. package/src/i18n/kit-labels.tsx +45 -5
  139. package/src/index.ts +19 -0
  140. package/src/lib/table-text.ts +265 -0
  141. package/src/rhf/form.tsx +300 -0
  142. package/src/rhf.ts +9 -0
  143. package/src/shell/app-shell.tsx +3 -1
  144. package/src/table-text.ts +8 -0
@@ -1,4 +1,4 @@
1
- import { Fragment, useEffect, useMemo, useRef, useState } from "react";
1
+ import { Fragment, useEffect, useId, useMemo, useRef, useState } from "react";
2
2
  import { createPortal } from "react-dom";
3
3
  import type {
4
4
  ComponentPropsWithoutRef,
@@ -18,6 +18,7 @@ import { type AnchorRect } from "../hooks/use-anchored-rect";
18
18
  import { useAnchoredPanel, type AnchoredPanel } from "../hooks/use-anchored-panel";
19
19
  import { useEscapeKey, useOutsideClick } from "../hooks/use-dismiss";
20
20
  import { DEFAULT_COMBOBOX_LABELS, useKitLabels } from "../i18n/kit-labels";
21
+ import { hasMessage, mergeDescribedBy } from "./choice-parts";
21
22
 
22
23
  export interface ComboOption<V extends string | number> {
23
24
  value: V;
@@ -34,15 +35,126 @@ export interface ComboOption<V extends string | number> {
34
35
  group?: string;
35
36
  }
36
37
 
37
- export interface ComboboxCoreOptions<V extends string | number> {
38
- /** Already-loaded options (client-side filtered), also used to resolve labels. */
38
+ /**
39
+ * Where a combobox's rows come from, and how a query narrows them. Shared by the
40
+ * trigger pickers ({@link useComboboxCore}) and the inline {@link Autocomplete}, so
41
+ * the two cannot drift in what a failed lookup or a too-short query does.
42
+ */
43
+ export interface OptionSourceOptions<V extends string | number> {
44
+ /** Already-loaded options (client-side filtered unless `filter` is false), also
45
+ * used to resolve labels. */
39
46
  options?: ComboOption<V>[];
40
- /** Async option source, debounced and race-safe; stale responses are ignored. */
47
+ /** Async option source, debounced and race-safe; stale responses are ignored,
48
+ * and a rejection is caught and reported as `failed`. */
41
49
  loadOptions?: (query: string) => Promise<ComboOption<V>[]>;
42
50
  /** External loading flag, OR-ed with the internal async state. */
43
51
  loading?: boolean;
52
+ /**
53
+ * Narrow `options` by the query (label/sublabel substring). Default `true`.
54
+ *
55
+ * `false` shows `options` exactly as given, in the given order — for a list the
56
+ * caller has already searched and ranked on a server (Keksdose's geocoder), which a
57
+ * second, client-side substring filter could only get wrong: a provider that
58
+ * matches "Bahnhofstr." to "Bahnhofstrasse" is right, and a label filter would
59
+ * throw that row away.
60
+ */
61
+ filter?: boolean;
62
+ /**
63
+ * Below this many (trimmed) characters nothing is offered and `loadOptions` is not
64
+ * called. Default `0` — the pickers list everything on open. Set it for a source
65
+ * that is costly or rate-limited: one letter against a geocoder matches half a
66
+ * continent, and a request per opened panel is a request nobody asked for.
67
+ */
68
+ minChars?: number;
69
+ /** Quiet time after the last keystroke before `loadOptions` runs. Default 150 ms. */
70
+ debounceMs?: number;
71
+ }
72
+
73
+ export interface OptionSource<V extends string | number> {
74
+ results: ComboOption<V>[];
75
+ busy: boolean;
76
+ /** The last lookup for this query REJECTED. The rows are emptied with it: results
77
+ * from an earlier query under a failed one would read as the answer to it. */
78
+ failed: boolean;
79
+ /** The query is shorter than `minChars`, so nothing was asked. */
80
+ tooShort: boolean;
81
+ }
82
+
83
+ /**
84
+ * The rows half of a combobox: client-side filtering, or a debounced, race-safe
85
+ * `loadOptions` with its loading and failure states.
86
+ *
87
+ * `active` gates the async side — a closed list asks nothing. Loading is DERIVED
88
+ * ("the settled answer is for a different query") rather than flagged on in the
89
+ * effect, so there is no render between a keystroke and the debounce in which an
90
+ * empty list could claim "No results" for a question still being asked.
91
+ */
92
+ export function useOptionSource<V extends string | number>({
93
+ options,
94
+ loadOptions,
95
+ loading,
96
+ filter = true,
97
+ minChars = 0,
98
+ debounceMs = 150,
99
+ query,
100
+ active,
101
+ }: OptionSourceOptions<V> & { query: string; active: boolean }): OptionSource<V> {
102
+ const isAsync = typeof loadOptions === "function";
103
+ const tooShort = query.trim().length < minChars;
104
+ const [settled, setSettled] = useState<{
105
+ query: string | null;
106
+ results: ComboOption<V>[];
107
+ failed: boolean;
108
+ }>({ query: null, results: [], failed: false });
109
+ const loadRef = useRef(loadOptions);
110
+ useEffect(() => {
111
+ loadRef.current = loadOptions;
112
+ });
113
+ const reqId = useRef(0);
114
+
115
+ useEffect(() => {
116
+ // Bumped even when nothing is asked, so a request still in flight from a longer
117
+ // query cannot land after the user has deleted back below `minChars`.
118
+ const id = ++reqId.current;
119
+ if (!active || !isAsync || tooShort) return;
120
+ const t = setTimeout(
121
+ async () => {
122
+ try {
123
+ const r = await loadRef.current!(query);
124
+ if (reqId.current === id) setSettled({ query, results: r, failed: false });
125
+ } catch {
126
+ // The one path the old `try/finally` had no answer for: the list kept the
127
+ // previous query's rows and said nothing (Keksdose proposal §2).
128
+ if (reqId.current === id) setSettled({ query, results: [], failed: true });
129
+ }
130
+ },
131
+ query ? debounceMs : 0,
132
+ );
133
+ return () => clearTimeout(t);
134
+ }, [query, active, isAsync, tooShort, debounceMs]);
135
+
136
+ const clientResults = useMemo(() => {
137
+ if (isAsync) return [];
138
+ const src = options ?? [];
139
+ const q = query.trim().toLowerCase();
140
+ if (!filter || !q) return src;
141
+ return src.filter(
142
+ (o) => o.label.toLowerCase().includes(q) || (o.sublabel ?? "").toLowerCase().includes(q),
143
+ );
144
+ }, [isAsync, options, query, filter]);
145
+
146
+ const pending = isAsync && active && !tooShort && settled.query !== query;
147
+ const failed = isAsync && !tooShort && settled.failed;
148
+ return {
149
+ results: tooShort ? [] : isAsync ? (failed ? [] : settled.results) : clientResults,
150
+ busy: Boolean(loading) || pending,
151
+ failed: failed && !pending,
152
+ tooShort,
153
+ };
44
154
  }
45
155
 
156
+ export type ComboboxCoreOptions<V extends string | number> = OptionSourceOptions<V>;
157
+
46
158
  export interface ComboboxCore<V extends string | number> {
47
159
  triggerRef: RefObject<HTMLButtonElement | null>;
48
160
  panelRef: RefObject<HTMLDivElement | null>;
@@ -55,6 +167,10 @@ export interface ComboboxCore<V extends string | number> {
55
167
  setActive: Dispatch<SetStateAction<number>>;
56
168
  results: ComboOption<V>[];
57
169
  busy: boolean;
170
+ /** See {@link OptionSource}. */
171
+ failed: boolean;
172
+ tooShort: boolean;
173
+ minChars: number;
58
174
  rect: AnchorRect | null;
59
175
  /** Where the dropdown goes, clamped to the visible viewport (feedback #135). */
60
176
  placement: AnchoredPanel;
@@ -75,8 +191,8 @@ export interface ComboboxCore<V extends string | number> {
75
191
  */
76
192
  export function useComboboxCore<V extends string | number>({
77
193
  options,
78
- loadOptions,
79
- loading,
194
+ minChars = 0,
195
+ ...source
80
196
  }: ComboboxCoreOptions<V>): ComboboxCore<V> {
81
197
  const triggerRef = useRef<HTMLButtonElement>(null);
82
198
  const panelRef = useRef<HTMLDivElement>(null);
@@ -85,13 +201,6 @@ export function useComboboxCore<V extends string | number>({
85
201
  const [query, setQuery] = useState("");
86
202
  const [active, setActive] = useState(0);
87
203
 
88
- const isAsync = typeof loadOptions === "function";
89
- const [asyncResults, setAsyncResults] = useState<ComboOption<V>[]>([]);
90
- const [asyncLoading, setAsyncLoading] = useState(false);
91
- const loadRef = useRef(loadOptions);
92
- loadRef.current = loadOptions;
93
- const reqId = useRef(0);
94
-
95
204
  const close = () => setOpen(false);
96
205
  // Escape unmounts the panel the user is typing in; without this the browser drops
97
206
  // focus on <body>, so the caret disappears and the next Tab restarts at the top of
@@ -131,35 +240,15 @@ export function useComboboxCore<V extends string | number>({
131
240
  return () => cancelAnimationFrame(id);
132
241
  }, [open]);
133
242
 
134
- // Debounced, race-safe async search (only when loadOptions is provided).
135
- useEffect(() => {
136
- if (!open || !isAsync) return;
137
- const id = ++reqId.current;
138
- setAsyncLoading(true);
139
- const run = async () => {
140
- try {
141
- const r = await loadRef.current!(query);
142
- if (reqId.current === id) setAsyncResults(r);
143
- } finally {
144
- if (reqId.current === id) setAsyncLoading(false);
145
- }
146
- };
147
- const t = setTimeout(run, query ? 150 : 0);
148
- return () => clearTimeout(t);
149
- }, [query, open, isAsync]);
150
-
151
- const clientResults = useMemo(() => {
152
- if (isAsync) return [];
153
- const src = options ?? [];
154
- const q = query.trim().toLowerCase();
155
- if (!q) return src;
156
- return src.filter(
157
- (o) => o.label.toLowerCase().includes(q) || (o.sublabel ?? "").toLowerCase().includes(q),
158
- );
159
- }, [isAsync, options, query]);
160
-
161
- const results = isAsync ? asyncResults : clientResults;
162
- const busy = Boolean(loading) || (isAsync && asyncLoading);
243
+ // Debounced, race-safe async search (only when loadOptions is provided) or the
244
+ // client-side filter — see {@link useOptionSource}.
245
+ const { results, busy, failed, tooShort } = useOptionSource<V>({
246
+ ...source,
247
+ options,
248
+ minChars,
249
+ query,
250
+ active: open,
251
+ });
163
252
 
164
253
  // Accumulate every option we've seen so a selected value can render its label
165
254
  // even after the async list has moved on to other query results.
@@ -188,6 +277,9 @@ export function useComboboxCore<V extends string | number>({
188
277
  setActive,
189
278
  results,
190
279
  busy,
280
+ failed,
281
+ tooShort,
282
+ minChars,
191
283
  rect,
192
284
  placement,
193
285
  cacheRef,
@@ -196,6 +288,45 @@ export function useComboboxCore<V extends string | number>({
196
288
  };
197
289
  }
198
290
 
291
+ /** The error line under a field — the same type as `ui.tsx`'s (module-private) one,
292
+ * so a combobox's message is indistinguishable from an Input's. */
293
+ const FIELD_ERROR_CLASS = "mt-1 text-[11px] leading-tight text-[var(--danger)]";
294
+
295
+ /**
296
+ * {@link Input}'s `error` for the combobox family: the message under the field, its
297
+ * id on the control's `aria-describedby` (merged, never replacing — the hint the
298
+ * caller pointed at stays first), and `invalid` implied. ui.tsx's `useFieldError` is
299
+ * module-private, so this restates it on the same rules `choice-parts.ts` does.
300
+ */
301
+ export function useComboboxFieldError(
302
+ error: ReactNode,
303
+ invalid: boolean | undefined,
304
+ describedBy?: string,
305
+ ) {
306
+ const errorId = useId();
307
+ const has = hasMessage(error);
308
+ return {
309
+ isInvalid: Boolean(invalid) || has,
310
+ describedBy: mergeDescribedBy(describedBy, has && errorId),
311
+ errorEl: has ? (
312
+ <p id={errorId} className={FIELD_ERROR_CLASS}>
313
+ {error}
314
+ </p>
315
+ ) : null,
316
+ };
317
+ }
318
+
319
+ /** The anchored suggestion list's box and rows — one look for {@link Combobox},
320
+ * {@link InlineEntityCombobox} and {@link Autocomplete}. */
321
+ export const SUGGESTION_LIST_CLASS =
322
+ "max-h-64 overflow-y-auto rounded-md border border-[var(--border)] bg-[var(--bg-surface)] py-1 shadow-lg";
323
+
324
+ export const suggestionRowClass = (isActive: boolean) =>
325
+ cn(
326
+ "block w-full truncate px-3 py-1.5 text-left text-sm text-[var(--text-primary)]",
327
+ isActive ? "bg-[var(--bg-active)]" : "hover:bg-[var(--bg-hover)]",
328
+ );
329
+
199
330
  /**
200
331
  * Keep the option the keyboard is on inside the visible part of the list.
201
332
  *
@@ -236,6 +367,8 @@ export interface ComboboxPanelProps<V extends string | number>
236
367
  * desktop, where the panel sits under the field that already says it. */
237
368
  sheetTitle?: ReactNode;
238
369
  closeLabel?: string;
370
+ /** Default: `combobox.loadError` from the {@link UiKitProvider}, else English. */
371
+ loadErrorLabel?: string;
239
372
  }
240
373
 
241
374
  /**
@@ -277,6 +410,7 @@ export function ComboboxPanel<V extends string | number>({
277
410
  createContent,
278
411
  sheetTitle,
279
412
  closeLabel,
413
+ loadErrorLabel,
280
414
  className,
281
415
  style,
282
416
  ...rest
@@ -290,6 +424,8 @@ export function ComboboxPanel<V extends string | number>({
290
424
  setQuery,
291
425
  results,
292
426
  busy,
427
+ failed,
428
+ tooShort,
293
429
  active,
294
430
  setActive,
295
431
  closeToTrigger,
@@ -337,6 +473,51 @@ export function ComboboxPanel<V extends string | number>({
337
473
  }
338
474
  };
339
475
 
476
+ // What the list holds, said in ONE place that is both on screen and a polite
477
+ // live region. Focus stays in the search box, so the list changing under it was
478
+ // silent — a reader typed and heard nothing back, and a failed lookup was the same
479
+ // silence as a slow one. Outside the listbox, which may own only options.
480
+ //
481
+ // Three different empties, and only one of them is "nothing matched": a lookup
482
+ // that failed must not claim there is nothing to find, and a query under
483
+ // `minChars` was never asked. With rows showing, the count is for the reader only.
484
+ const message: ReactNode =
485
+ busy && results.length === 0 ? (
486
+ // The ellipsis is the picture; the words are for a reader, who otherwise met
487
+ // an empty-looking list with a lone "…" in it — or, in most readers, nothing
488
+ // at all, since punctuation alone is skipped.
489
+ <>
490
+ <span aria-hidden>…</span>
491
+ <span className="sr-only">{labels.loading}</span>
492
+ </>
493
+ ) : !busy && results.length === 0 && !showCreate ? (
494
+ failed ? (
495
+ (loadErrorLabel ?? labels.loadError)
496
+ ) : tooShort ? (
497
+ labels.minChars(core.minChars)
498
+ ) : (
499
+ (emptyLabel ?? labels.noResults)
500
+ )
501
+ ) : null;
502
+ // `relative` so the sr-only text has a local containing block (see
503
+ // sr-only-containment.test).
504
+ const announcement = (
505
+ <div
506
+ role="status"
507
+ aria-live="polite"
508
+ className={cn(
509
+ "relative",
510
+ message !== null && cn("py-2 text-sm", isPhone ? "px-4" : "px-3"),
511
+ failed ? "text-[var(--danger)]" : "text-[var(--text-muted)]",
512
+ )}
513
+ >
514
+ {message ??
515
+ (results.length > 0 && (
516
+ <span className="sr-only">{labels.resultCount(results.length)}</span>
517
+ ))}
518
+ </div>
519
+ );
520
+
340
521
  if (!core.open || typeof document === "undefined") return null;
341
522
 
342
523
  const list = (
@@ -346,21 +527,6 @@ export function ComboboxPanel<V extends string | number>({
346
527
  aria-multiselectable={multi}
347
528
  className={cn("min-h-0 flex-1 overflow-y-auto py-1", isPhone && "flex-none")}
348
529
  >
349
- {busy && results.length === 0 && (
350
- // The ellipsis is the picture; the words are for a reader, who otherwise met
351
- // an empty-looking list with a lone "…" in it — or, in most readers, nothing
352
- // at all, since punctuation alone is skipped. `relative` so the sr-only text
353
- // has a local containing block (see sr-only-containment.test).
354
- <li className="relative px-3 py-2 text-sm text-[var(--text-muted)]">
355
- <span aria-hidden>…</span>
356
- <span className="sr-only">{labels.loading}</span>
357
- </li>
358
- )}
359
- {!busy && results.length === 0 && !showCreate && (
360
- <li className="px-3 py-2 text-sm text-[var(--text-muted)]">
361
- {emptyLabel ?? labels.noResults}
362
- </li>
363
- )}
364
530
  {results.map((o, i) => {
365
531
  const selected = isSelected(o.value);
366
532
  // One heading per group rather than a grey suffix on every row (#136), and
@@ -488,7 +654,10 @@ export function ComboboxPanel<V extends string | number>({
488
654
  inputRef={inputRef}
489
655
  closeLabel={closeLabel}
490
656
  >
491
- <div onKeyDown={onKeyDown}>{list}</div>
657
+ <div onKeyDown={onKeyDown} className="relative">
658
+ {list}
659
+ {announcement}
660
+ </div>
492
661
  </PickerSheet>
493
662
  );
494
663
  }
@@ -534,6 +703,7 @@ export function ComboboxPanel<V extends string | number>({
534
703
  {/* One list, two containers: the desktop panel and the phone sheet render
535
704
  the same rows through the same handlers (live #200). */}
536
705
  {list}
706
+ {announcement}
537
707
  </div>,
538
708
  document.body,
539
709
  );
@@ -8,13 +8,19 @@ import { useDropdown } from "./dropdown";
8
8
  import { useAnchoredPanel } from "../hooks/use-anchored-panel";
9
9
  import { useMediaQuery } from "../hooks/use-media-query";
10
10
  import { PickerSheet, SHEET_ROW_CLASS } from "./picker-sheet";
11
- import { useActiveOptionScroll, type ComboOption } from "./combobox-core";
11
+ import {
12
+ SUGGESTION_LIST_CLASS,
13
+ suggestionRowClass,
14
+ useActiveOptionScroll,
15
+ useComboboxFieldError,
16
+ type ComboOption,
17
+ } from "./combobox-core";
12
18
  import { DEFAULT_COMBOBOX_LABELS, useKitLabels } from "../i18n/kit-labels";
13
19
 
14
20
  // One look for both combobox flavors below — the suggestion list and its rows
15
- // must stay pixel-identical between the free-text and the id-keyed variant.
16
- const LIST_CLASS =
17
- "max-h-64 overflow-y-auto rounded-md border border-[var(--border)] bg-[var(--bg-surface)] py-1 shadow-lg";
21
+ // must stay pixel-identical between the free-text and the id-keyed variant (and
22
+ // `Autocomplete`, which shares them from the core).
23
+ const LIST_CLASS = SUGGESTION_LIST_CLASS;
18
24
 
19
25
  /**
20
26
  * "This focus came from a mouse button that is not the left one" — for the two
@@ -105,11 +111,7 @@ function SuggestionList({
105
111
  );
106
112
  }
107
113
 
108
- const rowClass = (isActive: boolean) =>
109
- cn(
110
- "block w-full truncate px-3 py-1.5 text-left text-sm text-[var(--text-primary)]",
111
- isActive ? "bg-[var(--bg-active)]" : "hover:bg-[var(--bg-hover)]",
112
- );
114
+ const rowClass = suggestionRowClass;
113
115
 
114
116
  /**
115
117
  * Four of the div's own attributes are omitted because this component already owns
@@ -133,6 +135,14 @@ export interface ComboboxProps
133
135
  * delete the `[&_input]:…` copy of {@link FIELD_INVALID} it had been carrying
134
136
  * because this component had no `invalid` of its own. */
135
137
  invalid?: boolean;
138
+ /** What is wrong with the value, as {@link Input}'s `error`: rendered under the
139
+ * field, on the `<input>`'s `aria-describedby`, and implies `invalid` (lenkbank's
140
+ * scope fields, which moved here off `<Input list>` + `<datalist>`). */
141
+ error?: ReactNode;
142
+ /** As `<input disabled>`: no focus, no list, the field's settled look. The chevron
143
+ * stops toggling with it — it is a mouse target the input's own `disabled` does
144
+ * not reach. */
145
+ disabled?: boolean;
136
146
  /** Heading an option belongs under. Supplying it makes this list read exactly
137
147
  * like {@link InlineEntityCombobox}'s — one heading per group with its rows
138
148
  * indented beneath — instead of a flat list (feedback #136 rework: the payee
@@ -211,6 +221,8 @@ export function Combobox({
211
221
  closeLabel,
212
222
  createLabel,
213
223
  invalid,
224
+ error,
225
+ disabled,
214
226
  optionAdornment,
215
227
  autoFocus,
216
228
  onBlur,
@@ -218,6 +230,7 @@ export function Combobox({
218
230
  "aria-label": ariaLabel,
219
231
  ...rest
220
232
  }: ComboboxProps) {
233
+ const field = useComboboxFieldError(error, invalid);
221
234
  const generated = useId();
222
235
  const fieldId = id ?? generated;
223
236
  // Derived from the GENERATED id, never from `id`: a caller's id is theirs to
@@ -373,8 +386,10 @@ export function Combobox({
373
386
  aria-controls={listboxId}
374
387
  aria-activedescendant={activeId}
375
388
  aria-autocomplete="list"
376
- aria-invalid={invalid || undefined}
389
+ aria-invalid={field.isInvalid || undefined}
390
+ aria-describedby={field.describedBy}
377
391
  autoComplete="off"
392
+ disabled={disabled}
378
393
  autoFocus={autoFocus}
379
394
  onBlur={onBlur}
380
395
  onMouseDown={primaryOnly.onMouseDown}
@@ -444,21 +459,30 @@ export function Combobox({
444
459
  // the desktop list is capped at 8 rows — so jumping it would be worth
445
460
  // almost nothing and would cost the one gesture that field is used with.
446
461
  }}
447
- className={cn(FIELD_BASE, label !== undefined && FIELD_FLOATING_PAD, "pr-9", invalid && FIELD_INVALID)}
462
+ className={cn(
463
+ FIELD_BASE,
464
+ label !== undefined && FIELD_FLOATING_PAD,
465
+ "pr-9",
466
+ field.isInvalid && FIELD_INVALID,
467
+ )}
448
468
  />
449
469
  <ChevronDown
450
470
  aria-hidden
451
471
  onMouseDown={(e) => {
452
472
  // Toggle on the chevron without stealing focus from the input.
453
473
  e.preventDefault();
454
- setOpen((o) => !o);
474
+ if (!disabled) setOpen((o) => !o);
455
475
  }}
456
- className="absolute right-2.5 top-1/2 size-4 -translate-y-1/2 cursor-pointer text-[var(--text-placeholder)]"
476
+ className={cn(
477
+ "absolute right-2.5 top-1/2 size-4 -translate-y-1/2 text-[var(--text-placeholder)]",
478
+ disabled ? "opacity-50" : "cursor-pointer",
479
+ )}
457
480
  />
458
481
  </div>
482
+ {field.errorEl}
459
483
  {isPhone && (
460
484
  <PickerSheet
461
- open={open}
485
+ open={open && !disabled}
462
486
  onClose={() => setOpen(false)}
463
487
  title={label}
464
488
  // The sheet's input IS the field: this is a free-text control, so what is
@@ -523,7 +547,7 @@ export function Combobox({
523
547
  </ul>
524
548
  </PickerSheet>
525
549
  )}
526
- {!isPhone && open && (matches.length > 0 || createRow) && (
550
+ {!isPhone && open && !disabled && (matches.length > 0 || createRow) && (
527
551
  <SuggestionList id={listboxId} anchorRef={fieldRef} panelRef={panelRef}>
528
552
  {createRow && (
529
553
  <li role="presentation">
@@ -609,6 +633,8 @@ export interface InlineEntityComboboxProps<V extends string | number>
609
633
  value: V | null;
610
634
  /** Required and unanswered — see {@link Combobox}'s `invalid`. */
611
635
  invalid?: boolean;
636
+ /** What is wrong with the value — see {@link Combobox}'s `error`. */
637
+ error?: ReactNode;
612
638
  /** A picked/typed option emits its id; emptying the field emits `null`. */
613
639
  onChange: (v: V | null) => void;
614
640
  options: ComboOption<V>[];
@@ -676,9 +702,11 @@ export function InlineEntityCombobox<V extends string | number>({
676
702
  clearable,
677
703
  clearLabel,
678
704
  invalid,
705
+ error,
679
706
  "aria-label": ariaLabel,
680
707
  ...rest
681
708
  }: InlineEntityComboboxProps<V>) {
709
+ const field = useComboboxFieldError(error, invalid);
682
710
  const generated = useId();
683
711
  const fieldId = id ?? generated;
684
712
  // See the twin above on why these hang off the generated id.
@@ -816,7 +844,8 @@ export function InlineEntityCombobox<V extends string | number>({
816
844
  aria-controls={listboxId}
817
845
  aria-activedescendant={activeId}
818
846
  aria-autocomplete="list"
819
- aria-invalid={invalid || undefined}
847
+ aria-invalid={field.isInvalid || undefined}
848
+ aria-describedby={field.describedBy}
820
849
  autoComplete="off"
821
850
  disabled={disabled}
822
851
  autoFocus={autoFocus}
@@ -875,7 +904,12 @@ export function InlineEntityCombobox<V extends string | number>({
875
904
  // Home/End stay with the caret — see the note in {@link Combobox}: this
876
905
  // field is editable, and its text is what `reconcile` judges.
877
906
  }}
878
- className={cn(FIELD_BASE, label !== undefined && FIELD_FLOATING_PAD, "pr-9", invalid && FIELD_INVALID)}
907
+ className={cn(
908
+ FIELD_BASE,
909
+ label !== undefined && FIELD_FLOATING_PAD,
910
+ "pr-9",
911
+ field.isInvalid && FIELD_INVALID,
912
+ )}
879
913
  />
880
914
  {showClear ? (
881
915
  <button
@@ -906,9 +940,11 @@ export function InlineEntityCombobox<V extends string | number>({
906
940
  <ChevronDown
907
941
  aria-hidden
908
942
  onMouseDown={(e) => {
909
- // Toggle on the chevron without stealing focus from the input.
943
+ // Toggle on the chevron without stealing focus from the input — and
944
+ // not at all on a disabled field, which the input's own `disabled`
945
+ // does not stop from here.
910
946
  e.preventDefault();
911
- setOpen((o) => !o);
947
+ if (!disabled) setOpen((o) => !o);
912
948
  }}
913
949
  className={cn(
914
950
  "absolute right-2.5 top-1/2 size-4 -translate-y-1/2 text-[var(--text-placeholder)]",
@@ -917,9 +953,10 @@ export function InlineEntityCombobox<V extends string | number>({
917
953
  />
918
954
  )}
919
955
  </div>
956
+ {field.errorEl}
920
957
  {isPhone && (
921
958
  <PickerSheet
922
- open={open}
959
+ open={open && !disabled}
923
960
  // Closing without choosing keeps the value: `text` was never emptied, so
924
961
  // reconcile has nothing to undo — it just puts the label back.
925
962
  onClose={reconcile}
@@ -966,7 +1003,7 @@ export function InlineEntityCombobox<V extends string | number>({
966
1003
  </ul>
967
1004
  </PickerSheet>
968
1005
  )}
969
- {!isPhone && open && matches.length > 0 && (
1006
+ {!isPhone && open && !disabled && matches.length > 0 && (
970
1007
  <SuggestionList id={listboxId} anchorRef={fieldRef} panelRef={panelRef}>
971
1008
  {matches.map((o, i) => (
972
1009
  // A group heading is emitted at each group boundary rather than repeating
@@ -4,7 +4,12 @@ import { Check } from "lucide-react";
4
4
  import { FieldChevron, FieldLabel, FIELD_TRIGGER, FIELD_INVALID, FIELD_FLOATING_PAD } from "./ui";
5
5
  import { cn } from "../lib/cn";
6
6
  import { DropdownPanel, DropdownSearchHeader, useDropdownSearch } from "./dropdown";
7
- import { DEFAULT_CURRENCY_LABELS, useKitLabels, useKitLocale } from "../i18n/kit-labels";
7
+ import {
8
+ DEFAULT_COMMON_LABELS,
9
+ DEFAULT_CURRENCY_LABELS,
10
+ useKitLabels,
11
+ useKitLocale,
12
+ } from "../i18n/kit-labels";
8
13
 
9
14
  export interface CurrencyOption {
10
15
  code: string;
@@ -159,6 +164,7 @@ export function CurrencySelect({
159
164
  const { open, setOpen, wrapperRef, query, setQuery, inputRef } = useDropdownSearch();
160
165
  // `labels` > `currency` from the provider > English.
161
166
  const text = useKitLabels("currency", DEFAULT_CURRENCY_LABELS, labels);
167
+ const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
162
168
  const locale = useKitLocale();
163
169
 
164
170
  const selected = getCurrency(value);
@@ -180,6 +186,14 @@ export function CurrencySelect({
180
186
 
181
187
  const listboxId = `${useId()}-listbox`;
182
188
 
189
+ // The trigger's NAME. A combobox does not take its name from its content, and the
190
+ // floating label is a <span>, not a <label for> — so without `aria-label` from the
191
+ // caller this trigger had no name at all (keksdose's test suite, 0.5.0). Composed the
192
+ // way MultiSelect and the entity comboboxes compose theirs, "label: value", with the
193
+ // `currency` word standing in for a missing label so it is never nameless.
194
+ const fieldName = typeof label === "string" ? label : text.currency;
195
+ const name = ariaLabel ?? common.fieldValue(fieldName, selected ? selected.code : (placeholder ?? text.currency));
196
+
183
197
  return (
184
198
  // The wrapper takes `rest`; the trigger takes the name. This field's label is a
185
199
  // floating <span> rather than a <label for>, so a caller who wants the control to
@@ -188,7 +202,7 @@ export function CurrencySelect({
188
202
  {label !== undefined && <FieldLabel>{label}</FieldLabel>}
189
203
  <button
190
204
  type="button"
191
- aria-label={ariaLabel}
205
+ aria-label={name}
192
206
  onClick={() => setOpen((v) => !v)}
193
207
  // The implicit `button` role supports neither `aria-expanded` nor
194
208
  // `aria-invalid`, so this trigger previously wore a red border and announced