@adia-ai/web-components 0.6.33 → 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.
- package/CHANGELOG.md +22 -0
- package/components/accordion/accordion.css +2 -2
- package/components/action-list/action-list.css +2 -2
- package/components/agent-artifact/agent-artifact.css +31 -31
- package/components/agent-feedback-bar/agent-feedback-bar.css +10 -10
- package/components/agent-questions/agent-questions.css +57 -57
- package/components/agent-reasoning/agent-reasoning.css +62 -62
- package/components/agent-suggestions/agent-suggestions.css +4 -4
- package/components/agent-trace/agent-trace.css +53 -53
- package/components/alert/alert.css +41 -41
- package/components/avatar/avatar.css +27 -27
- package/components/badge/badge.css +27 -27
- package/components/block/block.css +16 -16
- package/components/breadcrumb/breadcrumb.css +23 -23
- package/components/button/button.css +101 -91
- package/components/calendar-grid/calendar-grid.a2ui.json +136 -0
- package/components/calendar-grid/calendar-grid.css +226 -0
- package/components/calendar-grid/calendar-grid.d.ts +37 -0
- package/components/calendar-grid/calendar-grid.js +17 -0
- package/components/calendar-grid/calendar-grid.yaml +116 -0
- package/components/calendar-grid/class.js +300 -0
- package/components/calendar-picker/calendar-picker.css +139 -139
- package/components/canvas/canvas.css +12 -12
- package/components/card/card.css +83 -83
- package/components/chart/chart.css +224 -224
- package/components/chart-legend/chart-legend.css +26 -26
- package/components/check/check.css +40 -40
- package/components/code/code.css +125 -125
- package/components/col/col.css +15 -15
- package/components/color-picker/color-picker.css +55 -55
- package/components/combobox/class.js +861 -0
- package/components/combobox/combobox.a2ui.json +363 -0
- package/components/combobox/combobox.css +244 -0
- package/components/combobox/combobox.d.ts +113 -0
- package/components/combobox/combobox.examples.md +59 -0
- package/components/combobox/combobox.js +17 -0
- package/components/combobox/combobox.test.js +181 -0
- package/components/combobox/combobox.yaml +369 -0
- package/components/command/command.css +90 -90
- package/components/date-range-picker/class.js +775 -0
- package/components/date-range-picker/date-range-picker.a2ui.json +300 -0
- package/components/date-range-picker/date-range-picker.css +178 -0
- package/components/date-range-picker/date-range-picker.d.ts +82 -0
- package/components/date-range-picker/date-range-picker.examples.md +37 -0
- package/components/date-range-picker/date-range-picker.js +17 -0
- package/components/date-range-picker/date-range-picker.test.js +387 -0
- package/components/date-range-picker/date-range-picker.yaml +285 -0
- package/components/datetime-picker/class.js +706 -0
- package/components/datetime-picker/datetime-picker.a2ui.json +334 -0
- package/components/datetime-picker/datetime-picker.css +150 -0
- package/components/datetime-picker/datetime-picker.d.ts +86 -0
- package/components/datetime-picker/datetime-picker.examples.md +46 -0
- package/components/datetime-picker/datetime-picker.js +17 -0
- package/components/datetime-picker/datetime-picker.test.js +454 -0
- package/components/datetime-picker/datetime-picker.yaml +332 -0
- package/components/demo-toggle/demo-toggle.css +27 -27
- package/components/description-list/description-list.css +18 -18
- package/components/divider/divider.css +24 -24
- package/components/embed/embed.css +6 -6
- package/components/empty-state/empty-state.css +27 -27
- package/components/feed/feed.css +12 -12
- package/components/field/field.css +28 -28
- package/components/fields/fields.css +5 -5
- package/components/grid/grid.css +5 -5
- package/components/heatmap/heatmap.css +63 -63
- package/components/icon/icon.css +12 -12
- package/components/image/image.css +14 -14
- package/components/index.js +8 -0
- package/components/input/input.css +66 -66
- package/components/inspector/inspector.css +6 -6
- package/components/integration-card/class.js +410 -0
- package/components/integration-card/integration-card.a2ui.json +268 -0
- package/components/integration-card/integration-card.css +169 -0
- package/components/integration-card/integration-card.d.ts +63 -0
- package/components/integration-card/integration-card.examples.md +41 -0
- package/components/integration-card/integration-card.js +17 -0
- package/components/integration-card/integration-card.test.js +306 -0
- package/components/integration-card/integration-card.yaml +280 -0
- package/components/kbd/kbd.css +32 -32
- package/components/link/link.css +12 -12
- package/components/list/list.css +8 -8
- package/components/list-window/class.js +688 -0
- package/components/list-window/list-window.a2ui.json +277 -0
- package/components/list-window/list-window.css +124 -0
- package/components/list-window/list-window.d.ts +84 -0
- package/components/list-window/list-window.examples.md +73 -0
- package/components/list-window/list-window.js +17 -0
- package/components/list-window/list-window.test.js +303 -0
- package/components/list-window/list-window.yaml +270 -0
- package/components/menu/menu.css +8 -8
- package/components/modal/modal.css +43 -43
- package/components/nav/nav.css +40 -40
- package/components/nav-group/nav-group.css +52 -52
- package/components/nav-item/nav-item.css +44 -44
- package/components/noodles/noodles.css +31 -31
- package/components/option-card/option-card.css +69 -69
- package/components/otp-input/otp-input.css +30 -30
- package/components/page/page.css +18 -18
- package/components/pagination/pagination.css +61 -61
- package/components/pane/pane.css +57 -57
- package/components/pipeline-status/pipeline-status.css +65 -65
- package/components/popover/popover.css +17 -17
- package/components/progress/progress.css +23 -23
- package/components/progress-row/progress-row.css +17 -17
- package/components/radio/radio.css +39 -39
- package/components/range/range.css +55 -55
- package/components/rating/rating.css +28 -28
- package/components/richtext/richtext.css +133 -133
- package/components/row/row.css +19 -19
- package/components/search/search.css +5 -5
- package/components/segment/segment.css +24 -24
- package/components/segmented/segmented.css +25 -25
- package/components/select/select.css +84 -84
- package/components/skeleton/skeleton.css +14 -14
- package/components/slider/slider.css +46 -46
- package/components/spinner/class.js +69 -0
- package/components/spinner/spinner.a2ui.json +197 -0
- package/components/spinner/spinner.css +165 -0
- package/components/spinner/spinner.d.ts +26 -0
- package/components/spinner/spinner.examples.md +26 -0
- package/components/spinner/spinner.js +17 -0
- package/components/spinner/spinner.test.js +234 -0
- package/components/spinner/spinner.yaml +230 -0
- package/components/stack/stack.css +11 -11
- package/components/stat/stat.css +25 -25
- package/components/step-progress/step-progress.css +20 -20
- package/components/stepper/stepper.css +29 -29
- package/components/stream/stream.css +12 -12
- package/components/swatch/swatch.css +68 -68
- package/components/swiper/swiper.css +57 -57
- package/components/switch/switch.css +52 -52
- package/components/table/table.css +162 -162
- package/components/table-toolbar/table-toolbar.css +32 -32
- package/components/tabs/tabs.css +51 -51
- package/components/tag/tag.css +48 -48
- package/components/text/text.css +44 -44
- package/components/textarea/textarea.css +46 -46
- package/components/time-picker/class.js +693 -0
- package/components/time-picker/time-picker.a2ui.json +267 -0
- package/components/time-picker/time-picker.css +122 -0
- package/components/time-picker/time-picker.d.ts +75 -0
- package/components/time-picker/time-picker.examples.md +35 -0
- package/components/time-picker/time-picker.js +17 -0
- package/components/time-picker/time-picker.test.js +287 -0
- package/components/time-picker/time-picker.yaml +256 -0
- package/components/timeline/timeline.css +50 -50
- package/components/toast/toast.css +58 -58
- package/components/toggle-group/toggle-group.css +6 -6
- package/components/toggle-scheme/toggle-scheme.css +2 -2
- package/components/toolbar/toolbar.css +17 -17
- package/components/tooltip/tooltip.css +2 -2
- package/components/tree/tree.css +37 -37
- package/components/upload/upload.css +49 -49
- package/dist/web-components.min.css +1 -1
- package/dist/web-components.min.js +121 -83
- package/package.json +1 -1
- 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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
|
+
}
|