@adia-ai/web-components 0.6.32 → 0.6.34

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 (164) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/components/accordion/accordion.css +2 -2
  3. package/components/action-list/action-list.css +2 -2
  4. package/components/agent-artifact/agent-artifact.css +31 -31
  5. package/components/agent-feedback-bar/agent-feedback-bar.css +10 -10
  6. package/components/agent-questions/agent-questions.css +57 -57
  7. package/components/agent-reasoning/agent-reasoning.css +62 -62
  8. package/components/agent-suggestions/agent-suggestions.css +4 -4
  9. package/components/agent-trace/agent-trace.css +53 -53
  10. package/components/alert/alert.css +41 -41
  11. package/components/avatar/avatar.css +27 -27
  12. package/components/badge/badge.css +27 -27
  13. package/components/block/block.css +16 -16
  14. package/components/breadcrumb/breadcrumb.css +23 -23
  15. package/components/button/button.css +101 -91
  16. package/components/calendar-grid/calendar-grid.a2ui.json +136 -0
  17. package/components/calendar-grid/calendar-grid.css +226 -0
  18. package/components/calendar-grid/calendar-grid.d.ts +37 -0
  19. package/components/calendar-grid/calendar-grid.js +17 -0
  20. package/components/calendar-grid/calendar-grid.yaml +116 -0
  21. package/components/calendar-grid/class.js +300 -0
  22. package/components/calendar-picker/calendar-picker.css +139 -139
  23. package/components/canvas/canvas.css +12 -12
  24. package/components/card/card.css +83 -83
  25. package/components/chart/chart.css +224 -224
  26. package/components/chart-legend/chart-legend.css +26 -26
  27. package/components/check/check.css +40 -40
  28. package/components/code/code.css +125 -125
  29. package/components/col/col.css +15 -15
  30. package/components/color-picker/color-picker.css +55 -55
  31. package/components/combobox/class.js +861 -0
  32. package/components/combobox/combobox.a2ui.json +363 -0
  33. package/components/combobox/combobox.css +244 -0
  34. package/components/combobox/combobox.d.ts +113 -0
  35. package/components/combobox/combobox.examples.md +59 -0
  36. package/components/combobox/combobox.js +17 -0
  37. package/components/combobox/combobox.test.js +181 -0
  38. package/components/combobox/combobox.yaml +369 -0
  39. package/components/command/command.css +90 -90
  40. package/components/date-range-picker/class.js +775 -0
  41. package/components/date-range-picker/date-range-picker.a2ui.json +300 -0
  42. package/components/date-range-picker/date-range-picker.css +178 -0
  43. package/components/date-range-picker/date-range-picker.d.ts +82 -0
  44. package/components/date-range-picker/date-range-picker.examples.md +37 -0
  45. package/components/date-range-picker/date-range-picker.js +17 -0
  46. package/components/date-range-picker/date-range-picker.test.js +387 -0
  47. package/components/date-range-picker/date-range-picker.yaml +285 -0
  48. package/components/datetime-picker/class.js +706 -0
  49. package/components/datetime-picker/datetime-picker.a2ui.json +334 -0
  50. package/components/datetime-picker/datetime-picker.css +150 -0
  51. package/components/datetime-picker/datetime-picker.d.ts +86 -0
  52. package/components/datetime-picker/datetime-picker.examples.md +46 -0
  53. package/components/datetime-picker/datetime-picker.js +17 -0
  54. package/components/datetime-picker/datetime-picker.test.js +454 -0
  55. package/components/datetime-picker/datetime-picker.yaml +332 -0
  56. package/components/demo-toggle/demo-toggle.css +27 -27
  57. package/components/description-list/description-list.css +18 -18
  58. package/components/divider/divider.css +24 -24
  59. package/components/embed/embed.css +6 -6
  60. package/components/empty-state/empty-state.css +27 -27
  61. package/components/feed/feed.css +12 -12
  62. package/components/field/field.css +37 -28
  63. package/components/field/field.test.js +32 -0
  64. package/components/fields/fields.css +5 -5
  65. package/components/grid/grid.css +5 -5
  66. package/components/heatmap/heatmap.css +63 -63
  67. package/components/icon/icon.css +12 -12
  68. package/components/image/image.css +14 -14
  69. package/components/index.js +8 -0
  70. package/components/input/input.css +66 -66
  71. package/components/inspector/inspector.css +6 -6
  72. package/components/integration-card/class.js +410 -0
  73. package/components/integration-card/integration-card.a2ui.json +268 -0
  74. package/components/integration-card/integration-card.css +169 -0
  75. package/components/integration-card/integration-card.d.ts +63 -0
  76. package/components/integration-card/integration-card.examples.md +41 -0
  77. package/components/integration-card/integration-card.js +17 -0
  78. package/components/integration-card/integration-card.test.js +306 -0
  79. package/components/integration-card/integration-card.yaml +280 -0
  80. package/components/kbd/kbd.css +32 -32
  81. package/components/link/link.css +12 -12
  82. package/components/list/list.css +8 -8
  83. package/components/list-window/class.js +688 -0
  84. package/components/list-window/list-window.a2ui.json +277 -0
  85. package/components/list-window/list-window.css +124 -0
  86. package/components/list-window/list-window.d.ts +84 -0
  87. package/components/list-window/list-window.examples.md +73 -0
  88. package/components/list-window/list-window.js +17 -0
  89. package/components/list-window/list-window.test.js +303 -0
  90. package/components/list-window/list-window.yaml +270 -0
  91. package/components/menu/menu.css +8 -8
  92. package/components/modal/modal.css +43 -43
  93. package/components/nav/nav.css +40 -40
  94. package/components/nav-group/nav-group.css +52 -52
  95. package/components/nav-item/nav-item.css +44 -44
  96. package/components/noodles/noodles.css +31 -31
  97. package/components/option-card/option-card.css +69 -69
  98. package/components/otp-input/otp-input.css +30 -30
  99. package/components/page/page.css +18 -18
  100. package/components/pagination/pagination.css +61 -61
  101. package/components/pane/pane.css +57 -57
  102. package/components/pipeline-status/pipeline-status.css +65 -65
  103. package/components/popover/popover.css +17 -17
  104. package/components/progress/progress.css +23 -23
  105. package/components/progress-row/progress-row.css +17 -17
  106. package/components/radio/radio.css +39 -39
  107. package/components/range/range.css +55 -55
  108. package/components/rating/rating.css +28 -28
  109. package/components/richtext/richtext.css +133 -133
  110. package/components/row/row.css +19 -19
  111. package/components/search/search.css +5 -5
  112. package/components/segment/segment.css +24 -24
  113. package/components/segmented/segmented.css +25 -25
  114. package/components/select/select.css +84 -84
  115. package/components/skeleton/skeleton.css +14 -14
  116. package/components/slider/slider.css +46 -46
  117. package/components/spinner/class.js +69 -0
  118. package/components/spinner/spinner.a2ui.json +197 -0
  119. package/components/spinner/spinner.css +165 -0
  120. package/components/spinner/spinner.d.ts +26 -0
  121. package/components/spinner/spinner.examples.md +26 -0
  122. package/components/spinner/spinner.js +17 -0
  123. package/components/spinner/spinner.test.js +234 -0
  124. package/components/spinner/spinner.yaml +230 -0
  125. package/components/stack/stack.css +11 -11
  126. package/components/stat/stat.css +25 -25
  127. package/components/step-progress/step-progress.css +20 -20
  128. package/components/stepper/stepper.css +29 -29
  129. package/components/stream/stream.css +12 -12
  130. package/components/swatch/swatch.css +68 -68
  131. package/components/swiper/swiper.css +57 -57
  132. package/components/switch/switch.css +52 -52
  133. package/components/table/class.js +9 -0
  134. package/components/table/table.a2ui.json +1 -1
  135. package/components/table/table.css +162 -162
  136. package/components/table/table.d.ts +1 -1
  137. package/components/table/table.test.js +53 -0
  138. package/components/table/table.yaml +13 -1
  139. package/components/table-toolbar/table-toolbar.css +32 -32
  140. package/components/tabs/tabs.css +51 -51
  141. package/components/tag/tag.css +48 -48
  142. package/components/text/text.css +44 -44
  143. package/components/textarea/textarea.css +46 -46
  144. package/components/time-picker/class.js +693 -0
  145. package/components/time-picker/time-picker.a2ui.json +267 -0
  146. package/components/time-picker/time-picker.css +122 -0
  147. package/components/time-picker/time-picker.d.ts +75 -0
  148. package/components/time-picker/time-picker.examples.md +35 -0
  149. package/components/time-picker/time-picker.js +17 -0
  150. package/components/time-picker/time-picker.test.js +287 -0
  151. package/components/time-picker/time-picker.yaml +256 -0
  152. package/components/timeline/timeline.css +50 -50
  153. package/components/toast/toast.css +58 -58
  154. package/components/toggle-group/toggle-group.css +6 -6
  155. package/components/toggle-scheme/toggle-scheme.css +2 -2
  156. package/components/toolbar/toolbar.css +17 -17
  157. package/components/tooltip/tooltip.css +2 -2
  158. package/components/tree/tree.css +37 -37
  159. package/components/upload/upload.css +49 -49
  160. package/dist/icons-manifest.js +3 -3
  161. package/dist/web-components.min.css +1 -1
  162. package/dist/web-components.min.js +121 -83
  163. package/package.json +1 -1
  164. package/styles/components.css +8 -0
@@ -0,0 +1,861 @@
1
+ /**
2
+ * Non-side-effect class export for `<combobox-ui>`.
3
+ *
4
+ * Importing this file gives you the class(es) without auto-registering the tag.
5
+ * Useful for test isolation, subclassing with tag-name override, or selective
6
+ * composition.
7
+ *
8
+ * The auto-register path stays at `@adia-ai/web-components/components/combobox`
9
+ * (which imports this file + calls `defineIfFree()`).
10
+ *
11
+ * @see ../../USAGE.md#registration--auto-vs-explicit
12
+ */
13
+
14
+ /**
15
+ * `<combobox-ui>` — SPEC-036.
16
+ *
17
+ * Typeahead-filterable single-select per WAI-APG "Combobox With List
18
+ * Autocomplete (manual selection)". Constrained-choice by default:
19
+ * `value` must come from the options list. `[free-text]` opts into the
20
+ * autocomplete-style hybrid; `[creatable]` adds a create-flow on top.
21
+ *
22
+ * No native `<input>` wrap (per ADR-0025). The host carries
23
+ * `role="combobox"` and the editable surface is a `contenteditable`
24
+ * span. Form participation via `UIFormElement` + `ElementInternals`.
25
+ *
26
+ * <combobox-ui name="country" placeholder="Select country…">
27
+ * <option value="us">United States</option>
28
+ * <option value="gb">United Kingdom</option>
29
+ * </combobox-ui>
30
+ *
31
+ * <combobox-ui id="member" placeholder="Search members…"></combobox-ui>
32
+ * <script>
33
+ * document.getElementById('member').addEventListener('input', async e => {
34
+ * const res = await fetch(`/api/members?q=${e.detail.query}`);
35
+ * e.target.options = await res.json();
36
+ * });
37
+ * </script>
38
+ */
39
+
40
+ import { UIFormElement } from '../../core/form.js';
41
+ import { anchorPopover } from '../../core/anchor.js';
42
+ import { untracked } from '../../core/signals.js';
43
+
44
+ function escapeHTML(s) {
45
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
46
+ }
47
+
48
+ function escapeRegExp(s) {
49
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
50
+ }
51
+
52
+ let cbInstanceSeq = 0;
53
+
54
+ export class UICombobox extends UIFormElement {
55
+ // `label` here is first-class — rendered above the field with proper
56
+ // `aria-labelledby` wiring on the editable surface. Opt out of the
57
+ // base-class deprecation warning that targets inert above-field labels.
58
+ static labelDeprecated = false;
59
+
60
+ static requiredIcons = ['caret-down', 'x-circle', 'check'];
61
+
62
+ static properties = {
63
+ ...UIFormElement.properties,
64
+ placeholder: { type: String, default: 'Select...', reflect: true },
65
+ label: { type: String, default: '', reflect: true },
66
+ open: { type: Boolean, default: false, reflect: true },
67
+ freeText: { type: Boolean, default: false, reflect: true, attribute: 'free-text' },
68
+ creatable: { type: Boolean, default: false, reflect: true },
69
+ clearable: { type: Boolean, default: false, reflect: true },
70
+ filterMode: { type: String, default: 'substring', reflect: true, attribute: 'filter-mode' },
71
+ loading: { type: Boolean, default: false, reflect: true },
72
+ maxOptions: { type: Number, default: 100, reflect: true, attribute: 'max-options' },
73
+ highlightMatch: { type: Boolean, default: true, reflect: true, attribute: 'highlight-match' },
74
+ };
75
+
76
+ static template = () => null;
77
+
78
+ // ── Instance state ──
79
+ #options = [];
80
+ #lastCommittedValue = '';
81
+ #query = '';
82
+ #activeIndex = -1;
83
+ #suppressInput = false; // guard contenteditable re-syncs during programmatic updates
84
+
85
+ // ── Refs ──
86
+ #inputEl = null;
87
+ #labelEl = null;
88
+ #listbox = null;
89
+ #emptyEl = null;
90
+ #loadingEl = null;
91
+ #footerEl = null;
92
+ #clearBtn = null;
93
+ #suffixEl = null;
94
+ #anchorCleanup = null;
95
+ #rafId = null;
96
+ #instanceId = `combobox-${++cbInstanceSeq}`;
97
+
98
+ // ── Stable handler refs (so removeEventListener finds them) ──
99
+ #onInputEvent = () => this.#handleInput();
100
+ #onKeydown = (e) => this.#handleKeydown(e);
101
+ #onFocus = () => this.#handleFocus();
102
+ #onBlur = (e) => this.#handleBlur(e);
103
+ #onOptionClick = (e) => this.#handleOptionClick(e);
104
+ #onOptionMouseEnter = (e) => this.#handleOptionMouseEnter(e);
105
+ #onClearClick = (e) => this.#handleClearClick(e);
106
+ #onSuffixClick = (e) => this.#handleSuffixClick(e);
107
+ #onOutside = (e) => this.#handleOutside(e);
108
+
109
+ // ── Lifecycle ──
110
+
111
+ connected() {
112
+ super.connected();
113
+ // Host carries presentation. role="combobox" + ARIA expansion state
114
+ // live on the inner [data-input] per WAI-APG combobox-list-autocomplete
115
+ // (the editable surface IS the combobox; the host wraps the input +
116
+ // popover into one form-bearing custom element).
117
+
118
+ // Parse declarative <option> / <optgroup> children once on connect,
119
+ // unless options were already set programmatically.
120
+ if (this.#options.length === 0) {
121
+ this.#parseOptions();
122
+ }
123
+
124
+ this.#lastCommittedValue = this.value || '';
125
+
126
+ this.#stampShell();
127
+ this.#renderOptions();
128
+ this.#syncInputDisplay();
129
+ }
130
+
131
+ disconnected() {
132
+ super.disconnected();
133
+ this.#teardownListeners();
134
+ this.#anchorCleanup?.();
135
+ this.#anchorCleanup = null;
136
+ if (this.#rafId != null) {
137
+ cancelAnimationFrame(this.#rafId);
138
+ this.#rafId = null;
139
+ }
140
+ document.removeEventListener('pointerdown', this.#onOutside);
141
+ this.#listbox?.hidePopover?.();
142
+ this.#listbox = null;
143
+ this.#inputEl = null;
144
+ this.#labelEl = null;
145
+ this.#emptyEl = null;
146
+ this.#loadingEl = null;
147
+ this.#footerEl = null;
148
+ this.#clearBtn = null;
149
+ this.#suffixEl = null;
150
+ }
151
+
152
+ render() {
153
+ if (!this.#inputEl) return;
154
+
155
+ // Sync ARIA expanded on the inner combobox surface (WAI-APG).
156
+ this.#inputEl.setAttribute('aria-expanded', String(this.open));
157
+
158
+ // Disabled / readonly state on the editable surface.
159
+ if (this.disabled || this.readonly) {
160
+ this.#inputEl.contentEditable = 'false';
161
+ } else {
162
+ this.#inputEl.contentEditable = 'plaintext-only';
163
+ }
164
+
165
+ if (this.#labelEl) {
166
+ this.#labelEl.textContent = this.label || '';
167
+ this.#labelEl.style.display = this.label ? '' : 'none';
168
+ }
169
+
170
+ // aria-label fallback when no [label] is set.
171
+ if (this.label) {
172
+ this.removeAttribute('aria-label');
173
+ } else if (this.placeholder) {
174
+ this.setAttribute('aria-label', this.placeholder);
175
+ } else {
176
+ this.removeAttribute('aria-label');
177
+ }
178
+
179
+ // Toggle clear-button visibility when [clearable].
180
+ if (this.#clearBtn) {
181
+ this.#clearBtn.style.display = this.clearable && this.value ? '' : 'none';
182
+ }
183
+
184
+ // Show/hide the loading indicator + empty state in the popover.
185
+ if (this.#loadingEl) {
186
+ this.#loadingEl.style.display = this.loading ? '' : 'none';
187
+ }
188
+
189
+ // Popover open / close.
190
+ if (this.open) {
191
+ this.#openPopover();
192
+ } else {
193
+ this.#closePopover();
194
+ }
195
+ }
196
+
197
+ // ── Shell ──
198
+
199
+ #stampShell() {
200
+ if (this.querySelector(':scope > [data-field]')) {
201
+ // Already stamped (re-connect path); just re-query refs.
202
+ this.#queryRefs();
203
+ this.#bindListeners();
204
+ return;
205
+ }
206
+
207
+ const labelId = `${this.#instanceId}-label`;
208
+ const listboxId = `${this.#instanceId}-listbox`;
209
+ const inputId = `${this.#instanceId}-input`;
210
+
211
+ // Capture consumer-supplied slotted children before innerHTML wipes them.
212
+ const prefixNodes = Array.from(this.querySelectorAll(':scope > [slot="prefix"]'));
213
+ const suffixNodes = Array.from(this.querySelectorAll(':scope > [slot="suffix"]'));
214
+ const emptyNodes = Array.from(this.querySelectorAll(':scope > [slot="empty"]'));
215
+ const loadingNodes = Array.from(this.querySelectorAll(':scope > [slot="loading"]'));
216
+ const footerNodes = Array.from(this.querySelectorAll(':scope > [slot="footer"]'));
217
+
218
+ this.innerHTML = `
219
+ <span data-label id="${labelId}"${this.label ? '' : ' style="display:none"'}>${escapeHTML(this.label || '')}</span>
220
+ <div data-field>
221
+ <span data-prefix></span>
222
+ <span data-input
223
+ id="${inputId}"
224
+ contenteditable="plaintext-only"
225
+ role="combobox"
226
+ tabindex="0"
227
+ aria-autocomplete="list"
228
+ aria-expanded="false"
229
+ aria-controls="${listboxId}"
230
+ aria-labelledby="${labelId}"
231
+ data-placeholder="${escapeHTML(this.placeholder || '')}"></span>
232
+ <button type="button" data-clear aria-label="Clear" tabindex="-1" style="display:none">
233
+ <icon-ui name="x-circle"></icon-ui>
234
+ </button>
235
+ <span data-suffix>
236
+ <icon-ui name="caret-down"></icon-ui>
237
+ </span>
238
+ </div>
239
+ <div data-listbox id="${listboxId}" role="listbox" popover="manual" aria-labelledby="${labelId}">
240
+ <div data-loading style="display:none"><spinner-ui></spinner-ui></div>
241
+ <div data-options></div>
242
+ <div data-empty>No matches</div>
243
+ <div data-footer></div>
244
+ </div>
245
+ `;
246
+
247
+ this.#queryRefs();
248
+
249
+ // Re-insert consumer-supplied slot nodes into the appropriate region.
250
+ if (prefixNodes.length) {
251
+ const prefix = this.querySelector(':scope > [data-field] > [data-prefix]');
252
+ prefix.replaceChildren(...prefixNodes);
253
+ }
254
+ if (suffixNodes.length) {
255
+ // Consumer-supplied suffix replaces the default caret-down.
256
+ this.#suffixEl.replaceChildren(...suffixNodes);
257
+ }
258
+ if (emptyNodes.length) {
259
+ this.#emptyEl.replaceChildren(...emptyNodes);
260
+ }
261
+ if (loadingNodes.length) {
262
+ this.#loadingEl.replaceChildren(...loadingNodes);
263
+ }
264
+ if (footerNodes.length) {
265
+ this.#footerEl.replaceChildren(...footerNodes);
266
+ }
267
+
268
+ this.#bindListeners();
269
+ }
270
+
271
+ #queryRefs() {
272
+ this.#inputEl = this.querySelector(':scope > [data-field] > [data-input]');
273
+ this.#labelEl = this.querySelector(':scope > [data-label]');
274
+ this.#clearBtn = this.querySelector(':scope > [data-field] > [data-clear]');
275
+ this.#suffixEl = this.querySelector(':scope > [data-field] > [data-suffix]');
276
+ this.#listbox = this.querySelector(':scope > [data-listbox]');
277
+ this.#loadingEl = this.querySelector(':scope > [data-listbox] > [data-loading]');
278
+ this.#emptyEl = this.querySelector(':scope > [data-listbox] > [data-empty]');
279
+ this.#footerEl = this.querySelector(':scope > [data-listbox] > [data-footer]');
280
+ }
281
+
282
+ #bindListeners() {
283
+ if (this.#inputEl) {
284
+ this.#inputEl.addEventListener('input', this.#onInputEvent);
285
+ this.#inputEl.addEventListener('keydown', this.#onKeydown);
286
+ this.#inputEl.addEventListener('focus', this.#onFocus);
287
+ this.#inputEl.addEventListener('blur', this.#onBlur);
288
+ }
289
+ if (this.#clearBtn) {
290
+ this.#clearBtn.addEventListener('click', this.#onClearClick);
291
+ }
292
+ if (this.#suffixEl) {
293
+ this.#suffixEl.addEventListener('click', this.#onSuffixClick);
294
+ }
295
+ }
296
+
297
+ #teardownListeners() {
298
+ if (this.#inputEl) {
299
+ this.#inputEl.removeEventListener('input', this.#onInputEvent);
300
+ this.#inputEl.removeEventListener('keydown', this.#onKeydown);
301
+ this.#inputEl.removeEventListener('focus', this.#onFocus);
302
+ this.#inputEl.removeEventListener('blur', this.#onBlur);
303
+ }
304
+ if (this.#clearBtn) {
305
+ this.#clearBtn.removeEventListener('click', this.#onClearClick);
306
+ }
307
+ if (this.#suffixEl) {
308
+ this.#suffixEl.removeEventListener('click', this.#onSuffixClick);
309
+ }
310
+ }
311
+
312
+ // ── Options ──
313
+
314
+ /** Parse <option> + <optgroup> children into the internal option model. */
315
+ #parseOptions() {
316
+ const opts = [];
317
+ let preSelected = '';
318
+ for (const child of Array.from(this.children)) {
319
+ if (child.tagName === 'OPTGROUP') {
320
+ const group = { label: child.label || child.getAttribute('label') || '', options: [] };
321
+ for (const opt of child.querySelectorAll('option')) {
322
+ group.options.push({ value: opt.value, label: opt.textContent.trim(), disabled: opt.disabled });
323
+ if (opt.hasAttribute('selected') && !preSelected) preSelected = opt.value;
324
+ }
325
+ opts.push(group);
326
+ } else if (child.tagName === 'OPTION') {
327
+ opts.push({ value: child.value, label: child.textContent.trim(), disabled: child.disabled });
328
+ if (child.hasAttribute('selected') && !preSelected) preSelected = child.value;
329
+ }
330
+ }
331
+ // Remove the parsed nodes so they don't compete with the stamped shell.
332
+ for (const child of [...this.querySelectorAll(':scope > option, :scope > optgroup')]) child.remove();
333
+ this.#options = opts;
334
+ if (!this.value && preSelected) this.value = preSelected;
335
+ }
336
+
337
+ set options(list) {
338
+ // Reads of `this.value` happen below (for aria-selected wiring). Wrap
339
+ // the rebuild in `untracked` so calling-effect contexts don't subscribe
340
+ // to value via this setter — mirrors the select-ui pattern from
341
+ // FEEDBACK-22 / v0.5.18.
342
+ untracked(() => {
343
+ this.#options = Array.isArray(list) ? list : [];
344
+ if (this.#listbox) this.#renderOptions();
345
+ this.#syncInputDisplay();
346
+ });
347
+ }
348
+
349
+ get options() { return this.#options; }
350
+
351
+ #flatOptions() {
352
+ const flat = [];
353
+ for (const item of this.#options) {
354
+ if (item && Array.isArray(item.options)) {
355
+ for (const opt of item.options) flat.push(opt);
356
+ } else if (item) {
357
+ flat.push(item);
358
+ }
359
+ }
360
+ return flat;
361
+ }
362
+
363
+ #findOption(value) {
364
+ return this.#flatOptions().find((o) => o.value === value) || null;
365
+ }
366
+
367
+ // ── Render the listbox ──
368
+
369
+ #renderOptions() {
370
+ if (!this.#listbox) return;
371
+ const container = this.querySelector(':scope > [data-listbox] > [data-options]');
372
+ if (!container) return;
373
+ container.replaceChildren();
374
+
375
+ const flat = this.#flatOptions();
376
+ const renderable = this.#applyFilter(flat).slice(0, Number(this.maxOptions) || 100);
377
+
378
+ let optionCounter = 0;
379
+ const renderOpt = (opt) => {
380
+ optionCounter += 1;
381
+ const el = document.createElement('div');
382
+ el.setAttribute('role', 'option');
383
+ el.setAttribute('id', `${this.#instanceId}-opt-${optionCounter}`);
384
+ el.dataset.value = opt.value ?? '';
385
+ if (opt.disabled) el.setAttribute('aria-disabled', 'true');
386
+ if (opt.value === this.value) el.setAttribute('aria-selected', 'true');
387
+ el.innerHTML = this.#renderOptionLabel(opt.label ?? opt.value);
388
+ el.addEventListener('click', this.#onOptionClick);
389
+ el.addEventListener('mouseenter', this.#onOptionMouseEnter);
390
+ return el;
391
+ };
392
+
393
+ if (this.#options.some((o) => o && Array.isArray(o.options))) {
394
+ // Grouped path — render groups with section headers, filtered.
395
+ for (const item of this.#options) {
396
+ if (item && Array.isArray(item.options)) {
397
+ const groupOpts = this.#applyFilter(item.options);
398
+ if (!groupOpts.length) continue;
399
+ const groupEl = document.createElement('div');
400
+ groupEl.setAttribute('role', 'group');
401
+ const header = document.createElement('div');
402
+ header.setAttribute('data-group-label', '');
403
+ header.textContent = item.label || '';
404
+ groupEl.appendChild(header);
405
+ for (const opt of groupOpts) groupEl.appendChild(renderOpt(opt));
406
+ container.appendChild(groupEl);
407
+ } else if (item) {
408
+ // Flat option mixed into a grouped list — still render it.
409
+ if (this.#matches(item)) container.appendChild(renderOpt(item));
410
+ }
411
+ }
412
+ } else {
413
+ for (const opt of renderable) container.appendChild(renderOpt(opt));
414
+ }
415
+
416
+ // Update the empty-state visibility based on what got rendered.
417
+ const visible = container.querySelectorAll('[role="option"]').length;
418
+ if (this.#emptyEl) {
419
+ this.#emptyEl.style.display = visible || this.loading ? 'none' : '';
420
+ }
421
+
422
+ // Clear any stale active-descendant pointer.
423
+ this.#activeIndex = -1;
424
+ this.#inputEl?.removeAttribute('aria-activedescendant');
425
+ }
426
+
427
+ #renderOptionLabel(label) {
428
+ const text = String(label ?? '');
429
+ if (!this.highlightMatch || !this.#query) return escapeHTML(text);
430
+ const q = this.#query;
431
+ const re = new RegExp(escapeRegExp(q), 'i');
432
+ const match = text.match(re);
433
+ if (!match) return escapeHTML(text);
434
+ const start = match.index ?? 0;
435
+ const end = start + match[0].length;
436
+ return (
437
+ escapeHTML(text.slice(0, start)) +
438
+ '<mark>' +
439
+ escapeHTML(text.slice(start, end)) +
440
+ '</mark>' +
441
+ escapeHTML(text.slice(end))
442
+ );
443
+ }
444
+
445
+ // ── Filtering ──
446
+
447
+ #matches(opt) {
448
+ if (!this.#query) return true;
449
+ const q = this.#query.toLowerCase();
450
+ const label = String(opt.label ?? opt.value ?? '').toLowerCase();
451
+ switch (this.filterMode) {
452
+ case 'prefix':
453
+ return label.startsWith(q);
454
+ case 'fuzzy': {
455
+ let li = 0;
456
+ for (const ch of q) {
457
+ li = label.indexOf(ch, li);
458
+ if (li === -1) return false;
459
+ li += 1;
460
+ }
461
+ return true;
462
+ }
463
+ case 'substring':
464
+ default:
465
+ return label.includes(q);
466
+ }
467
+ }
468
+
469
+ #applyFilter(list) {
470
+ if (!this.#query) return list.slice();
471
+ return list.filter((o) => this.#matches(o));
472
+ }
473
+
474
+ // ── Input handling ──
475
+
476
+ #handleInput() {
477
+ if (this.#suppressInput) return;
478
+ const text = this.#inputEl.textContent || '';
479
+ this.#query = text.trim();
480
+
481
+ if (!this.open) {
482
+ this.open = true;
483
+ this.dispatchEvent(new CustomEvent('open', { bubbles: true, detail: { trigger: 'typing' } }));
484
+ }
485
+
486
+ this.#renderOptions();
487
+
488
+ this.dispatchEvent(new CustomEvent('input', {
489
+ bubbles: true,
490
+ detail: { value: text, query: this.#query.toLowerCase() },
491
+ }));
492
+ }
493
+
494
+ #handleKeydown(e) {
495
+ const k = e.key;
496
+
497
+ if (k === 'ArrowDown') {
498
+ e.preventDefault();
499
+ if (!this.open) {
500
+ this.open = true;
501
+ this.dispatchEvent(new CustomEvent('open', { bubbles: true, detail: { trigger: 'keyboard' } }));
502
+ }
503
+ this.#moveActive(1);
504
+ return;
505
+ }
506
+ if (k === 'ArrowUp') {
507
+ if (!this.open) return;
508
+ e.preventDefault();
509
+ this.#moveActive(-1);
510
+ return;
511
+ }
512
+ if (k === 'Home' && this.open) {
513
+ e.preventDefault();
514
+ this.#setActiveIndex(0);
515
+ return;
516
+ }
517
+ if (k === 'End' && this.open) {
518
+ e.preventDefault();
519
+ const opts = this.#renderedOptions();
520
+ this.#setActiveIndex(opts.length - 1);
521
+ return;
522
+ }
523
+ if (k === 'Enter') {
524
+ e.preventDefault();
525
+ this.#commitOnEnter();
526
+ return;
527
+ }
528
+ if (k === 'Escape') {
529
+ if (this.open) {
530
+ e.preventDefault();
531
+ this.#closeAndRestore('escape');
532
+ }
533
+ return;
534
+ }
535
+ if (k === 'Tab') {
536
+ // Tab: commit active option if any; otherwise per free-text policy.
537
+ this.#commitOnTab();
538
+ return;
539
+ }
540
+ if (k === 'Backspace') {
541
+ // When clearable + the input is empty + a value is committed, clear.
542
+ const empty = !(this.#inputEl.textContent || '').length;
543
+ if (this.clearable && empty && this.value) {
544
+ e.preventDefault();
545
+ this.#clear('keyboard');
546
+ }
547
+ return;
548
+ }
549
+ }
550
+
551
+ #handleFocus() {
552
+ // No auto-open on focus; user opens with Arrow / click / typing.
553
+ }
554
+
555
+ #handleBlur(e) {
556
+ // Closing on blur is handled by the document-level outside-click handler
557
+ // (so clicks on listbox options don't close before they fire). Just
558
+ // restore the input display if free-text is off and the typed query
559
+ // doesn't resolve to a real value.
560
+ if (!this.freeText && this.#query && !this.value) {
561
+ // No commit happened; restore last committed value into the display.
562
+ this.#syncInputDisplay();
563
+ this.#query = '';
564
+ }
565
+ void e;
566
+ }
567
+
568
+ // ── Active-descendant management ──
569
+
570
+ #renderedOptions() {
571
+ if (!this.#listbox) return [];
572
+ return Array.from(this.#listbox.querySelectorAll('[role="option"]:not([aria-disabled="true"])'));
573
+ }
574
+
575
+ #moveActive(dir) {
576
+ const opts = this.#renderedOptions();
577
+ if (!opts.length) return;
578
+ if (this.#activeIndex < 0) {
579
+ this.#setActiveIndex(dir > 0 ? 0 : opts.length - 1);
580
+ return;
581
+ }
582
+ const next = (this.#activeIndex + dir + opts.length) % opts.length;
583
+ this.#setActiveIndex(next);
584
+ }
585
+
586
+ #setActiveIndex(idx) {
587
+ const opts = this.#renderedOptions();
588
+ if (!opts.length) {
589
+ this.#activeIndex = -1;
590
+ this.#inputEl?.removeAttribute('aria-activedescendant');
591
+ return;
592
+ }
593
+ // Clear previous data-active marker.
594
+ for (const o of opts) o.removeAttribute('data-active');
595
+ const clamped = Math.max(0, Math.min(idx, opts.length - 1));
596
+ const el = opts[clamped];
597
+ if (!el) return;
598
+ el.setAttribute('data-active', '');
599
+ el.scrollIntoView({ block: 'nearest' });
600
+ this.#activeIndex = clamped;
601
+ this.#inputEl?.setAttribute('aria-activedescendant', el.id);
602
+ }
603
+
604
+ // ── Commit paths ──
605
+
606
+ #handleOptionClick(e) {
607
+ const el = e.currentTarget;
608
+ if (el.getAttribute('aria-disabled') === 'true') return;
609
+ const value = el.dataset.value || '';
610
+ this.#commitOption(value, 'click');
611
+ }
612
+
613
+ #handleOptionMouseEnter(e) {
614
+ const el = e.currentTarget;
615
+ const opts = this.#renderedOptions();
616
+ const idx = opts.indexOf(el);
617
+ if (idx >= 0) this.#setActiveIndex(idx);
618
+ }
619
+
620
+ #commitOnEnter() {
621
+ if (!this.open) {
622
+ // Closed: open the popover for the user.
623
+ this.open = true;
624
+ this.dispatchEvent(new CustomEvent('open', { bubbles: true, detail: { trigger: 'keyboard' } }));
625
+ return;
626
+ }
627
+ const opts = this.#renderedOptions();
628
+ const active = this.#activeIndex >= 0 ? opts[this.#activeIndex] : null;
629
+ if (active) {
630
+ this.#commitOption(active.dataset.value || '', 'keyboard');
631
+ return;
632
+ }
633
+ const typed = (this.#inputEl.textContent || '').trim();
634
+ if (!typed) {
635
+ this.#closePopover('select');
636
+ return;
637
+ }
638
+ if (this.creatable) {
639
+ this.dispatchEvent(new CustomEvent('create', { bubbles: true, detail: { value: typed } }));
640
+ // Free-text commit also fires for creatable (consumer can refresh options after).
641
+ this.#commitFreeText(typed, 'keyboard');
642
+ return;
643
+ }
644
+ if (this.freeText) {
645
+ this.#commitFreeText(typed, 'keyboard');
646
+ return;
647
+ }
648
+ // Constrained-choice + no match: invalid.
649
+ this.dispatchEvent(new CustomEvent('invalid', { bubbles: true, detail: { query: typed } }));
650
+ this.#syncInputDisplay();
651
+ this.#query = '';
652
+ this.#renderOptions();
653
+ }
654
+
655
+ #commitOnTab() {
656
+ if (!this.open) return;
657
+ const opts = this.#renderedOptions();
658
+ const active = this.#activeIndex >= 0 ? opts[this.#activeIndex] : null;
659
+ if (active) {
660
+ this.#commitOption(active.dataset.value || '', 'keyboard');
661
+ return;
662
+ }
663
+ const typed = (this.#inputEl.textContent || '').trim();
664
+ if (typed && (this.freeText || this.creatable)) {
665
+ if (this.creatable && !this.#findOption(typed)) {
666
+ this.dispatchEvent(new CustomEvent('create', { bubbles: true, detail: { value: typed } }));
667
+ }
668
+ this.#commitFreeText(typed, 'keyboard');
669
+ return;
670
+ }
671
+ // No active + no free-text: restore + close.
672
+ this.#syncInputDisplay();
673
+ this.#query = '';
674
+ this.#closePopover('blur');
675
+ }
676
+
677
+ #commitOption(value, source) {
678
+ if (this.readonly) {
679
+ this.#closePopover('select');
680
+ return;
681
+ }
682
+ const opt = this.#findOption(value);
683
+ untracked(() => {
684
+ this.value = value;
685
+ });
686
+ this.#lastCommittedValue = value;
687
+ this.#query = '';
688
+ this.syncValue(value);
689
+ this.#syncInputDisplay();
690
+ this.#renderOptions();
691
+ this.dispatchEvent(new CustomEvent('change', {
692
+ bubbles: true,
693
+ detail: { value, option: opt, source },
694
+ }));
695
+ this.open = false;
696
+ this.dispatchEvent(new CustomEvent('close', { bubbles: true, detail: { reason: 'select' } }));
697
+ }
698
+
699
+ #commitFreeText(value, source) {
700
+ if (this.readonly) {
701
+ this.#closePopover('select');
702
+ return;
703
+ }
704
+ untracked(() => {
705
+ this.value = value;
706
+ });
707
+ this.#lastCommittedValue = value;
708
+ this.#query = '';
709
+ this.syncValue(value);
710
+ this.#syncInputDisplay();
711
+ this.#renderOptions();
712
+ this.dispatchEvent(new CustomEvent('change', {
713
+ bubbles: true,
714
+ detail: { value, option: null, source },
715
+ }));
716
+ this.open = false;
717
+ this.dispatchEvent(new CustomEvent('close', { bubbles: true, detail: { reason: 'select' } }));
718
+ }
719
+
720
+ // ── Clear ──
721
+
722
+ #handleClearClick(e) {
723
+ e.preventDefault();
724
+ e.stopPropagation();
725
+ this.#clear('click');
726
+ this.#inputEl?.focus();
727
+ }
728
+
729
+ #clear(source) {
730
+ untracked(() => { this.value = ''; });
731
+ this.#lastCommittedValue = '';
732
+ this.#query = '';
733
+ this.syncValue('');
734
+ this.#syncInputDisplay();
735
+ this.#renderOptions();
736
+ this.dispatchEvent(new CustomEvent('change', {
737
+ bubbles: true,
738
+ detail: { value: '', option: null, source },
739
+ }));
740
+ }
741
+
742
+ clear() { this.#clear('programmatic'); }
743
+
744
+ // ── Suffix (caret) toggles popover ──
745
+
746
+ #handleSuffixClick(e) {
747
+ e.preventDefault();
748
+ e.stopPropagation();
749
+ if (this.disabled) return;
750
+ if (this.open) {
751
+ this.open = false;
752
+ this.dispatchEvent(new CustomEvent('close', { bubbles: true, detail: { reason: 'outside' } }));
753
+ } else {
754
+ this.open = true;
755
+ this.dispatchEvent(new CustomEvent('open', { bubbles: true, detail: { trigger: 'click' } }));
756
+ this.#inputEl?.focus();
757
+ }
758
+ }
759
+
760
+ // ── Outside-click + popover anchor ──
761
+
762
+ #handleOutside(e) {
763
+ const insideHost = this.contains(e.target);
764
+ const insidePopover = this.#listbox && e.composedPath().includes(this.#listbox);
765
+ if (!insideHost && !insidePopover) {
766
+ // Restore the displayed text to the last committed value if there
767
+ // was an in-flight unmatched query.
768
+ if (this.#query && !this.freeText) {
769
+ this.#syncInputDisplay();
770
+ this.#query = '';
771
+ }
772
+ this.open = false;
773
+ this.dispatchEvent(new CustomEvent('close', { bubbles: true, detail: { reason: 'outside' } }));
774
+ }
775
+ }
776
+
777
+ #closeAndRestore(reason) {
778
+ this.#syncInputDisplay();
779
+ this.#query = '';
780
+ this.#renderOptions();
781
+ this.open = false;
782
+ this.dispatchEvent(new CustomEvent('close', { bubbles: true, detail: { reason } }));
783
+ }
784
+
785
+ #openPopover() {
786
+ if (!this.#listbox) return;
787
+ this.#listbox.showPopover?.();
788
+ const trigger = this.querySelector(':scope > [data-field]') || this;
789
+ this.#listbox.style.minWidth = `${trigger.getBoundingClientRect().width}px`;
790
+ this.#anchorCleanup?.();
791
+ this.#anchorCleanup = anchorPopover(trigger, this.#listbox, {
792
+ placement: this.getAttribute('placement') || 'bottom-start',
793
+ gap: 4,
794
+ matchWidth: false,
795
+ });
796
+ // Defer registration so the current click that opened us doesn't
797
+ // immediately close us.
798
+ if (this.#rafId != null) cancelAnimationFrame(this.#rafId);
799
+ this.#rafId = requestAnimationFrame(() => {
800
+ this.#rafId = null;
801
+ document.addEventListener('pointerdown', this.#onOutside);
802
+ });
803
+ // Initial active option: the selected one if visible, else first.
804
+ const opts = this.#renderedOptions();
805
+ if (!opts.length) return;
806
+ const selectedIdx = opts.findIndex((o) => o.dataset.value === this.value);
807
+ this.#setActiveIndex(selectedIdx >= 0 ? selectedIdx : 0);
808
+ }
809
+
810
+ #closePopover(reason) {
811
+ if (!this.#listbox) return;
812
+ this.#anchorCleanup?.();
813
+ this.#anchorCleanup = null;
814
+ this.#listbox.hidePopover?.();
815
+ if (this.#rafId != null) {
816
+ cancelAnimationFrame(this.#rafId);
817
+ this.#rafId = null;
818
+ }
819
+ document.removeEventListener('pointerdown', this.#onOutside);
820
+ this.#activeIndex = -1;
821
+ this.#inputEl?.removeAttribute('aria-activedescendant');
822
+ if (reason) {
823
+ // Note: caller usually dispatches close; this is a no-op signal path.
824
+ }
825
+ }
826
+
827
+ // ── Display sync ──
828
+
829
+ #syncInputDisplay() {
830
+ if (!this.#inputEl) return;
831
+ const opt = this.#findOption(this.value);
832
+ const text = opt ? (opt.label ?? opt.value ?? '') : (this.value || '');
833
+ if ((this.#inputEl.textContent || '') !== text) {
834
+ this.#suppressInput = true;
835
+ this.#inputEl.textContent = text;
836
+ this.#suppressInput = false;
837
+ }
838
+ this.#inputEl.toggleAttribute('data-empty', !text);
839
+ }
840
+
841
+ // ── Public methods ──
842
+
843
+ open$() { this.open = true; }
844
+ close$() { this.open = false; }
845
+
846
+ focus() { this.#inputEl?.focus(); }
847
+
848
+ selectOption(value) {
849
+ const opt = this.#findOption(value);
850
+ if (!opt) return false;
851
+ this.#commitOption(value, 'programmatic');
852
+ return true;
853
+ }
854
+
855
+ // ── Form value sync override ──
856
+
857
+ syncValue(val) {
858
+ const v = val ?? this.value ?? '';
859
+ super.syncValue(String(v));
860
+ }
861
+ }