@eifi1/ui-kit 0.5.1 → 0.6.1

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 (136) 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 +103 -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/checkbox.d.ts +9 -0
  8. package/dist/components/checkbox.js +7 -2
  9. package/dist/components/checkbox.js.map +1 -1
  10. package/dist/components/chip.d.ts +3 -2
  11. package/dist/components/chip.js +14 -1
  12. package/dist/components/chip.js.map +1 -1
  13. package/dist/components/choice-card.d.ts +100 -0
  14. package/dist/components/choice-card.js +170 -0
  15. package/dist/components/choice-card.js.map +1 -0
  16. package/dist/components/combobox-core.d.ts +76 -6
  17. package/dist/components/combobox-core.js +119 -49
  18. package/dist/components/combobox-core.js.map +1 -1
  19. package/dist/components/combobox.d.ts +12 -2
  20. package/dist/components/combobox.js +42 -17
  21. package/dist/components/combobox.js.map +1 -1
  22. package/dist/components/danger-confirm.d.ts +91 -0
  23. package/dist/components/danger-confirm.js +181 -0
  24. package/dist/components/danger-confirm.js.map +1 -0
  25. package/dist/components/dialog-frame.d.ts +84 -0
  26. package/dist/components/dialog-frame.js +86 -0
  27. package/dist/components/dialog-frame.js.map +1 -0
  28. package/dist/components/disclosure.d.ts +108 -0
  29. package/dist/components/disclosure.js +127 -0
  30. package/dist/components/disclosure.js.map +1 -0
  31. package/dist/components/entity-combobox.d.ts +17 -3
  32. package/dist/components/entity-combobox.js +25 -5
  33. package/dist/components/entity-combobox.js.map +1 -1
  34. package/dist/components/file-button.d.ts +161 -0
  35. package/dist/components/file-button.js +243 -0
  36. package/dist/components/file-button.js.map +1 -0
  37. package/dist/components/file-dropzone.d.ts +72 -23
  38. package/dist/components/file-dropzone.js +219 -94
  39. package/dist/components/file-dropzone.js.map +1 -1
  40. package/dist/components/icon-picker.d.ts +72 -0
  41. package/dist/components/icon-picker.js +104 -0
  42. package/dist/components/icon-picker.js.map +1 -0
  43. package/dist/components/mini-calendar.d.ts +3 -0
  44. package/dist/components/mini-calendar.js +4 -3
  45. package/dist/components/mini-calendar.js.map +1 -1
  46. package/dist/components/modal.d.ts +8 -1
  47. package/dist/components/modal.js +4 -2
  48. package/dist/components/modal.js.map +1 -1
  49. package/dist/components/multi-entity-combobox.d.ts +16 -3
  50. package/dist/components/multi-entity-combobox.js +25 -5
  51. package/dist/components/multi-entity-combobox.js.map +1 -1
  52. package/dist/components/number-field.d.ts +41 -1
  53. package/dist/components/number-field.js +42 -10
  54. package/dist/components/number-field.js.map +1 -1
  55. package/dist/components/number-input.d.ts +35 -2
  56. package/dist/components/number-input.js +35 -4
  57. package/dist/components/number-input.js.map +1 -1
  58. package/dist/components/numpad-sheet.d.ts +7 -0
  59. package/dist/components/search-field.d.ts +16 -0
  60. package/dist/components/search-field.js +29 -7
  61. package/dist/components/search-field.js.map +1 -1
  62. package/dist/components/signature-pad.d.ts +43 -1
  63. package/dist/components/signature-pad.js +74 -2
  64. package/dist/components/signature-pad.js.map +1 -1
  65. package/dist/components/swatch-picker.d.ts +69 -0
  66. package/dist/components/swatch-picker.js +75 -0
  67. package/dist/components/swatch-picker.js.map +1 -0
  68. package/dist/components/switch.d.ts +9 -0
  69. package/dist/components/switch.js +7 -2
  70. package/dist/components/switch.js.map +1 -1
  71. package/dist/components/tile-radio.d.ts +50 -0
  72. package/dist/components/tile-radio.js +140 -0
  73. package/dist/components/tile-radio.js.map +1 -0
  74. package/dist/components/toggle-group.d.ts +27 -5
  75. package/dist/components/toggle-group.js +22 -15
  76. package/dist/components/toggle-group.js.map +1 -1
  77. package/dist/components/ui.d.ts +150 -12
  78. package/dist/components/ui.js +196 -22
  79. package/dist/components/ui.js.map +1 -1
  80. package/dist/i18n/defaults.d.ts +7 -0
  81. package/dist/i18n/defaults.js +13 -2
  82. package/dist/i18n/defaults.js.map +1 -1
  83. package/dist/i18n/kit-labels.d.ts +38 -5
  84. package/dist/i18n/kit-labels.js +12 -4
  85. package/dist/i18n/kit-labels.js.map +1 -1
  86. package/dist/index.d.ts +16 -7
  87. package/dist/index.js +12 -0
  88. package/dist/index.js.map +1 -1
  89. package/dist/lib/table-text.d.ts +127 -0
  90. package/dist/lib/table-text.js +82 -0
  91. package/dist/lib/table-text.js.map +1 -0
  92. package/dist/rhf/form.d.ts +79 -0
  93. package/dist/rhf/form.js +143 -0
  94. package/dist/rhf/form.js.map +1 -0
  95. package/dist/rhf.d.ts +4 -0
  96. package/dist/rhf.js +3 -0
  97. package/dist/rhf.js.map +1 -0
  98. package/dist/shell/app-shell.js +3 -1
  99. package/dist/shell/app-shell.js.map +1 -1
  100. package/dist/table-text.d.ts +1 -0
  101. package/dist/table-text.js +3 -0
  102. package/dist/table-text.js.map +1 -0
  103. package/package.json +14 -1
  104. package/src/components/autocomplete.tsx +429 -0
  105. package/src/components/checkbox.tsx +16 -0
  106. package/src/components/chip.tsx +24 -2
  107. package/src/components/choice-card.tsx +305 -0
  108. package/src/components/combobox-core.tsx +228 -58
  109. package/src/components/combobox.tsx +58 -21
  110. package/src/components/danger-confirm.tsx +286 -0
  111. package/src/components/dialog-frame.tsx +179 -0
  112. package/src/components/disclosure.tsx +259 -0
  113. package/src/components/entity-combobox.tsx +41 -6
  114. package/src/components/file-button.tsx +458 -0
  115. package/src/components/file-dropzone.tsx +323 -117
  116. package/src/components/icon-picker.tsx +181 -0
  117. package/src/components/mini-calendar.tsx +7 -3
  118. package/src/components/modal.tsx +10 -2
  119. package/src/components/multi-entity-combobox.tsx +40 -6
  120. package/src/components/number-field.tsx +86 -10
  121. package/src/components/number-input.tsx +79 -2
  122. package/src/components/search-field.tsx +49 -6
  123. package/src/components/signature-pad.tsx +112 -0
  124. package/src/components/swatch-picker.tsx +141 -0
  125. package/src/components/switch.tsx +16 -0
  126. package/src/components/tile-radio.tsx +228 -0
  127. package/src/components/toggle-group.tsx +54 -18
  128. package/src/components/ui.tsx +400 -24
  129. package/src/i18n/defaults.ts +12 -1
  130. package/src/i18n/kit-labels.tsx +45 -5
  131. package/src/index.ts +19 -0
  132. package/src/lib/table-text.ts +265 -0
  133. package/src/rhf/form.tsx +300 -0
  134. package/src/rhf.ts +9 -0
  135. package/src/shell/app-shell.tsx +3 -1
  136. package/src/table-text.ts +8 -0
@@ -1,6 +1,6 @@
1
1
  "use client";
2
- import { jsx, jsxs } from "react/jsx-runtime";
3
- import { Fragment, useEffect, useMemo, useRef, useState } from "react";
2
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
+ import { Fragment as Fragment2, useEffect, useId, useMemo, useRef, useState } from "react";
4
4
  import { createPortal } from "react-dom";
5
5
  import { Check, Plus } from "lucide-react";
6
6
  import { cn } from "../lib/cn.js";
@@ -11,10 +11,63 @@ import { PHONE_QUERY } from "./ui.js";
11
11
  import { useAnchoredPanel } from "../hooks/use-anchored-panel.js";
12
12
  import { useEscapeKey, useOutsideClick } from "../hooks/use-dismiss.js";
13
13
  import { DEFAULT_COMBOBOX_LABELS, useKitLabels } from "../i18n/kit-labels.js";
14
- function useComboboxCore({
14
+ import { hasMessage, mergeDescribedBy } from "./choice-parts.js";
15
+ function useOptionSource({
15
16
  options,
16
17
  loadOptions,
17
- loading
18
+ loading,
19
+ filter = true,
20
+ minChars = 0,
21
+ debounceMs = 150,
22
+ query,
23
+ active
24
+ }) {
25
+ const isAsync = typeof loadOptions === "function";
26
+ const tooShort = query.trim().length < minChars;
27
+ const [settled, setSettled] = useState({ query: null, results: [], failed: false });
28
+ const loadRef = useRef(loadOptions);
29
+ useEffect(() => {
30
+ loadRef.current = loadOptions;
31
+ });
32
+ const reqId = useRef(0);
33
+ useEffect(() => {
34
+ const id = ++reqId.current;
35
+ if (!active || !isAsync || tooShort) return;
36
+ const t = setTimeout(
37
+ async () => {
38
+ try {
39
+ const r = await loadRef.current(query);
40
+ if (reqId.current === id) setSettled({ query, results: r, failed: false });
41
+ } catch {
42
+ if (reqId.current === id) setSettled({ query, results: [], failed: true });
43
+ }
44
+ },
45
+ query ? debounceMs : 0
46
+ );
47
+ return () => clearTimeout(t);
48
+ }, [query, active, isAsync, tooShort, debounceMs]);
49
+ const clientResults = useMemo(() => {
50
+ if (isAsync) return [];
51
+ const src = options ?? [];
52
+ const q = query.trim().toLowerCase();
53
+ if (!filter || !q) return src;
54
+ return src.filter(
55
+ (o) => o.label.toLowerCase().includes(q) || (o.sublabel ?? "").toLowerCase().includes(q)
56
+ );
57
+ }, [isAsync, options, query, filter]);
58
+ const pending = isAsync && active && !tooShort && settled.query !== query;
59
+ const failed = isAsync && !tooShort && settled.failed;
60
+ return {
61
+ results: tooShort ? [] : isAsync ? failed ? [] : settled.results : clientResults,
62
+ busy: Boolean(loading) || pending,
63
+ failed: failed && !pending,
64
+ tooShort
65
+ };
66
+ }
67
+ function useComboboxCore({
68
+ options,
69
+ minChars = 0,
70
+ ...source
18
71
  }) {
19
72
  const triggerRef = useRef(null);
20
73
  const panelRef = useRef(null);
@@ -22,12 +75,6 @@ function useComboboxCore({
22
75
  const [open, setOpen] = useState(false);
23
76
  const [query, setQuery] = useState("");
24
77
  const [active, setActive] = useState(0);
25
- const isAsync = typeof loadOptions === "function";
26
- const [asyncResults, setAsyncResults] = useState([]);
27
- const [asyncLoading, setAsyncLoading] = useState(false);
28
- const loadRef = useRef(loadOptions);
29
- loadRef.current = loadOptions;
30
- const reqId = useRef(0);
31
78
  const close = () => setOpen(false);
32
79
  const closeToTrigger = () => {
33
80
  setOpen(false);
@@ -44,32 +91,13 @@ function useComboboxCore({
44
91
  const id = requestAnimationFrame(() => inputRef.current?.focus());
45
92
  return () => cancelAnimationFrame(id);
46
93
  }, [open]);
47
- useEffect(() => {
48
- if (!open || !isAsync) return;
49
- const id = ++reqId.current;
50
- setAsyncLoading(true);
51
- const run = async () => {
52
- try {
53
- const r = await loadRef.current(query);
54
- if (reqId.current === id) setAsyncResults(r);
55
- } finally {
56
- if (reqId.current === id) setAsyncLoading(false);
57
- }
58
- };
59
- const t = setTimeout(run, query ? 150 : 0);
60
- return () => clearTimeout(t);
61
- }, [query, open, isAsync]);
62
- const clientResults = useMemo(() => {
63
- if (isAsync) return [];
64
- const src = options ?? [];
65
- const q = query.trim().toLowerCase();
66
- if (!q) return src;
67
- return src.filter(
68
- (o) => o.label.toLowerCase().includes(q) || (o.sublabel ?? "").toLowerCase().includes(q)
69
- );
70
- }, [isAsync, options, query]);
71
- const results = isAsync ? asyncResults : clientResults;
72
- const busy = Boolean(loading) || isAsync && asyncLoading;
94
+ const { results, busy, failed, tooShort } = useOptionSource({
95
+ ...source,
96
+ options,
97
+ minChars,
98
+ query,
99
+ active: open
100
+ });
73
101
  const cacheRef = useRef(/* @__PURE__ */ new Map());
74
102
  useEffect(() => {
75
103
  for (const o of options ?? []) cacheRef.current.set(o.value, o);
@@ -91,6 +119,9 @@ function useComboboxCore({
91
119
  setActive,
92
120
  results,
93
121
  busy,
122
+ failed,
123
+ tooShort,
124
+ minChars,
94
125
  rect,
95
126
  placement,
96
127
  cacheRef,
@@ -98,6 +129,21 @@ function useComboboxCore({
98
129
  closeToTrigger
99
130
  };
100
131
  }
132
+ const FIELD_ERROR_CLASS = "mt-1 text-[11px] leading-tight text-[var(--danger)]";
133
+ function useComboboxFieldError(error, invalid, describedBy) {
134
+ const errorId = useId();
135
+ const has = hasMessage(error);
136
+ return {
137
+ isInvalid: Boolean(invalid) || has,
138
+ describedBy: mergeDescribedBy(describedBy, has && errorId),
139
+ errorEl: has ? /* @__PURE__ */ jsx("p", { id: errorId, className: FIELD_ERROR_CLASS, children: error }) : null
140
+ };
141
+ }
142
+ const SUGGESTION_LIST_CLASS = "max-h-64 overflow-y-auto rounded-md border border-[var(--border)] bg-[var(--bg-surface)] py-1 shadow-lg";
143
+ const suggestionRowClass = (isActive) => cn(
144
+ "block w-full truncate px-3 py-1.5 text-left text-sm text-[var(--text-primary)]",
145
+ isActive ? "bg-[var(--bg-active)]" : "hover:bg-[var(--bg-hover)]"
146
+ );
101
147
  function useActiveOptionScroll(activeId) {
102
148
  useEffect(() => {
103
149
  if (!activeId) return;
@@ -117,6 +163,7 @@ function ComboboxPanel({
117
163
  createContent,
118
164
  sheetTitle,
119
165
  closeLabel,
166
+ loadErrorLabel,
120
167
  className,
121
168
  style,
122
169
  ...rest
@@ -130,6 +177,8 @@ function ComboboxPanel({
130
177
  setQuery,
131
178
  results,
132
179
  busy,
180
+ failed,
181
+ tooShort,
133
182
  active,
134
183
  setActive,
135
184
  closeToTrigger
@@ -162,6 +211,28 @@ function ComboboxPanel({
162
211
  closeToTrigger();
163
212
  }
164
213
  };
214
+ const message = busy && results.length === 0 ? (
215
+ // The ellipsis is the picture; the words are for a reader, who otherwise met
216
+ // an empty-looking list with a lone "…" in it — or, in most readers, nothing
217
+ // at all, since punctuation alone is skipped.
218
+ /* @__PURE__ */ jsxs(Fragment, { children: [
219
+ /* @__PURE__ */ jsx("span", { "aria-hidden": true, children: "\u2026" }),
220
+ /* @__PURE__ */ jsx("span", { className: "sr-only", children: labels.loading })
221
+ ] })
222
+ ) : !busy && results.length === 0 && !showCreate ? failed ? loadErrorLabel ?? labels.loadError : tooShort ? labels.minChars(core.minChars) : emptyLabel ?? labels.noResults : null;
223
+ const announcement = /* @__PURE__ */ jsx(
224
+ "div",
225
+ {
226
+ role: "status",
227
+ "aria-live": "polite",
228
+ className: cn(
229
+ "relative",
230
+ message !== null && cn("py-2 text-sm", isPhone ? "px-4" : "px-3"),
231
+ failed ? "text-[var(--danger)]" : "text-[var(--text-muted)]"
232
+ ),
233
+ children: message ?? (results.length > 0 && /* @__PURE__ */ jsx("span", { className: "sr-only", children: labels.resultCount(results.length) }))
234
+ }
235
+ );
165
236
  if (!core.open || typeof document === "undefined") return null;
166
237
  const list = /* @__PURE__ */ jsxs(
167
238
  "ul",
@@ -171,15 +242,6 @@ function ComboboxPanel({
171
242
  "aria-multiselectable": multi,
172
243
  className: cn("min-h-0 flex-1 overflow-y-auto py-1", isPhone && "flex-none"),
173
244
  children: [
174
- busy && results.length === 0 && // The ellipsis is the picture; the words are for a reader, who otherwise met
175
- // an empty-looking list with a lone "…" in it — or, in most readers, nothing
176
- // at all, since punctuation alone is skipped. `relative` so the sr-only text
177
- // has a local containing block (see sr-only-containment.test).
178
- /* @__PURE__ */ jsxs("li", { className: "relative px-3 py-2 text-sm text-[var(--text-muted)]", children: [
179
- /* @__PURE__ */ jsx("span", { "aria-hidden": true, children: "\u2026" }),
180
- /* @__PURE__ */ jsx("span", { className: "sr-only", children: labels.loading })
181
- ] }),
182
- !busy && results.length === 0 && !showCreate && /* @__PURE__ */ jsx("li", { className: "px-3 py-2 text-sm text-[var(--text-muted)]", children: emptyLabel ?? labels.noResults }),
183
245
  results.map((o, i) => {
184
246
  const selected = isSelected(o.value);
185
247
  const startsGroup = o.group != null && o.group !== results[i - 1]?.group;
@@ -187,7 +249,7 @@ function ComboboxPanel({
187
249
  // Keyed by group AND value: an option may deliberately appear twice —
188
250
  // Keksdose repeats recently-used categories in a "Recent" group at the
189
251
  // top (live #203) — and a bare value key would collide.
190
- /* @__PURE__ */ jsxs(Fragment, { children: [
252
+ /* @__PURE__ */ jsxs(Fragment2, { children: [
191
253
  startsGroup && /* @__PURE__ */ jsx(
192
254
  "li",
193
255
  {
@@ -282,7 +344,10 @@ function ComboboxPanel({
282
344
  searchPlaceholder: searchPlaceholder ?? labels.search,
283
345
  inputRef,
284
346
  closeLabel,
285
- children: /* @__PURE__ */ jsx("div", { onKeyDown, children: list })
347
+ children: /* @__PURE__ */ jsxs("div", { onKeyDown, className: "relative", children: [
348
+ list,
349
+ announcement
350
+ ] })
286
351
  }
287
352
  );
288
353
  }
@@ -319,7 +384,8 @@ function ComboboxPanel({
319
384
  activeId
320
385
  }
321
386
  ),
322
- list
387
+ list,
388
+ announcement
323
389
  ]
324
390
  }
325
391
  ),
@@ -328,7 +394,11 @@ function ComboboxPanel({
328
394
  }
329
395
  export {
330
396
  ComboboxPanel,
397
+ SUGGESTION_LIST_CLASS,
398
+ suggestionRowClass,
331
399
  useActiveOptionScroll,
332
- useComboboxCore
400
+ useComboboxCore,
401
+ useComboboxFieldError,
402
+ useOptionSource
333
403
  };
334
404
  //# sourceMappingURL=combobox-core.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/combobox-core.tsx"],"sourcesContent":["import { Fragment, useEffect, useMemo, useRef, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport type {\n ComponentPropsWithoutRef,\n Dispatch,\n KeyboardEvent as ReactKeyboardEvent,\n ReactNode,\n RefObject,\n SetStateAction,\n} from \"react\";\nimport { Check, Plus } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { DropdownSearchHeader } from \"./dropdown\";\nimport { PickerSheet, SHEET_ROW_CLASS } from \"./picker-sheet\";\nimport { useMediaQuery } from \"../hooks/use-media-query\";\nimport { PHONE_QUERY } from \"./ui\";\nimport { type AnchorRect } from \"../hooks/use-anchored-rect\";\nimport { useAnchoredPanel, type AnchoredPanel } from \"../hooks/use-anchored-panel\";\nimport { useEscapeKey, useOutsideClick } from \"../hooks/use-dismiss\";\nimport { DEFAULT_COMBOBOX_LABELS, useKitLabels } from \"../i18n/kit-labels\";\n\nexport interface ComboOption<V extends string | number> {\n value: V;\n label: string;\n sublabel?: string;\n icon?: ReactNode;\n /**\n * Section this option belongs to. Where a consumer renders it as a heading with the\n * members indented beneath — {@link InlineEntityCombobox} does — this is a genuine\n * grouping, not decoration: the name appears once per section rather than trailing\n * every row in small grey type, which is barely legible on a phone (feedback #136).\n * Searchable like `sublabel`.\n */\n group?: string;\n}\n\nexport interface ComboboxCoreOptions<V extends string | number> {\n /** Already-loaded options (client-side filtered), also used to resolve labels. */\n options?: ComboOption<V>[];\n /** Async option source, debounced and race-safe; stale responses are ignored. */\n loadOptions?: (query: string) => Promise<ComboOption<V>[]>;\n /** External loading flag, OR-ed with the internal async state. */\n loading?: boolean;\n}\n\nexport interface ComboboxCore<V extends string | number> {\n triggerRef: RefObject<HTMLButtonElement | null>;\n panelRef: RefObject<HTMLDivElement | null>;\n inputRef: RefObject<HTMLInputElement | null>;\n open: boolean;\n setOpen: Dispatch<SetStateAction<boolean>>;\n query: string;\n setQuery: Dispatch<SetStateAction<string>>;\n active: number;\n setActive: Dispatch<SetStateAction<number>>;\n results: ComboOption<V>[];\n busy: boolean;\n rect: AnchorRect | null;\n /** Where the dropdown goes, clamped to the visible viewport (feedback #135). */\n placement: AnchoredPanel;\n cacheRef: RefObject<Map<V, ComboOption<V>>>;\n resolve: (v: V) => ComboOption<V> | null;\n /** Close and hand focus back to the trigger — the keyboard paths only. See\n * {@link useDropdown} for why a pointer dismissal must not do this. */\n closeToTrigger: () => void;\n}\n\n/**\n * Shared plumbing for the entity pickers: open/query/active state, portalled\n * anchoring + outside-click/Escape dismissal, focus-on-open, a debounced\n * race-safe async (or client-side) result list, and a label cache so a selected\n * value still renders its label after the async list has moved on. The single-\n * and multi-value comboboxes render their triggers on top of this and share\n * {@link ComboboxPanel} for the dropdown.\n */\nexport function useComboboxCore<V extends string | number>({\n options,\n loadOptions,\n loading,\n}: ComboboxCoreOptions<V>): ComboboxCore<V> {\n const triggerRef = useRef<HTMLButtonElement>(null);\n const panelRef = useRef<HTMLDivElement>(null);\n const inputRef = useRef<HTMLInputElement>(null);\n const [open, setOpen] = useState(false);\n const [query, setQuery] = useState(\"\");\n const [active, setActive] = useState(0);\n\n const isAsync = typeof loadOptions === \"function\";\n const [asyncResults, setAsyncResults] = useState<ComboOption<V>[]>([]);\n const [asyncLoading, setAsyncLoading] = useState(false);\n const loadRef = useRef(loadOptions);\n loadRef.current = loadOptions;\n const reqId = useRef(0);\n\n const close = () => setOpen(false);\n // Escape unmounts the panel the user is typing in; without this the browser drops\n // focus on <body>, so the caret disappears and the next Tab restarts at the top of\n // the page. A pointer dismissal deliberately does NOT do this — the press has\n // already decided where focus belongs.\n const closeToTrigger = () => {\n setOpen(false);\n triggerRef.current?.focus();\n };\n // Placement, not just the anchor rect: the panel has to dodge the on-screen\n // keyboard its own search box summons (feedback #135).\n const placement = useAnchoredPanel(triggerRef, open);\n const rect = placement.rect;\n // Outside-click dismissal belongs to the ANCHORED panel, and only to it. On a\n // phone `ComboboxPanel` renders a full-screen `PickerSheet` instead (live #200),\n // portalled to <body> and carrying its own ways out — the close X, Escape, and\n // Back through `useOverlayHistory` — so there is no outside left to press.\n //\n // Leaving the listener on there was not merely redundant, it was the bug:\n // `panelRef` is attached to the desktop panel alone, so with the sheet up every\n // press inside it answered \"outside\". While `useOutsideClick` listened for\n // `mousedown` the rows survived by accident — they commit on `onMouseDown` too,\n // and won the race — but `pointerdown` PRECEDES `mousedown`, so the sheet closed\n // at finger-down and the commit landed on an unmounted tree: a tapped row kept the\n // old value, and tapping the search box or the X was equally fatal. `PickerSheet`\n // stops `mousedown` for exactly this reason, but that guard was written for\n // `useDropdown`'s listener and never covered this hook.\n const isPhone = useMediaQuery(PHONE_QUERY, false);\n useOutsideClick([triggerRef, panelRef], close, open && !isPhone);\n useEscapeKey(closeToTrigger, open);\n\n // Reset the query + focus the search box each time the panel opens.\n useEffect(() => {\n if (!open) return;\n setQuery(\"\");\n const id = requestAnimationFrame(() => inputRef.current?.focus());\n return () => cancelAnimationFrame(id);\n }, [open]);\n\n // Debounced, race-safe async search (only when loadOptions is provided).\n useEffect(() => {\n if (!open || !isAsync) return;\n const id = ++reqId.current;\n setAsyncLoading(true);\n const run = async () => {\n try {\n const r = await loadRef.current!(query);\n if (reqId.current === id) setAsyncResults(r);\n } finally {\n if (reqId.current === id) setAsyncLoading(false);\n }\n };\n const t = setTimeout(run, query ? 150 : 0);\n return () => clearTimeout(t);\n }, [query, open, isAsync]);\n\n const clientResults = useMemo(() => {\n if (isAsync) return [];\n const src = options ?? [];\n const q = query.trim().toLowerCase();\n if (!q) return src;\n return src.filter(\n (o) => o.label.toLowerCase().includes(q) || (o.sublabel ?? \"\").toLowerCase().includes(q),\n );\n }, [isAsync, options, query]);\n\n const results = isAsync ? asyncResults : clientResults;\n const busy = Boolean(loading) || (isAsync && asyncLoading);\n\n // Accumulate every option we've seen so a selected value can render its label\n // even after the async list has moved on to other query results.\n const cacheRef = useRef(new Map<V, ComboOption<V>>());\n useEffect(() => {\n for (const o of options ?? []) cacheRef.current.set(o.value, o);\n for (const o of results) cacheRef.current.set(o.value, o);\n }, [options, results]);\n const resolve = (v: V): ComboOption<V> | null =>\n options?.find((o) => o.value === v) ?? cacheRef.current.get(v) ?? null;\n\n // Reset the highlighted row whenever the visible set changes.\n useEffect(() => {\n setActive(0);\n }, [query, open]);\n\n return {\n triggerRef,\n panelRef,\n inputRef,\n open,\n setOpen,\n query,\n setQuery,\n active,\n setActive,\n results,\n busy,\n rect,\n placement,\n cacheRef,\n resolve,\n closeToTrigger,\n };\n}\n\n/**\n * Keep the option the keyboard is on inside the visible part of the list.\n *\n * `aria-activedescendant` moves a highlight WITHOUT moving focus, which is the whole\n * point of it — and the cost is that the browser scrolls nothing on its own. In a\n * panel capped at a few hundred pixels over an account list two hundred long, Down\n * walked the highlight straight out of the box and the list sat still.\n *\n * Optional-called because jsdom does not implement `scrollIntoView`: three consumers\n * run this component in their own suites, and a picker that throws there is a worse\n * trade than a list that does not scroll in a test.\n */\nexport function useActiveOptionScroll(activeId: string | undefined): void {\n useEffect(() => {\n if (!activeId) return;\n document.getElementById(activeId)?.scrollIntoView?.({ block: \"nearest\" });\n }, [activeId]);\n}\n\nexport interface ComboboxPanelProps<V extends string | number>\n extends ComponentPropsWithoutRef<\"div\"> {\n core: ComboboxCore<V>;\n /** What the trigger's `aria-controls` names. Owned by the component that renders\n * the trigger, not by the core: one id has to reach both, and only one of them\n * can be the origin. */\n listboxId: string;\n /** Default: `combobox.search` from the {@link UiKitProvider}, else English. */\n searchPlaceholder?: string;\n /** Default: `combobox.noResults` from the {@link UiKitProvider}, else English. */\n emptyLabel?: string;\n multi?: boolean;\n isSelected: (v: V) => boolean;\n onChoose: (o: ComboOption<V>) => void;\n showCreate: boolean;\n onCreate: () => void;\n createContent: ReactNode;\n /** What the sheet calls itself on a phone — the field's label. Ignored on\n * desktop, where the panel sits under the field that already says it. */\n sheetTitle?: ReactNode;\n closeLabel?: string;\n}\n\n/**\n * The portalled dropdown (search header + result rows + optional create row)\n * shared by the single- and multi-value comboboxes. Left-aligned to the trigger\n * and sized to its width; `multi` swaps the trailing check for a leading\n * checkbox. `onChoose` decides whether to close (single) or stay open (multi).\n *\n * ## The ARIA, which is `CommandPalette`'s\n *\n * This rendered a `role=\"listbox\"` of `role=\"option\"` rows and stopped there, so a\n * reader was handed a text box and silence: the highlight the arrow keys move lived\n * only in a background colour, and nothing tied the box being typed in to the list\n * being filtered. `CommandPalette` has had the whole model since it was written —\n * `aria-controls` from the field to the list, `aria-activedescendant` naming the row\n * the keyboard is on — and it was the only thing in the package that did.\n *\n * Two distinct states, deliberately not conflated:\n * - `aria-selected` is CHOSEN. It is the check mark, and in `multi` the checkbox.\n * - `aria-activedescendant` is where the KEYBOARD is. Announcing that as selected\n * would tell a reader that the row they are merely passing over is the answer.\n *\n * `role=\"option\"` sits on the `<button>`, not on the `<li>` around it, for the same\n * reason it does in `CommandPalette`: an option may not contain a separately\n * focusable control, and the button is the thing a pointer presses. The rows are\n * `tabIndex={-1}` because focus never leaves the search box — that is what\n * `aria-activedescendant` is FOR.\n */\nexport function ComboboxPanel<V extends string | number>({\n core,\n listboxId,\n searchPlaceholder,\n emptyLabel,\n multi,\n isSelected,\n onChoose,\n showCreate,\n onCreate,\n createContent,\n sheetTitle,\n closeLabel,\n className,\n style,\n ...rest\n}: ComboboxPanelProps<V>) {\n const {\n rect,\n placement,\n panelRef,\n inputRef,\n query,\n setQuery,\n results,\n busy,\n active,\n setActive,\n closeToTrigger,\n } = core;\n // The phone gets a full-screen sheet instead of an anchored panel (live #200).\n // Same core, same results, same handlers — only the container differs, so the\n // two presentations cannot drift in what they offer.\n const isPhone = useMediaQuery(PHONE_QUERY, false);\n const rowCount = results.length + (showCreate ? 1 : 0);\n const optionId = (index: number) => `${listboxId}-option-${index}`;\n const activeId = active >= 0 && active < rowCount ? optionId(active) : undefined;\n useActiveOptionScroll(activeId);\n // The consumers resolve their own props against the provider already; this is\n // for the loading row, which no consumer names, and for a caller that renders the\n // panel directly and leaves the two optional strings out.\n const labels = useKitLabels(\"combobox\", DEFAULT_COMBOBOX_LABELS);\n\n // The WAI-ARIA APG's listbox keyboard, in full: Up/Down step, Home/End jump, Enter\n // commits, Tab leaves. Escape is the one key handled elsewhere — `useComboboxCore`\n // registers it on the document, so it answers wherever focus has ended up.\n const onKeyDown = (e: ReactKeyboardEvent) => {\n if (e.key === \"ArrowDown\") {\n e.preventDefault();\n setActive((i) => Math.min(i + 1, rowCount - 1));\n } else if (e.key === \"ArrowUp\") {\n e.preventDefault();\n setActive((i) => Math.max(i - 1, 0));\n } else if (e.key === \"Home\") {\n e.preventDefault();\n setActive(0);\n } else if (e.key === \"End\") {\n e.preventDefault();\n setActive(rowCount - 1);\n } else if (e.key === \"Enter\") {\n e.preventDefault();\n if (active < results.length) onChoose(results[active]);\n else if (showCreate) onCreate();\n } else if (e.key === \"Tab\") {\n // The panel is PORTALLED to <body>, so the browser's own Tab would move from\n // here to whatever follows the portal — i.e. off the end of the document. The\n // trade is one extra Tab (trigger, then onward) against never stranding focus\n // at the bottom of the page.\n e.preventDefault();\n closeToTrigger();\n }\n };\n\n if (!core.open || typeof document === \"undefined\") return null;\n\n const list = (\n <ul\n id={listboxId}\n role=\"listbox\"\n aria-multiselectable={multi}\n className={cn(\"min-h-0 flex-1 overflow-y-auto py-1\", isPhone && \"flex-none\")}\n >\n {busy && results.length === 0 && (\n // The ellipsis is the picture; the words are for a reader, who otherwise met\n // an empty-looking list with a lone \"…\" in it — or, in most readers, nothing\n // at all, since punctuation alone is skipped. `relative` so the sr-only text\n // has a local containing block (see sr-only-containment.test).\n <li className=\"relative px-3 py-2 text-sm text-[var(--text-muted)]\">\n <span aria-hidden>…</span>\n <span className=\"sr-only\">{labels.loading}</span>\n </li>\n )}\n {!busy && results.length === 0 && !showCreate && (\n <li className=\"px-3 py-2 text-sm text-[var(--text-muted)]\">\n {emptyLabel ?? labels.noResults}\n </li>\n )}\n {results.map((o, i) => {\n const selected = isSelected(o.value);\n // One heading per group rather than a grey suffix on every row (#136), and\n // the thing the native <select>'s <optgroup> used to give the account\n // picker for free (live #200). `results` keeps its source order, so a\n // boundary is simply \"different from the row above\".\n const startsGroup = o.group != null && o.group !== results[i - 1]?.group;\n return (\n // Keyed by group AND value: an option may deliberately appear twice —\n // Keksdose repeats recently-used categories in a \"Recent\" group at the\n // top (live #203) — and a bare value key would collide.\n <Fragment key={`${o.group ?? \"\"}|${String(o.value)}`}>\n {startsGroup && (\n <li\n role=\"presentation\"\n className={cn(\n \"font-semibold uppercase tracking-wide text-[var(--text-muted)]\",\n isPhone ? \"px-4 pb-1 pt-3 text-xs\" : \"px-3 pb-0.5 pt-2 text-[11px] first:pt-1\",\n )}\n >\n {o.group}\n </li>\n )}\n <li role=\"presentation\">\n <button\n type=\"button\"\n id={optionId(i)}\n role=\"option\"\n aria-selected={selected}\n tabIndex={-1}\n onMouseDown={(e) => {\n e.preventDefault();\n onChoose(o);\n }}\n onMouseEnter={() => setActive(i)}\n className={cn(\n isPhone\n ? SHEET_ROW_CLASS\n : \"flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm\",\n i === active && !isPhone\n ? \"bg-[var(--bg-active)]\"\n : !isPhone && \"hover:bg-[var(--bg-hover)]\",\n )}\n >\n {multi && (\n <span\n // The checkbox is a PICTURE of `aria-selected` above, so a reader\n // that met both would be told twice.\n aria-hidden\n className={cn(\n \"flex size-4 shrink-0 items-center justify-center rounded border\",\n selected\n ? \"border-[var(--bg-inverse)] bg-[var(--bg-inverse)] text-[var(--text-inverse)]\"\n : \"border-[var(--border-strong)] bg-[var(--bg-surface)]\",\n )}\n >\n {selected && <Check className=\"size-3\" />}\n </span>\n )}\n {o.icon && <span className=\"shrink-0\">{o.icon}</span>}\n <span className=\"min-w-0 flex-1\">\n <span className=\"block truncate text-[var(--text-primary)]\">{o.label}</span>\n {o.sublabel && (\n <span className=\"block truncate text-xs text-[var(--text-placeholder)]\">\n {o.sublabel}\n </span>\n )}\n </span>\n {!multi && selected && <Check className=\"size-4 shrink-0 text-[var(--brand)]\" />}\n </button>\n </li>\n </Fragment>\n );\n })}\n {showCreate && (\n <li role=\"presentation\">\n <button\n type=\"button\"\n id={optionId(results.length)}\n role=\"option\"\n // Never chosen — it is an action, and it disappears the moment it has\n // been taken. Whether the keyboard is ON it is `aria-activedescendant`'s\n // job to say.\n aria-selected={false}\n tabIndex={-1}\n onMouseDown={(e) => {\n e.preventDefault();\n onCreate();\n }}\n onMouseEnter={() => setActive(results.length)}\n className={cn(\n isPhone\n ? SHEET_ROW_CLASS\n : \"flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm\",\n \"text-[var(--text-secondary)]\",\n active === results.length && !isPhone\n ? \"bg-[var(--bg-active)]\"\n : !isPhone && \"hover:bg-[var(--bg-hover)]\",\n )}\n >\n <Plus className=\"size-4 shrink-0\" />\n <span className=\"truncate\">{createContent}</span>\n </button>\n </li>\n )}\n </ul>\n );\n\n if (isPhone) {\n return (\n <PickerSheet\n // First, so the sheet's own props below win: `rest` is typed as div\n // attributes, and the DOM's `title` is a tooltip string where the sheet's is\n // its heading — one name, two meanings, and the sheet's is the one the caller\n // asked for.\n {...rest}\n className={className}\n style={style}\n open\n onClose={() => core.setOpen(false)}\n title={sheetTitle}\n query={query}\n onQueryChange={setQuery}\n searchPlaceholder={searchPlaceholder ?? labels.search}\n inputRef={inputRef}\n closeLabel={closeLabel}\n >\n <div onKeyDown={onKeyDown}>{list}</div>\n </PickerSheet>\n );\n }\n\n if (!rect) return null;\n\n return createPortal(\n <div\n // Spread FIRST: `onKeyDown` below is the listbox keyboard (Up/Down/Home/End/\n // Enter/Tab) and the placement is measured rather than chosen, so neither is a\n // caller's to replace by accident — while a `data-*` anchor or an\n // `aria-describedby` has nothing to collide with.\n {...rest}\n ref={panelRef}\n onKeyDown={onKeyDown}\n // `placement` keeps the panel inside the region actually on screen: on a phone\n // the search box below pulls up the keyboard, and a panel pinned under a\n // low trigger would otherwise sit entirely behind it (feedback #135).\n style={{\n ...style,\n position: \"fixed\",\n top: placement.top,\n left: rect.left,\n width: rect.width,\n minWidth: 220,\n maxHeight: placement.maxHeight,\n }}\n className={cn(\n \"z-50 flex flex-col rounded-md border border-[var(--border)] bg-[var(--bg-surface)] shadow-lg\",\n className,\n )}\n >\n <DropdownSearchHeader\n query={query}\n onQueryChange={setQuery}\n inputRef={inputRef}\n placeholder={searchPlaceholder ?? labels.search}\n // Focus is in this box while the list is up, so this is what has to name the\n // list and the row the arrows are on.\n listboxId={listboxId}\n activeId={activeId}\n />\n {/* One list, two containers: the desktop panel and the phone sheet render\n the same rows through the same handlers (live #200). */}\n {list}\n </div>,\n document.body,\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/components/combobox-core.tsx"],"sourcesContent":["import { Fragment, useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport type {\n ComponentPropsWithoutRef,\n Dispatch,\n KeyboardEvent as ReactKeyboardEvent,\n ReactNode,\n RefObject,\n SetStateAction,\n} from \"react\";\nimport { Check, Plus } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { DropdownSearchHeader } from \"./dropdown\";\nimport { PickerSheet, SHEET_ROW_CLASS } from \"./picker-sheet\";\nimport { useMediaQuery } from \"../hooks/use-media-query\";\nimport { PHONE_QUERY } from \"./ui\";\nimport { type AnchorRect } from \"../hooks/use-anchored-rect\";\nimport { useAnchoredPanel, type AnchoredPanel } from \"../hooks/use-anchored-panel\";\nimport { useEscapeKey, useOutsideClick } from \"../hooks/use-dismiss\";\nimport { DEFAULT_COMBOBOX_LABELS, useKitLabels } from \"../i18n/kit-labels\";\nimport { hasMessage, mergeDescribedBy } from \"./choice-parts\";\n\nexport interface ComboOption<V extends string | number> {\n value: V;\n label: string;\n sublabel?: string;\n icon?: ReactNode;\n /**\n * Section this option belongs to. Where a consumer renders it as a heading with the\n * members indented beneath — {@link InlineEntityCombobox} does — this is a genuine\n * grouping, not decoration: the name appears once per section rather than trailing\n * every row in small grey type, which is barely legible on a phone (feedback #136).\n * Searchable like `sublabel`.\n */\n group?: string;\n}\n\n/**\n * Where a combobox's rows come from, and how a query narrows them. Shared by the\n * trigger pickers ({@link useComboboxCore}) and the inline {@link Autocomplete}, so\n * the two cannot drift in what a failed lookup or a too-short query does.\n */\nexport interface OptionSourceOptions<V extends string | number> {\n /** Already-loaded options (client-side filtered unless `filter` is false), also\n * used to resolve labels. */\n options?: ComboOption<V>[];\n /** Async option source, debounced and race-safe; stale responses are ignored,\n * and a rejection is caught and reported as `failed`. */\n loadOptions?: (query: string) => Promise<ComboOption<V>[]>;\n /** External loading flag, OR-ed with the internal async state. */\n loading?: boolean;\n /**\n * Narrow `options` by the query (label/sublabel substring). Default `true`.\n *\n * `false` shows `options` exactly as given, in the given order — for a list the\n * caller has already searched and ranked on a server (Keksdose's geocoder), which a\n * second, client-side substring filter could only get wrong: a provider that\n * matches \"Bahnhofstr.\" to \"Bahnhofstrasse\" is right, and a label filter would\n * throw that row away.\n */\n filter?: boolean;\n /**\n * Below this many (trimmed) characters nothing is offered and `loadOptions` is not\n * called. Default `0` — the pickers list everything on open. Set it for a source\n * that is costly or rate-limited: one letter against a geocoder matches half a\n * continent, and a request per opened panel is a request nobody asked for.\n */\n minChars?: number;\n /** Quiet time after the last keystroke before `loadOptions` runs. Default 150 ms. */\n debounceMs?: number;\n}\n\nexport interface OptionSource<V extends string | number> {\n results: ComboOption<V>[];\n busy: boolean;\n /** The last lookup for this query REJECTED. The rows are emptied with it: results\n * from an earlier query under a failed one would read as the answer to it. */\n failed: boolean;\n /** The query is shorter than `minChars`, so nothing was asked. */\n tooShort: boolean;\n}\n\n/**\n * The rows half of a combobox: client-side filtering, or a debounced, race-safe\n * `loadOptions` with its loading and failure states.\n *\n * `active` gates the async side — a closed list asks nothing. Loading is DERIVED\n * (\"the settled answer is for a different query\") rather than flagged on in the\n * effect, so there is no render between a keystroke and the debounce in which an\n * empty list could claim \"No results\" for a question still being asked.\n */\nexport function useOptionSource<V extends string | number>({\n options,\n loadOptions,\n loading,\n filter = true,\n minChars = 0,\n debounceMs = 150,\n query,\n active,\n}: OptionSourceOptions<V> & { query: string; active: boolean }): OptionSource<V> {\n const isAsync = typeof loadOptions === \"function\";\n const tooShort = query.trim().length < minChars;\n const [settled, setSettled] = useState<{\n query: string | null;\n results: ComboOption<V>[];\n failed: boolean;\n }>({ query: null, results: [], failed: false });\n const loadRef = useRef(loadOptions);\n useEffect(() => {\n loadRef.current = loadOptions;\n });\n const reqId = useRef(0);\n\n useEffect(() => {\n // Bumped even when nothing is asked, so a request still in flight from a longer\n // query cannot land after the user has deleted back below `minChars`.\n const id = ++reqId.current;\n if (!active || !isAsync || tooShort) return;\n const t = setTimeout(\n async () => {\n try {\n const r = await loadRef.current!(query);\n if (reqId.current === id) setSettled({ query, results: r, failed: false });\n } catch {\n // The one path the old `try/finally` had no answer for: the list kept the\n // previous query's rows and said nothing (Keksdose proposal §2).\n if (reqId.current === id) setSettled({ query, results: [], failed: true });\n }\n },\n query ? debounceMs : 0,\n );\n return () => clearTimeout(t);\n }, [query, active, isAsync, tooShort, debounceMs]);\n\n const clientResults = useMemo(() => {\n if (isAsync) return [];\n const src = options ?? [];\n const q = query.trim().toLowerCase();\n if (!filter || !q) return src;\n return src.filter(\n (o) => o.label.toLowerCase().includes(q) || (o.sublabel ?? \"\").toLowerCase().includes(q),\n );\n }, [isAsync, options, query, filter]);\n\n const pending = isAsync && active && !tooShort && settled.query !== query;\n const failed = isAsync && !tooShort && settled.failed;\n return {\n results: tooShort ? [] : isAsync ? (failed ? [] : settled.results) : clientResults,\n busy: Boolean(loading) || pending,\n failed: failed && !pending,\n tooShort,\n };\n}\n\nexport type ComboboxCoreOptions<V extends string | number> = OptionSourceOptions<V>;\n\nexport interface ComboboxCore<V extends string | number> {\n triggerRef: RefObject<HTMLButtonElement | null>;\n panelRef: RefObject<HTMLDivElement | null>;\n inputRef: RefObject<HTMLInputElement | null>;\n open: boolean;\n setOpen: Dispatch<SetStateAction<boolean>>;\n query: string;\n setQuery: Dispatch<SetStateAction<string>>;\n active: number;\n setActive: Dispatch<SetStateAction<number>>;\n results: ComboOption<V>[];\n busy: boolean;\n /** See {@link OptionSource}. */\n failed: boolean;\n tooShort: boolean;\n minChars: number;\n rect: AnchorRect | null;\n /** Where the dropdown goes, clamped to the visible viewport (feedback #135). */\n placement: AnchoredPanel;\n cacheRef: RefObject<Map<V, ComboOption<V>>>;\n resolve: (v: V) => ComboOption<V> | null;\n /** Close and hand focus back to the trigger — the keyboard paths only. See\n * {@link useDropdown} for why a pointer dismissal must not do this. */\n closeToTrigger: () => void;\n}\n\n/**\n * Shared plumbing for the entity pickers: open/query/active state, portalled\n * anchoring + outside-click/Escape dismissal, focus-on-open, a debounced\n * race-safe async (or client-side) result list, and a label cache so a selected\n * value still renders its label after the async list has moved on. The single-\n * and multi-value comboboxes render their triggers on top of this and share\n * {@link ComboboxPanel} for the dropdown.\n */\nexport function useComboboxCore<V extends string | number>({\n options,\n minChars = 0,\n ...source\n}: ComboboxCoreOptions<V>): ComboboxCore<V> {\n const triggerRef = useRef<HTMLButtonElement>(null);\n const panelRef = useRef<HTMLDivElement>(null);\n const inputRef = useRef<HTMLInputElement>(null);\n const [open, setOpen] = useState(false);\n const [query, setQuery] = useState(\"\");\n const [active, setActive] = useState(0);\n\n const close = () => setOpen(false);\n // Escape unmounts the panel the user is typing in; without this the browser drops\n // focus on <body>, so the caret disappears and the next Tab restarts at the top of\n // the page. A pointer dismissal deliberately does NOT do this — the press has\n // already decided where focus belongs.\n const closeToTrigger = () => {\n setOpen(false);\n triggerRef.current?.focus();\n };\n // Placement, not just the anchor rect: the panel has to dodge the on-screen\n // keyboard its own search box summons (feedback #135).\n const placement = useAnchoredPanel(triggerRef, open);\n const rect = placement.rect;\n // Outside-click dismissal belongs to the ANCHORED panel, and only to it. On a\n // phone `ComboboxPanel` renders a full-screen `PickerSheet` instead (live #200),\n // portalled to <body> and carrying its own ways out — the close X, Escape, and\n // Back through `useOverlayHistory` — so there is no outside left to press.\n //\n // Leaving the listener on there was not merely redundant, it was the bug:\n // `panelRef` is attached to the desktop panel alone, so with the sheet up every\n // press inside it answered \"outside\". While `useOutsideClick` listened for\n // `mousedown` the rows survived by accident — they commit on `onMouseDown` too,\n // and won the race — but `pointerdown` PRECEDES `mousedown`, so the sheet closed\n // at finger-down and the commit landed on an unmounted tree: a tapped row kept the\n // old value, and tapping the search box or the X was equally fatal. `PickerSheet`\n // stops `mousedown` for exactly this reason, but that guard was written for\n // `useDropdown`'s listener and never covered this hook.\n const isPhone = useMediaQuery(PHONE_QUERY, false);\n useOutsideClick([triggerRef, panelRef], close, open && !isPhone);\n useEscapeKey(closeToTrigger, open);\n\n // Reset the query + focus the search box each time the panel opens.\n useEffect(() => {\n if (!open) return;\n setQuery(\"\");\n const id = requestAnimationFrame(() => inputRef.current?.focus());\n return () => cancelAnimationFrame(id);\n }, [open]);\n\n // Debounced, race-safe async search (only when loadOptions is provided) or the\n // client-side filter — see {@link useOptionSource}.\n const { results, busy, failed, tooShort } = useOptionSource<V>({\n ...source,\n options,\n minChars,\n query,\n active: open,\n });\n\n // Accumulate every option we've seen so a selected value can render its label\n // even after the async list has moved on to other query results.\n const cacheRef = useRef(new Map<V, ComboOption<V>>());\n useEffect(() => {\n for (const o of options ?? []) cacheRef.current.set(o.value, o);\n for (const o of results) cacheRef.current.set(o.value, o);\n }, [options, results]);\n const resolve = (v: V): ComboOption<V> | null =>\n options?.find((o) => o.value === v) ?? cacheRef.current.get(v) ?? null;\n\n // Reset the highlighted row whenever the visible set changes.\n useEffect(() => {\n setActive(0);\n }, [query, open]);\n\n return {\n triggerRef,\n panelRef,\n inputRef,\n open,\n setOpen,\n query,\n setQuery,\n active,\n setActive,\n results,\n busy,\n failed,\n tooShort,\n minChars,\n rect,\n placement,\n cacheRef,\n resolve,\n closeToTrigger,\n };\n}\n\n/** The error line under a field — the same type as `ui.tsx`'s (module-private) one,\n * so a combobox's message is indistinguishable from an Input's. */\nconst FIELD_ERROR_CLASS = \"mt-1 text-[11px] leading-tight text-[var(--danger)]\";\n\n/**\n * {@link Input}'s `error` for the combobox family: the message under the field, its\n * id on the control's `aria-describedby` (merged, never replacing — the hint the\n * caller pointed at stays first), and `invalid` implied. ui.tsx's `useFieldError` is\n * module-private, so this restates it on the same rules `choice-parts.ts` does.\n */\nexport function useComboboxFieldError(\n error: ReactNode,\n invalid: boolean | undefined,\n describedBy?: string,\n) {\n const errorId = useId();\n const has = hasMessage(error);\n return {\n isInvalid: Boolean(invalid) || has,\n describedBy: mergeDescribedBy(describedBy, has && errorId),\n errorEl: has ? (\n <p id={errorId} className={FIELD_ERROR_CLASS}>\n {error}\n </p>\n ) : null,\n };\n}\n\n/** The anchored suggestion list's box and rows — one look for {@link Combobox},\n * {@link InlineEntityCombobox} and {@link Autocomplete}. */\nexport const SUGGESTION_LIST_CLASS =\n \"max-h-64 overflow-y-auto rounded-md border border-[var(--border)] bg-[var(--bg-surface)] py-1 shadow-lg\";\n\nexport const suggestionRowClass = (isActive: boolean) =>\n cn(\n \"block w-full truncate px-3 py-1.5 text-left text-sm text-[var(--text-primary)]\",\n isActive ? \"bg-[var(--bg-active)]\" : \"hover:bg-[var(--bg-hover)]\",\n );\n\n/**\n * Keep the option the keyboard is on inside the visible part of the list.\n *\n * `aria-activedescendant` moves a highlight WITHOUT moving focus, which is the whole\n * point of it — and the cost is that the browser scrolls nothing on its own. In a\n * panel capped at a few hundred pixels over an account list two hundred long, Down\n * walked the highlight straight out of the box and the list sat still.\n *\n * Optional-called because jsdom does not implement `scrollIntoView`: three consumers\n * run this component in their own suites, and a picker that throws there is a worse\n * trade than a list that does not scroll in a test.\n */\nexport function useActiveOptionScroll(activeId: string | undefined): void {\n useEffect(() => {\n if (!activeId) return;\n document.getElementById(activeId)?.scrollIntoView?.({ block: \"nearest\" });\n }, [activeId]);\n}\n\nexport interface ComboboxPanelProps<V extends string | number>\n extends ComponentPropsWithoutRef<\"div\"> {\n core: ComboboxCore<V>;\n /** What the trigger's `aria-controls` names. Owned by the component that renders\n * the trigger, not by the core: one id has to reach both, and only one of them\n * can be the origin. */\n listboxId: string;\n /** Default: `combobox.search` from the {@link UiKitProvider}, else English. */\n searchPlaceholder?: string;\n /** Default: `combobox.noResults` from the {@link UiKitProvider}, else English. */\n emptyLabel?: string;\n multi?: boolean;\n isSelected: (v: V) => boolean;\n onChoose: (o: ComboOption<V>) => void;\n showCreate: boolean;\n onCreate: () => void;\n createContent: ReactNode;\n /** What the sheet calls itself on a phone — the field's label. Ignored on\n * desktop, where the panel sits under the field that already says it. */\n sheetTitle?: ReactNode;\n closeLabel?: string;\n /** Default: `combobox.loadError` from the {@link UiKitProvider}, else English. */\n loadErrorLabel?: string;\n}\n\n/**\n * The portalled dropdown (search header + result rows + optional create row)\n * shared by the single- and multi-value comboboxes. Left-aligned to the trigger\n * and sized to its width; `multi` swaps the trailing check for a leading\n * checkbox. `onChoose` decides whether to close (single) or stay open (multi).\n *\n * ## The ARIA, which is `CommandPalette`'s\n *\n * This rendered a `role=\"listbox\"` of `role=\"option\"` rows and stopped there, so a\n * reader was handed a text box and silence: the highlight the arrow keys move lived\n * only in a background colour, and nothing tied the box being typed in to the list\n * being filtered. `CommandPalette` has had the whole model since it was written —\n * `aria-controls` from the field to the list, `aria-activedescendant` naming the row\n * the keyboard is on — and it was the only thing in the package that did.\n *\n * Two distinct states, deliberately not conflated:\n * - `aria-selected` is CHOSEN. It is the check mark, and in `multi` the checkbox.\n * - `aria-activedescendant` is where the KEYBOARD is. Announcing that as selected\n * would tell a reader that the row they are merely passing over is the answer.\n *\n * `role=\"option\"` sits on the `<button>`, not on the `<li>` around it, for the same\n * reason it does in `CommandPalette`: an option may not contain a separately\n * focusable control, and the button is the thing a pointer presses. The rows are\n * `tabIndex={-1}` because focus never leaves the search box — that is what\n * `aria-activedescendant` is FOR.\n */\nexport function ComboboxPanel<V extends string | number>({\n core,\n listboxId,\n searchPlaceholder,\n emptyLabel,\n multi,\n isSelected,\n onChoose,\n showCreate,\n onCreate,\n createContent,\n sheetTitle,\n closeLabel,\n loadErrorLabel,\n className,\n style,\n ...rest\n}: ComboboxPanelProps<V>) {\n const {\n rect,\n placement,\n panelRef,\n inputRef,\n query,\n setQuery,\n results,\n busy,\n failed,\n tooShort,\n active,\n setActive,\n closeToTrigger,\n } = core;\n // The phone gets a full-screen sheet instead of an anchored panel (live #200).\n // Same core, same results, same handlers — only the container differs, so the\n // two presentations cannot drift in what they offer.\n const isPhone = useMediaQuery(PHONE_QUERY, false);\n const rowCount = results.length + (showCreate ? 1 : 0);\n const optionId = (index: number) => `${listboxId}-option-${index}`;\n const activeId = active >= 0 && active < rowCount ? optionId(active) : undefined;\n useActiveOptionScroll(activeId);\n // The consumers resolve their own props against the provider already; this is\n // for the loading row, which no consumer names, and for a caller that renders the\n // panel directly and leaves the two optional strings out.\n const labels = useKitLabels(\"combobox\", DEFAULT_COMBOBOX_LABELS);\n\n // The WAI-ARIA APG's listbox keyboard, in full: Up/Down step, Home/End jump, Enter\n // commits, Tab leaves. Escape is the one key handled elsewhere — `useComboboxCore`\n // registers it on the document, so it answers wherever focus has ended up.\n const onKeyDown = (e: ReactKeyboardEvent) => {\n if (e.key === \"ArrowDown\") {\n e.preventDefault();\n setActive((i) => Math.min(i + 1, rowCount - 1));\n } else if (e.key === \"ArrowUp\") {\n e.preventDefault();\n setActive((i) => Math.max(i - 1, 0));\n } else if (e.key === \"Home\") {\n e.preventDefault();\n setActive(0);\n } else if (e.key === \"End\") {\n e.preventDefault();\n setActive(rowCount - 1);\n } else if (e.key === \"Enter\") {\n e.preventDefault();\n if (active < results.length) onChoose(results[active]);\n else if (showCreate) onCreate();\n } else if (e.key === \"Tab\") {\n // The panel is PORTALLED to <body>, so the browser's own Tab would move from\n // here to whatever follows the portal — i.e. off the end of the document. The\n // trade is one extra Tab (trigger, then onward) against never stranding focus\n // at the bottom of the page.\n e.preventDefault();\n closeToTrigger();\n }\n };\n\n // What the list holds, said in ONE place that is both on screen and a polite\n // live region. Focus stays in the search box, so the list changing under it was\n // silent — a reader typed and heard nothing back, and a failed lookup was the same\n // silence as a slow one. Outside the listbox, which may own only options.\n //\n // Three different empties, and only one of them is \"nothing matched\": a lookup\n // that failed must not claim there is nothing to find, and a query under\n // `minChars` was never asked. With rows showing, the count is for the reader only.\n const message: ReactNode =\n busy && results.length === 0 ? (\n // The ellipsis is the picture; the words are for a reader, who otherwise met\n // an empty-looking list with a lone \"…\" in it — or, in most readers, nothing\n // at all, since punctuation alone is skipped.\n <>\n <span aria-hidden>…</span>\n <span className=\"sr-only\">{labels.loading}</span>\n </>\n ) : !busy && results.length === 0 && !showCreate ? (\n failed ? (\n (loadErrorLabel ?? labels.loadError)\n ) : tooShort ? (\n labels.minChars(core.minChars)\n ) : (\n (emptyLabel ?? labels.noResults)\n )\n ) : null;\n // `relative` so the sr-only text has a local containing block (see\n // sr-only-containment.test).\n const announcement = (\n <div\n role=\"status\"\n aria-live=\"polite\"\n className={cn(\n \"relative\",\n message !== null && cn(\"py-2 text-sm\", isPhone ? \"px-4\" : \"px-3\"),\n failed ? \"text-[var(--danger)]\" : \"text-[var(--text-muted)]\",\n )}\n >\n {message ??\n (results.length > 0 && (\n <span className=\"sr-only\">{labels.resultCount(results.length)}</span>\n ))}\n </div>\n );\n\n if (!core.open || typeof document === \"undefined\") return null;\n\n const list = (\n <ul\n id={listboxId}\n role=\"listbox\"\n aria-multiselectable={multi}\n className={cn(\"min-h-0 flex-1 overflow-y-auto py-1\", isPhone && \"flex-none\")}\n >\n {results.map((o, i) => {\n const selected = isSelected(o.value);\n // One heading per group rather than a grey suffix on every row (#136), and\n // the thing the native <select>'s <optgroup> used to give the account\n // picker for free (live #200). `results` keeps its source order, so a\n // boundary is simply \"different from the row above\".\n const startsGroup = o.group != null && o.group !== results[i - 1]?.group;\n return (\n // Keyed by group AND value: an option may deliberately appear twice —\n // Keksdose repeats recently-used categories in a \"Recent\" group at the\n // top (live #203) — and a bare value key would collide.\n <Fragment key={`${o.group ?? \"\"}|${String(o.value)}`}>\n {startsGroup && (\n <li\n role=\"presentation\"\n className={cn(\n \"font-semibold uppercase tracking-wide text-[var(--text-muted)]\",\n isPhone ? \"px-4 pb-1 pt-3 text-xs\" : \"px-3 pb-0.5 pt-2 text-[11px] first:pt-1\",\n )}\n >\n {o.group}\n </li>\n )}\n <li role=\"presentation\">\n <button\n type=\"button\"\n id={optionId(i)}\n role=\"option\"\n aria-selected={selected}\n tabIndex={-1}\n onMouseDown={(e) => {\n e.preventDefault();\n onChoose(o);\n }}\n onMouseEnter={() => setActive(i)}\n className={cn(\n isPhone\n ? SHEET_ROW_CLASS\n : \"flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm\",\n i === active && !isPhone\n ? \"bg-[var(--bg-active)]\"\n : !isPhone && \"hover:bg-[var(--bg-hover)]\",\n )}\n >\n {multi && (\n <span\n // The checkbox is a PICTURE of `aria-selected` above, so a reader\n // that met both would be told twice.\n aria-hidden\n className={cn(\n \"flex size-4 shrink-0 items-center justify-center rounded border\",\n selected\n ? \"border-[var(--bg-inverse)] bg-[var(--bg-inverse)] text-[var(--text-inverse)]\"\n : \"border-[var(--border-strong)] bg-[var(--bg-surface)]\",\n )}\n >\n {selected && <Check className=\"size-3\" />}\n </span>\n )}\n {o.icon && <span className=\"shrink-0\">{o.icon}</span>}\n <span className=\"min-w-0 flex-1\">\n <span className=\"block truncate text-[var(--text-primary)]\">{o.label}</span>\n {o.sublabel && (\n <span className=\"block truncate text-xs text-[var(--text-placeholder)]\">\n {o.sublabel}\n </span>\n )}\n </span>\n {!multi && selected && <Check className=\"size-4 shrink-0 text-[var(--brand)]\" />}\n </button>\n </li>\n </Fragment>\n );\n })}\n {showCreate && (\n <li role=\"presentation\">\n <button\n type=\"button\"\n id={optionId(results.length)}\n role=\"option\"\n // Never chosen — it is an action, and it disappears the moment it has\n // been taken. Whether the keyboard is ON it is `aria-activedescendant`'s\n // job to say.\n aria-selected={false}\n tabIndex={-1}\n onMouseDown={(e) => {\n e.preventDefault();\n onCreate();\n }}\n onMouseEnter={() => setActive(results.length)}\n className={cn(\n isPhone\n ? SHEET_ROW_CLASS\n : \"flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm\",\n \"text-[var(--text-secondary)]\",\n active === results.length && !isPhone\n ? \"bg-[var(--bg-active)]\"\n : !isPhone && \"hover:bg-[var(--bg-hover)]\",\n )}\n >\n <Plus className=\"size-4 shrink-0\" />\n <span className=\"truncate\">{createContent}</span>\n </button>\n </li>\n )}\n </ul>\n );\n\n if (isPhone) {\n return (\n <PickerSheet\n // First, so the sheet's own props below win: `rest` is typed as div\n // attributes, and the DOM's `title` is a tooltip string where the sheet's is\n // its heading — one name, two meanings, and the sheet's is the one the caller\n // asked for.\n {...rest}\n className={className}\n style={style}\n open\n onClose={() => core.setOpen(false)}\n title={sheetTitle}\n query={query}\n onQueryChange={setQuery}\n searchPlaceholder={searchPlaceholder ?? labels.search}\n inputRef={inputRef}\n closeLabel={closeLabel}\n >\n <div onKeyDown={onKeyDown} className=\"relative\">\n {list}\n {announcement}\n </div>\n </PickerSheet>\n );\n }\n\n if (!rect) return null;\n\n return createPortal(\n <div\n // Spread FIRST: `onKeyDown` below is the listbox keyboard (Up/Down/Home/End/\n // Enter/Tab) and the placement is measured rather than chosen, so neither is a\n // caller's to replace by accident — while a `data-*` anchor or an\n // `aria-describedby` has nothing to collide with.\n {...rest}\n ref={panelRef}\n onKeyDown={onKeyDown}\n // `placement` keeps the panel inside the region actually on screen: on a phone\n // the search box below pulls up the keyboard, and a panel pinned under a\n // low trigger would otherwise sit entirely behind it (feedback #135).\n style={{\n ...style,\n position: \"fixed\",\n top: placement.top,\n left: rect.left,\n width: rect.width,\n minWidth: 220,\n maxHeight: placement.maxHeight,\n }}\n className={cn(\n \"z-50 flex flex-col rounded-md border border-[var(--border)] bg-[var(--bg-surface)] shadow-lg\",\n className,\n )}\n >\n <DropdownSearchHeader\n query={query}\n onQueryChange={setQuery}\n inputRef={inputRef}\n placeholder={searchPlaceholder ?? labels.search}\n // Focus is in this box while the list is up, so this is what has to name the\n // list and the row the arrows are on.\n listboxId={listboxId}\n activeId={activeId}\n />\n {/* One list, two containers: the desktop panel and the phone sheet render\n the same rows through the same handlers (live #200). */}\n {list}\n {announcement}\n </div>,\n document.body,\n );\n}\n"],"mappings":";AAuTM,SAiLA,UAjLA,KAiLA,YAjLA;AAvTN,SAAS,YAAAA,WAAU,WAAW,OAAO,SAAS,QAAQ,gBAAgB;AACtE,SAAS,oBAAoB;AAS7B,SAAS,OAAO,YAAY;AAC5B,SAAS,UAAU;AACnB,SAAS,4BAA4B;AACrC,SAAS,aAAa,uBAAuB;AAC7C,SAAS,qBAAqB;AAC9B,SAAS,mBAAmB;AAE5B,SAAS,wBAA4C;AACrD,SAAS,cAAc,uBAAuB;AAC9C,SAAS,yBAAyB,oBAAoB;AACtD,SAAS,YAAY,wBAAwB;AAuEtC,SAAS,gBAA2C;AAAA,EACzD;AAAA,EACA;AAAA,EACA;AAAA,EACA,SAAS;AAAA,EACT,WAAW;AAAA,EACX,aAAa;AAAA,EACb;AAAA,EACA;AACF,GAAiF;AAC/E,QAAM,UAAU,OAAO,gBAAgB;AACvC,QAAM,WAAW,MAAM,KAAK,EAAE,SAAS;AACvC,QAAM,CAAC,SAAS,UAAU,IAAI,SAI3B,EAAE,OAAO,MAAM,SAAS,CAAC,GAAG,QAAQ,MAAM,CAAC;AAC9C,QAAM,UAAU,OAAO,WAAW;AAClC,YAAU,MAAM;AACd,YAAQ,UAAU;AAAA,EACpB,CAAC;AACD,QAAM,QAAQ,OAAO,CAAC;AAEtB,YAAU,MAAM;AAGd,UAAM,KAAK,EAAE,MAAM;AACnB,QAAI,CAAC,UAAU,CAAC,WAAW,SAAU;AACrC,UAAM,IAAI;AAAA,MACR,YAAY;AACV,YAAI;AACF,gBAAM,IAAI,MAAM,QAAQ,QAAS,KAAK;AACtC,cAAI,MAAM,YAAY,GAAI,YAAW,EAAE,OAAO,SAAS,GAAG,QAAQ,MAAM,CAAC;AAAA,QAC3E,QAAQ;AAGN,cAAI,MAAM,YAAY,GAAI,YAAW,EAAE,OAAO,SAAS,CAAC,GAAG,QAAQ,KAAK,CAAC;AAAA,QAC3E;AAAA,MACF;AAAA,MACA,QAAQ,aAAa;AAAA,IACvB;AACA,WAAO,MAAM,aAAa,CAAC;AAAA,EAC7B,GAAG,CAAC,OAAO,QAAQ,SAAS,UAAU,UAAU,CAAC;AAEjD,QAAM,gBAAgB,QAAQ,MAAM;AAClC,QAAI,QAAS,QAAO,CAAC;AACrB,UAAM,MAAM,WAAW,CAAC;AACxB,UAAM,IAAI,MAAM,KAAK,EAAE,YAAY;AACnC,QAAI,CAAC,UAAU,CAAC,EAAG,QAAO;AAC1B,WAAO,IAAI;AAAA,MACT,CAAC,MAAM,EAAE,MAAM,YAAY,EAAE,SAAS,CAAC,MAAM,EAAE,YAAY,IAAI,YAAY,EAAE,SAAS,CAAC;AAAA,IACzF;AAAA,EACF,GAAG,CAAC,SAAS,SAAS,OAAO,MAAM,CAAC;AAEpC,QAAM,UAAU,WAAW,UAAU,CAAC,YAAY,QAAQ,UAAU;AACpE,QAAM,SAAS,WAAW,CAAC,YAAY,QAAQ;AAC/C,SAAO;AAAA,IACL,SAAS,WAAW,CAAC,IAAI,UAAW,SAAS,CAAC,IAAI,QAAQ,UAAW;AAAA,IACrE,MAAM,QAAQ,OAAO,KAAK;AAAA,IAC1B,QAAQ,UAAU,CAAC;AAAA,IACnB;AAAA,EACF;AACF;AAsCO,SAAS,gBAA2C;AAAA,EACzD;AAAA,EACA,WAAW;AAAA,EACX,GAAG;AACL,GAA4C;AAC1C,QAAM,aAAa,OAA0B,IAAI;AACjD,QAAM,WAAW,OAAuB,IAAI;AAC5C,QAAM,WAAW,OAAyB,IAAI;AAC9C,QAAM,CAAC,MAAM,OAAO,IAAI,SAAS,KAAK;AACtC,QAAM,CAAC,OAAO,QAAQ,IAAI,SAAS,EAAE;AACrC,QAAM,CAAC,QAAQ,SAAS,IAAI,SAAS,CAAC;AAEtC,QAAM,QAAQ,MAAM,QAAQ,KAAK;AAKjC,QAAM,iBAAiB,MAAM;AAC3B,YAAQ,KAAK;AACb,eAAW,SAAS,MAAM;AAAA,EAC5B;AAGA,QAAM,YAAY,iBAAiB,YAAY,IAAI;AACnD,QAAM,OAAO,UAAU;AAevB,QAAM,UAAU,cAAc,aAAa,KAAK;AAChD,kBAAgB,CAAC,YAAY,QAAQ,GAAG,OAAO,QAAQ,CAAC,OAAO;AAC/D,eAAa,gBAAgB,IAAI;AAGjC,YAAU,MAAM;AACd,QAAI,CAAC,KAAM;AACX,aAAS,EAAE;AACX,UAAM,KAAK,sBAAsB,MAAM,SAAS,SAAS,MAAM,CAAC;AAChE,WAAO,MAAM,qBAAqB,EAAE;AAAA,EACtC,GAAG,CAAC,IAAI,CAAC;AAIT,QAAM,EAAE,SAAS,MAAM,QAAQ,SAAS,IAAI,gBAAmB;AAAA,IAC7D,GAAG;AAAA,IACH;AAAA,IACA;AAAA,IACA;AAAA,IACA,QAAQ;AAAA,EACV,CAAC;AAID,QAAM,WAAW,OAAO,oBAAI,IAAuB,CAAC;AACpD,YAAU,MAAM;AACd,eAAW,KAAK,WAAW,CAAC,EAAG,UAAS,QAAQ,IAAI,EAAE,OAAO,CAAC;AAC9D,eAAW,KAAK,QAAS,UAAS,QAAQ,IAAI,EAAE,OAAO,CAAC;AAAA,EAC1D,GAAG,CAAC,SAAS,OAAO,CAAC;AACrB,QAAM,UAAU,CAAC,MACf,SAAS,KAAK,CAAC,MAAM,EAAE,UAAU,CAAC,KAAK,SAAS,QAAQ,IAAI,CAAC,KAAK;AAGpE,YAAU,MAAM;AACd,cAAU,CAAC;AAAA,EACb,GAAG,CAAC,OAAO,IAAI,CAAC;AAEhB,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF;AACF;AAIA,MAAM,oBAAoB;AAQnB,SAAS,sBACd,OACA,SACA,aACA;AACA,QAAM,UAAU,MAAM;AACtB,QAAM,MAAM,WAAW,KAAK;AAC5B,SAAO;AAAA,IACL,WAAW,QAAQ,OAAO,KAAK;AAAA,IAC/B,aAAa,iBAAiB,aAAa,OAAO,OAAO;AAAA,IACzD,SAAS,MACP,oBAAC,OAAE,IAAI,SAAS,WAAW,mBACxB,iBACH,IACE;AAAA,EACN;AACF;AAIO,MAAM,wBACX;AAEK,MAAM,qBAAqB,CAAC,aACjC;AAAA,EACE;AAAA,EACA,WAAW,0BAA0B;AACvC;AAcK,SAAS,sBAAsB,UAAoC;AACxE,YAAU,MAAM;AACd,QAAI,CAAC,SAAU;AACf,aAAS,eAAe,QAAQ,GAAG,iBAAiB,EAAE,OAAO,UAAU,CAAC;AAAA,EAC1E,GAAG,CAAC,QAAQ,CAAC;AACf;AAqDO,SAAS,cAAyC;AAAA,EACvD;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,GAAG;AACL,GAA0B;AACxB,QAAM;AAAA,IACJ;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF,IAAI;AAIJ,QAAM,UAAU,cAAc,aAAa,KAAK;AAChD,QAAM,WAAW,QAAQ,UAAU,aAAa,IAAI;AACpD,QAAM,WAAW,CAAC,UAAkB,GAAG,SAAS,WAAW,KAAK;AAChE,QAAM,WAAW,UAAU,KAAK,SAAS,WAAW,SAAS,MAAM,IAAI;AACvE,wBAAsB,QAAQ;AAI9B,QAAM,SAAS,aAAa,YAAY,uBAAuB;AAK/D,QAAM,YAAY,CAAC,MAA0B;AAC3C,QAAI,EAAE,QAAQ,aAAa;AACzB,QAAE,eAAe;AACjB,gBAAU,CAAC,MAAM,KAAK,IAAI,IAAI,GAAG,WAAW,CAAC,CAAC;AAAA,IAChD,WAAW,EAAE,QAAQ,WAAW;AAC9B,QAAE,eAAe;AACjB,gBAAU,CAAC,MAAM,KAAK,IAAI,IAAI,GAAG,CAAC,CAAC;AAAA,IACrC,WAAW,EAAE,QAAQ,QAAQ;AAC3B,QAAE,eAAe;AACjB,gBAAU,CAAC;AAAA,IACb,WAAW,EAAE,QAAQ,OAAO;AAC1B,QAAE,eAAe;AACjB,gBAAU,WAAW,CAAC;AAAA,IACxB,WAAW,EAAE,QAAQ,SAAS;AAC5B,QAAE,eAAe;AACjB,UAAI,SAAS,QAAQ,OAAQ,UAAS,QAAQ,MAAM,CAAC;AAAA,eAC5C,WAAY,UAAS;AAAA,IAChC,WAAW,EAAE,QAAQ,OAAO;AAK1B,QAAE,eAAe;AACjB,qBAAe;AAAA,IACjB;AAAA,EACF;AAUA,QAAM,UACJ,QAAQ,QAAQ,WAAW;AAAA;AAAA;AAAA;AAAA,IAIzB,iCACE;AAAA,0BAAC,UAAK,eAAW,MAAC,oBAAC;AAAA,MACnB,oBAAC,UAAK,WAAU,WAAW,iBAAO,SAAQ;AAAA,OAC5C;AAAA,MACE,CAAC,QAAQ,QAAQ,WAAW,KAAK,CAAC,aACpC,SACG,kBAAkB,OAAO,YACxB,WACF,OAAO,SAAS,KAAK,QAAQ,IAE5B,cAAc,OAAO,YAEtB;AAGN,QAAM,eACJ;AAAA,IAAC;AAAA;AAAA,MACC,MAAK;AAAA,MACL,aAAU;AAAA,MACV,WAAW;AAAA,QACT;AAAA,QACA,YAAY,QAAQ,GAAG,gBAAgB,UAAU,SAAS,MAAM;AAAA,QAChE,SAAS,yBAAyB;AAAA,MACpC;AAAA,MAEC,sBACE,QAAQ,SAAS,KAChB,oBAAC,UAAK,WAAU,WAAW,iBAAO,YAAY,QAAQ,MAAM,GAAE;AAAA;AAAA,EAEpE;AAGF,MAAI,CAAC,KAAK,QAAQ,OAAO,aAAa,YAAa,QAAO;AAE1D,QAAM,OACJ;AAAA,IAAC;AAAA;AAAA,MACC,IAAI;AAAA,MACJ,MAAK;AAAA,MACL,wBAAsB;AAAA,MACtB,WAAW,GAAG,uCAAuC,WAAW,WAAW;AAAA,MAE1E;AAAA,gBAAQ,IAAI,CAAC,GAAG,MAAM;AACrB,gBAAM,WAAW,WAAW,EAAE,KAAK;AAKnC,gBAAM,cAAc,EAAE,SAAS,QAAQ,EAAE,UAAU,QAAQ,IAAI,CAAC,GAAG;AACnE;AAAA;AAAA;AAAA;AAAA,YAIE,qBAACA,WAAA,EACE;AAAA,6BACC;AAAA,gBAAC;AAAA;AAAA,kBACC,MAAK;AAAA,kBACL,WAAW;AAAA,oBACT;AAAA,oBACA,UAAU,2BAA2B;AAAA,kBACvC;AAAA,kBAEC,YAAE;AAAA;AAAA,cACL;AAAA,cAEJ,oBAAC,QAAG,MAAK,gBACP;AAAA,gBAAC;AAAA;AAAA,kBACC,MAAK;AAAA,kBACL,IAAI,SAAS,CAAC;AAAA,kBACd,MAAK;AAAA,kBACL,iBAAe;AAAA,kBACf,UAAU;AAAA,kBACV,aAAa,CAAC,MAAM;AAClB,sBAAE,eAAe;AACjB,6BAAS,CAAC;AAAA,kBACZ;AAAA,kBACA,cAAc,MAAM,UAAU,CAAC;AAAA,kBAC/B,WAAW;AAAA,oBACT,UACI,kBACA;AAAA,oBACJ,MAAM,UAAU,CAAC,UACb,0BACA,CAAC,WAAW;AAAA,kBAClB;AAAA,kBAEC;AAAA,6BACC;AAAA,sBAAC;AAAA;AAAA,wBAGC,eAAW;AAAA,wBACX,WAAW;AAAA,0BACT;AAAA,0BACA,WACI,iFACA;AAAA,wBACN;AAAA,wBAEC,sBAAY,oBAAC,SAAM,WAAU,UAAS;AAAA;AAAA,oBACzC;AAAA,oBAED,EAAE,QAAQ,oBAAC,UAAK,WAAU,YAAY,YAAE,MAAK;AAAA,oBAC9C,qBAAC,UAAK,WAAU,kBACd;AAAA,0CAAC,UAAK,WAAU,6CAA6C,YAAE,OAAM;AAAA,sBACpE,EAAE,YACD,oBAAC,UAAK,WAAU,yDACb,YAAE,UACL;AAAA,uBAEJ;AAAA,oBACC,CAAC,SAAS,YAAY,oBAAC,SAAM,WAAU,uCAAsC;AAAA;AAAA;AAAA,cAChF,GACF;AAAA,iBA3De,GAAG,EAAE,SAAS,EAAE,IAAI,OAAO,EAAE,KAAK,CAAC,EA4DlD;AAAA;AAAA,QAEJ,CAAC;AAAA,QACA,cACC,oBAAC,QAAG,MAAK,gBACP;AAAA,UAAC;AAAA;AAAA,YACC,MAAK;AAAA,YACL,IAAI,SAAS,QAAQ,MAAM;AAAA,YAC3B,MAAK;AAAA,YAIL,iBAAe;AAAA,YACf,UAAU;AAAA,YACV,aAAa,CAAC,MAAM;AAClB,gBAAE,eAAe;AACjB,uBAAS;AAAA,YACX;AAAA,YACA,cAAc,MAAM,UAAU,QAAQ,MAAM;AAAA,YAC5C,WAAW;AAAA,cACT,UACI,kBACA;AAAA,cACJ;AAAA,cACA,WAAW,QAAQ,UAAU,CAAC,UAC1B,0BACA,CAAC,WAAW;AAAA,YAClB;AAAA,YAEA;AAAA,kCAAC,QAAK,WAAU,mBAAkB;AAAA,cAClC,oBAAC,UAAK,WAAU,YAAY,yBAAc;AAAA;AAAA;AAAA,QAC5C,GACF;AAAA;AAAA;AAAA,EAEJ;AAGF,MAAI,SAAS;AACX,WACE;AAAA,MAAC;AAAA;AAAA,QAKE,GAAG;AAAA,QACJ;AAAA,QACA;AAAA,QACA,MAAI;AAAA,QACJ,SAAS,MAAM,KAAK,QAAQ,KAAK;AAAA,QACjC,OAAO;AAAA,QACP;AAAA,QACA,eAAe;AAAA,QACf,mBAAmB,qBAAqB,OAAO;AAAA,QAC/C;AAAA,QACA;AAAA,QAEA,+BAAC,SAAI,WAAsB,WAAU,YAClC;AAAA;AAAA,UACA;AAAA,WACH;AAAA;AAAA,IACF;AAAA,EAEJ;AAEA,MAAI,CAAC,KAAM,QAAO;AAElB,SAAO;AAAA,IACL;AAAA,MAAC;AAAA;AAAA,QAKE,GAAG;AAAA,QACJ,KAAK;AAAA,QACL;AAAA,QAIA,OAAO;AAAA,UACL,GAAG;AAAA,UACH,UAAU;AAAA,UACV,KAAK,UAAU;AAAA,UACf,MAAM,KAAK;AAAA,UACX,OAAO,KAAK;AAAA,UACZ,UAAU;AAAA,UACV,WAAW,UAAU;AAAA,QACvB;AAAA,QACA,WAAW;AAAA,UACT;AAAA,UACA;AAAA,QACF;AAAA,QAEA;AAAA;AAAA,YAAC;AAAA;AAAA,cACC;AAAA,cACA,eAAe;AAAA,cACf;AAAA,cACA,aAAa,qBAAqB,OAAO;AAAA,cAGzC;AAAA,cACA;AAAA;AAAA,UACF;AAAA,UAGC;AAAA,UACA;AAAA;AAAA;AAAA,IACH;AAAA,IACA,SAAS;AAAA,EACX;AACF;","names":["Fragment"]}
@@ -25,6 +25,14 @@ interface ComboboxProps extends Omit<ComponentPropsWithoutRef<"div">, "id" | "on
25
25
  * delete the `[&_input]:…` copy of {@link FIELD_INVALID} it had been carrying
26
26
  * because this component had no `invalid` of its own. */
27
27
  invalid?: boolean;
28
+ /** What is wrong with the value, as {@link Input}'s `error`: rendered under the
29
+ * field, on the `<input>`'s `aria-describedby`, and implies `invalid` (lenkbank's
30
+ * scope fields, which moved here off `<Input list>` + `<datalist>`). */
31
+ error?: ReactNode;
32
+ /** As `<input disabled>`: no focus, no list, the field's settled look. The chevron
33
+ * stops toggling with it — it is a mouse target the input's own `disabled` does
34
+ * not reach. */
35
+ disabled?: boolean;
28
36
  /** Heading an option belongs under. Supplying it makes this list read exactly
29
37
  * like {@link InlineEntityCombobox}'s — one heading per group with its rows
30
38
  * indented beneath — instead of a flat list (feedback #136 rework: the payee
@@ -88,7 +96,7 @@ interface ComboboxProps extends Omit<ComponentPropsWithoutRef<"div">, "id" | "on
88
96
  * visually and behaviourally consistent with the other dropdowns (feedback
89
97
  * #230 — the payee field looked/behaved differently from every other select).
90
98
  */
91
- declare function Combobox({ value, onChange, options, label, id, placeholder, className, groupBy, maxSuggestions, searchPlaceholder, closeLabel, createLabel, invalid, optionAdornment, autoFocus, onBlur, onSubmit, "aria-label": ariaLabel, ...rest }: ComboboxProps): react.JSX.Element;
99
+ declare function Combobox({ value, onChange, options, label, id, placeholder, className, groupBy, maxSuggestions, searchPlaceholder, closeLabel, createLabel, invalid, error, disabled, optionAdornment, autoFocus, onBlur, onSubmit, "aria-label": ariaLabel, ...rest }: ComboboxProps): react.JSX.Element;
92
100
  /** `id` is the INPUT's and `onChange` is the field's — see {@link ComboboxProps}
93
101
  * for why the div's spellings of them are omitted. */
94
102
  interface InlineEntityComboboxProps<V extends string | number> extends Omit<ComponentPropsWithoutRef<"div">, "id" | "onChange"> {
@@ -96,6 +104,8 @@ interface InlineEntityComboboxProps<V extends string | number> extends Omit<Comp
96
104
  value: V | null;
97
105
  /** Required and unanswered — see {@link Combobox}'s `invalid`. */
98
106
  invalid?: boolean;
107
+ /** What is wrong with the value — see {@link Combobox}'s `error`. */
108
+ error?: ReactNode;
99
109
  /** A picked/typed option emits its id; emptying the field emits `null`. */
100
110
  onChange: (v: V | null) => void;
101
111
  options: ComboOption<V>[];
@@ -146,6 +156,6 @@ interface InlineEntityComboboxProps<V extends string | number> extends Omit<Comp
146
156
  * sheet that emptied it to show the full list would read as "the user cleared the
147
157
  * field" the moment it closed.
148
158
  */
149
- declare function InlineEntityCombobox<V extends string | number>({ value, onChange, options, label, id, placeholder, className, disabled, autoFocus, searchPlaceholder, emptyLabel, closeLabel, clearable, clearLabel, invalid, "aria-label": ariaLabel, ...rest }: InlineEntityComboboxProps<V>): react.JSX.Element;
159
+ declare function InlineEntityCombobox<V extends string | number>({ value, onChange, options, label, id, placeholder, className, disabled, autoFocus, searchPlaceholder, emptyLabel, closeLabel, clearable, clearLabel, invalid, error, "aria-label": ariaLabel, ...rest }: InlineEntityComboboxProps<V>): react.JSX.Element;
150
160
 
151
161
  export { Combobox, type ComboboxProps, InlineEntityCombobox, type InlineEntityComboboxProps };
@@ -9,9 +9,14 @@ import { useDropdown } from "./dropdown.js";
9
9
  import { useAnchoredPanel } from "../hooks/use-anchored-panel.js";
10
10
  import { useMediaQuery } from "../hooks/use-media-query.js";
11
11
  import { PickerSheet, SHEET_ROW_CLASS } from "./picker-sheet.js";
12
- import { useActiveOptionScroll } from "./combobox-core.js";
12
+ import {
13
+ SUGGESTION_LIST_CLASS,
14
+ suggestionRowClass,
15
+ useActiveOptionScroll,
16
+ useComboboxFieldError
17
+ } from "./combobox-core.js";
13
18
  import { DEFAULT_COMBOBOX_LABELS, useKitLabels } from "../i18n/kit-labels.js";
14
- const LIST_CLASS = "max-h-64 overflow-y-auto rounded-md border border-[var(--border)] bg-[var(--bg-surface)] py-1 shadow-lg";
19
+ const LIST_CLASS = SUGGESTION_LIST_CLASS;
15
20
  function usePrimaryPressOnly() {
16
21
  const auxPress = useRef(false);
17
22
  return {
@@ -47,10 +52,7 @@ function SuggestionList({
47
52
  document.body
48
53
  );
49
54
  }
50
- const rowClass = (isActive) => cn(
51
- "block w-full truncate px-3 py-1.5 text-left text-sm text-[var(--text-primary)]",
52
- isActive ? "bg-[var(--bg-active)]" : "hover:bg-[var(--bg-hover)]"
53
- );
55
+ const rowClass = suggestionRowClass;
54
56
  function Combobox({
55
57
  value,
56
58
  onChange,
@@ -65,6 +67,8 @@ function Combobox({
65
67
  closeLabel,
66
68
  createLabel,
67
69
  invalid,
70
+ error,
71
+ disabled,
68
72
  optionAdornment,
69
73
  autoFocus,
70
74
  onBlur,
@@ -72,6 +76,7 @@ function Combobox({
72
76
  "aria-label": ariaLabel,
73
77
  ...rest
74
78
  }) {
79
+ const field = useComboboxFieldError(error, invalid);
75
80
  const generated = useId();
76
81
  const fieldId = id ?? generated;
77
82
  const listboxId = `${generated}-listbox`;
@@ -148,8 +153,10 @@ function Combobox({
148
153
  "aria-controls": listboxId,
149
154
  "aria-activedescendant": activeId,
150
155
  "aria-autocomplete": "list",
151
- "aria-invalid": invalid || void 0,
156
+ "aria-invalid": field.isInvalid || void 0,
157
+ "aria-describedby": field.describedBy,
152
158
  autoComplete: "off",
159
+ disabled,
153
160
  autoFocus,
154
161
  onBlur,
155
162
  onMouseDown: primaryOnly.onMouseDown,
@@ -195,7 +202,12 @@ function Combobox({
195
202
  setActive(-1);
196
203
  }
197
204
  },
198
- className: cn(FIELD_BASE, label !== void 0 && FIELD_FLOATING_PAD, "pr-9", invalid && FIELD_INVALID)
205
+ className: cn(
206
+ FIELD_BASE,
207
+ label !== void 0 && FIELD_FLOATING_PAD,
208
+ "pr-9",
209
+ field.isInvalid && FIELD_INVALID
210
+ )
199
211
  }
200
212
  ),
201
213
  /* @__PURE__ */ jsx(
@@ -204,16 +216,20 @@ function Combobox({
204
216
  "aria-hidden": true,
205
217
  onMouseDown: (e) => {
206
218
  e.preventDefault();
207
- setOpen((o) => !o);
219
+ if (!disabled) setOpen((o) => !o);
208
220
  },
209
- className: "absolute right-2.5 top-1/2 size-4 -translate-y-1/2 cursor-pointer text-[var(--text-placeholder)]"
221
+ className: cn(
222
+ "absolute right-2.5 top-1/2 size-4 -translate-y-1/2 text-[var(--text-placeholder)]",
223
+ disabled ? "opacity-50" : "cursor-pointer"
224
+ )
210
225
  }
211
226
  )
212
227
  ] }),
228
+ field.errorEl,
213
229
  isPhone && /* @__PURE__ */ jsx(
214
230
  PickerSheet,
215
231
  {
216
- open,
232
+ open: open && !disabled,
217
233
  onClose: () => setOpen(false),
218
234
  title: label,
219
235
  query: value,
@@ -272,7 +288,7 @@ function Combobox({
272
288
  ] })
273
289
  }
274
290
  ),
275
- !isPhone && open && (matches.length > 0 || createRow) && /* @__PURE__ */ jsxs(SuggestionList, { id: listboxId, anchorRef: fieldRef, panelRef, children: [
291
+ !isPhone && open && !disabled && (matches.length > 0 || createRow) && /* @__PURE__ */ jsxs(SuggestionList, { id: listboxId, anchorRef: fieldRef, panelRef, children: [
276
292
  createRow && /* @__PURE__ */ jsx("li", { role: "presentation", children: /* @__PURE__ */ jsx(
277
293
  "button",
278
294
  {
@@ -348,9 +364,11 @@ function InlineEntityCombobox({
348
364
  clearable,
349
365
  clearLabel,
350
366
  invalid,
367
+ error,
351
368
  "aria-label": ariaLabel,
352
369
  ...rest
353
370
  }) {
371
+ const field = useComboboxFieldError(error, invalid);
354
372
  const generated = useId();
355
373
  const fieldId = id ?? generated;
356
374
  const listboxId = `${generated}-listbox`;
@@ -441,7 +459,8 @@ function InlineEntityCombobox({
441
459
  "aria-controls": listboxId,
442
460
  "aria-activedescendant": activeId,
443
461
  "aria-autocomplete": "list",
444
- "aria-invalid": invalid || void 0,
462
+ "aria-invalid": field.isInvalid || void 0,
463
+ "aria-describedby": field.describedBy,
445
464
  autoComplete: "off",
446
465
  disabled,
447
466
  autoFocus,
@@ -484,7 +503,12 @@ function InlineEntityCombobox({
484
503
  reconcile();
485
504
  }
486
505
  },
487
- className: cn(FIELD_BASE, label !== void 0 && FIELD_FLOATING_PAD, "pr-9", invalid && FIELD_INVALID)
506
+ className: cn(
507
+ FIELD_BASE,
508
+ label !== void 0 && FIELD_FLOATING_PAD,
509
+ "pr-9",
510
+ field.isInvalid && FIELD_INVALID
511
+ )
488
512
  }
489
513
  ),
490
514
  showClear ? /* @__PURE__ */ jsx(
@@ -511,7 +535,7 @@ function InlineEntityCombobox({
511
535
  "aria-hidden": true,
512
536
  onMouseDown: (e) => {
513
537
  e.preventDefault();
514
- setOpen((o) => !o);
538
+ if (!disabled) setOpen((o) => !o);
515
539
  },
516
540
  className: cn(
517
541
  "absolute right-2.5 top-1/2 size-4 -translate-y-1/2 text-[var(--text-placeholder)]",
@@ -520,10 +544,11 @@ function InlineEntityCombobox({
520
544
  }
521
545
  )
522
546
  ] }),
547
+ field.errorEl,
523
548
  isPhone && /* @__PURE__ */ jsx(
524
549
  PickerSheet,
525
550
  {
526
- open,
551
+ open: open && !disabled,
527
552
  onClose: reconcile,
528
553
  title: label,
529
554
  query: sheetQuery,
@@ -565,7 +590,7 @@ function InlineEntityCombobox({
565
590
  ] })
566
591
  }
567
592
  ),
568
- !isPhone && open && matches.length > 0 && /* @__PURE__ */ jsx(SuggestionList, { id: listboxId, anchorRef: fieldRef, panelRef, children: matches.map((o, i) => (
593
+ !isPhone && open && !disabled && matches.length > 0 && /* @__PURE__ */ jsx(SuggestionList, { id: listboxId, anchorRef: fieldRef, panelRef, children: matches.map((o, i) => (
569
594
  // A group heading is emitted at each group boundary rather than repeating
570
595
  // the group on every row (feedback #136). `matches` is group-contiguous,
571
596
  // so comparing with the previous row is enough. Fragment key sits here;