@lekoala/combobox 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +566 -0
- package/custom-elements.json +247 -0
- package/dist/combobox.css +447 -0
- package/dist/combobox.js +2220 -0
- package/dist/combobox.min.css +1 -0
- package/dist/combobox.min.js +2 -0
- package/dist/types/combo-box.d.ts +112 -0
- package/dist/types/combo-box.d.ts.map +1 -0
- package/dist/types/combobox.d.ts +530 -0
- package/dist/types/combobox.d.ts.map +1 -0
- package/dist/types/define.d.ts +2 -0
- package/dist/types/define.d.ts.map +1 -0
- package/dist/types/helpers.d.ts +251 -0
- package/dist/types/helpers.d.ts.map +1 -0
- package/dist/types/index.d.ts +24 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/locales/de.d.ts +7 -0
- package/dist/types/locales/de.d.ts.map +1 -0
- package/dist/types/locales/en.d.ts +8 -0
- package/dist/types/locales/en.d.ts.map +1 -0
- package/dist/types/locales/es.d.ts +7 -0
- package/dist/types/locales/es.d.ts.map +1 -0
- package/dist/types/locales/fr.d.ts +7 -0
- package/dist/types/locales/fr.d.ts.map +1 -0
- package/dist/types/locales/it.d.ts +7 -0
- package/dist/types/locales/it.d.ts.map +1 -0
- package/dist/types/locales/nl.d.ts +7 -0
- package/dist/types/locales/nl.d.ts.map +1 -0
- package/dist/types/locales/pt.d.ts +7 -0
- package/dist/types/locales/pt.d.ts.map +1 -0
- package/dist/types/locales/ru.d.ts +7 -0
- package/dist/types/locales/ru.d.ts.map +1 -0
- package/dist/types/locales/zh-CN.d.ts +7 -0
- package/dist/types/locales/zh-CN.d.ts.map +1 -0
- package/dist/types/messages.d.ts +48 -0
- package/dist/types/messages.d.ts.map +1 -0
- package/package.json +92 -0
- package/src/combo-box.js +316 -0
- package/src/combobox.css +447 -0
- package/src/combobox.js +2975 -0
- package/src/define.js +20 -0
- package/src/helpers.js +377 -0
- package/src/index.js +35 -0
- package/src/locales/de.js +17 -0
- package/src/locales/en.js +18 -0
- package/src/locales/es.js +17 -0
- package/src/locales/fr.js +17 -0
- package/src/locales/it.js +17 -0
- package/src/locales/nl.js +17 -0
- package/src/locales/pt.js +17 -0
- package/src/locales/ru.js +17 -0
- package/src/locales/zh-CN.js +17 -0
- package/src/messages.js +55 -0
package/src/combobox.js
ADDED
|
@@ -0,0 +1,2975 @@
|
|
|
1
|
+
import {
|
|
2
|
+
hasOwn,
|
|
3
|
+
matchesField,
|
|
4
|
+
moveValueInOrder,
|
|
5
|
+
normalize,
|
|
6
|
+
rankByScore,
|
|
7
|
+
reconcileSelected,
|
|
8
|
+
splitTokens,
|
|
9
|
+
toItem,
|
|
10
|
+
} from "./helpers.js";
|
|
11
|
+
import { DEFAULT_MESSAGES, getDefaultMessages, setDefaultMessages } from "./messages.js";
|
|
12
|
+
|
|
13
|
+
/* ---------------------------------------------------------------------- */
|
|
14
|
+
/* Public type contracts */
|
|
15
|
+
/* ---------------------------------------------------------------------- */
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A selectable source: a free-form `<input list>` or a `<select>`.
|
|
19
|
+
* @typedef {HTMLInputElement | HTMLSelectElement} ComboboxSource
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Matching/search strategy for a combobox.
|
|
24
|
+
* @typedef {"includes" | "startswith" | "fuzzy" | "pattern"} MatchStrategy
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Context passed to filtering, matching, scoring and sorting hooks, plus the
|
|
29
|
+
* async load/create callbacks.
|
|
30
|
+
* @typedef {Object} ComboboxContext
|
|
31
|
+
* @property {Combobox} combobox
|
|
32
|
+
* @property {ComboboxSource} source
|
|
33
|
+
* @property {HTMLInputElement} input The live search/filter input
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Load context for the async `load` callback.
|
|
38
|
+
* @typedef {Object} LoadContext
|
|
39
|
+
* @property {AbortSignal} signal
|
|
40
|
+
* @property {string | null} cursor
|
|
41
|
+
* @property {Combobox} combobox
|
|
42
|
+
* @property {ComboboxSource} source
|
|
43
|
+
* @property {HTMLInputElement} input
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Result of an async load: either a bare item list or `{ items, cursor }` for
|
|
48
|
+
* paged/append-only loading.
|
|
49
|
+
* @typedef {{ items: import("./helpers.js").ComboboxItem[], cursor: string | null } | import("./helpers.js").ComboboxItem[]} LoadResult
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* async load callback.
|
|
54
|
+
* @typedef {(query: string, context: LoadContext) => Promise<LoadResult>} LoadCallback
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Context passed to the create and guards callbacks.
|
|
59
|
+
* @typedef {Object} CreateContext
|
|
60
|
+
* @property {AbortSignal} signal
|
|
61
|
+
* @property {Combobox} combobox
|
|
62
|
+
* @property {ComboboxSource} source
|
|
63
|
+
* @property {HTMLInputElement} input
|
|
64
|
+
* @property {boolean} [fallback] True when running on the native fallback path
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* async create callback.
|
|
69
|
+
* @typedef {(label: string, context: CreateContext) => Promise<any>} CreateCallback
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Guard callback for `add`/`remove`/`clear`. A resolved `false` refuses the
|
|
74
|
+
* change; any other resolved value allows it. A thrown/rejected value is
|
|
75
|
+
* surfaced as an application error (guards distinguish `false` from rejection).
|
|
76
|
+
* @typedef {(payload: any, context: CreateContext) => Promise<boolean | void> | boolean | void} GuardCallback
|
|
77
|
+
*/
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Custom tokenizer seam: `tokens` are complete consumed values; `rest` is the
|
|
81
|
+
* trailing incomplete text that must keep living in the input.
|
|
82
|
+
* @typedef {(value: string, context: ComboboxContext) => { tokens: string[], rest?: string }} TokenizeCallback
|
|
83
|
+
*/
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Render context for the `render.*` hooks.
|
|
87
|
+
* @typedef {Object} RenderContext
|
|
88
|
+
* @property {Combobox} combobox
|
|
89
|
+
* @property {string} [query]
|
|
90
|
+
* @property {boolean} [selected]
|
|
91
|
+
* @property {*} [error]
|
|
92
|
+
*/
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A renderer returns a DOM Node for rich content (strings render as text).
|
|
96
|
+
* Item renderers (`option`, `item`, `group`) receive the item; the row
|
|
97
|
+
* renderers (`create`, `noResults`, `loading`, `error`) receive the query or
|
|
98
|
+
* the load error instead.
|
|
99
|
+
* @typedef {(item: import("./helpers.js").ComboboxItem, context: RenderContext) => Node | string | null | undefined} ItemRenderer
|
|
100
|
+
* @typedef {(query: string, context: RenderContext) => Node | string | null | undefined} TextRenderer
|
|
101
|
+
* @typedef {(query: string, context: RenderContext & { error: any }) => Node | string | null | undefined} ErrorRenderer
|
|
102
|
+
*/
|
|
103
|
+
|
|
104
|
+
/** @type {WeakMap<ComboboxSource, Combobox>} */
|
|
105
|
+
const instances = new WeakMap();
|
|
106
|
+
let uid = 0;
|
|
107
|
+
/** @type {Combobox | null} */
|
|
108
|
+
let openCombobox = null;
|
|
109
|
+
|
|
110
|
+
// Generated UI text lives in messages.js (`render` is the DOM-representation
|
|
111
|
+
// seam and stays separate; behavior options are above it).
|
|
112
|
+
const DEFAULTS = {
|
|
113
|
+
create: false,
|
|
114
|
+
allowEmptyOption: false,
|
|
115
|
+
placeholder: "Search…",
|
|
116
|
+
messages: DEFAULT_MESSAGES,
|
|
117
|
+
match: "includes", // Open UI-aligned: includes | startswith | fuzzy | pattern | function
|
|
118
|
+
searchFields: ["label"],
|
|
119
|
+
minChars: 0,
|
|
120
|
+
load: null,
|
|
121
|
+
loadOnEmpty: false,
|
|
122
|
+
shouldLoad: null,
|
|
123
|
+
debounce: 200,
|
|
124
|
+
createFilter: null,
|
|
125
|
+
maxItems: 0, // 0 = unlimited: cap on selected values, never on rendering
|
|
126
|
+
maxOptions: 0, // 0 = unlimited: cap on rendered options only
|
|
127
|
+
separators: [],
|
|
128
|
+
tokenize: null, // custom seam: (value, ctx) => { tokens: string[], rest?: string }
|
|
129
|
+
closeOnSelect: undefined, // default: single closes, multiple stays open
|
|
130
|
+
createOnBlur: false,
|
|
131
|
+
autoselectFirst: false,
|
|
132
|
+
tabSelect: false, // when true, Tab commits the active option like Enter
|
|
133
|
+
labelField: undefined,
|
|
134
|
+
valueField: undefined,
|
|
135
|
+
guards: {}, // async add/remove/clear guards
|
|
136
|
+
selectionOrder: "source", // source | selected
|
|
137
|
+
observeSource: false, // opt-in MutationObserver -> debounced sync()
|
|
138
|
+
sort: null,
|
|
139
|
+
score: null,
|
|
140
|
+
filter: null,
|
|
141
|
+
render: {},
|
|
142
|
+
anchor: null,
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
function supportsModernCombobox() {
|
|
146
|
+
return (
|
|
147
|
+
typeof HTMLElement.prototype.showPopover === "function" &&
|
|
148
|
+
typeof HTMLElement.prototype.hidePopover === "function" &&
|
|
149
|
+
CSS.supports("position-area: bottom") &&
|
|
150
|
+
CSS.supports("inline-size: anchor-size(width)") &&
|
|
151
|
+
CSS.supports("position-try: flip-block")
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* @param {EventTarget} target
|
|
157
|
+
* @param {string} type
|
|
158
|
+
* @param {Record<string, any>} [detail]
|
|
159
|
+
* @param {{ cancelable?: boolean }} [options]
|
|
160
|
+
* @returns {CustomEvent}
|
|
161
|
+
*/
|
|
162
|
+
function emit(target, type, detail = {}, { cancelable = false } = {}) {
|
|
163
|
+
const event = new CustomEvent(type, {
|
|
164
|
+
bubbles: true,
|
|
165
|
+
cancelable,
|
|
166
|
+
detail,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// Open UI's proposed beforefilter exposes event.query directly. Mirror that
|
|
170
|
+
// now while retaining CustomEvent.detail for ordinary library consumers.
|
|
171
|
+
if (hasOwn(detail, "query")) {
|
|
172
|
+
Object.defineProperty(event, "query", {
|
|
173
|
+
configurable: true,
|
|
174
|
+
enumerable: true,
|
|
175
|
+
value: detail.query,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
target.dispatchEvent(event);
|
|
180
|
+
return event;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* @param {number} ms
|
|
185
|
+
* @param {AbortSignal} [signal]
|
|
186
|
+
* @returns {Promise<void>}
|
|
187
|
+
*/
|
|
188
|
+
function wait(ms, signal) {
|
|
189
|
+
return new Promise((resolve, reject) => {
|
|
190
|
+
const timer = setTimeout(resolve, ms);
|
|
191
|
+
signal?.addEventListener(
|
|
192
|
+
"abort",
|
|
193
|
+
() => {
|
|
194
|
+
clearTimeout(timer);
|
|
195
|
+
reject(new DOMException("Aborted", "AbortError"));
|
|
196
|
+
},
|
|
197
|
+
{ once: true },
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* @param {HTMLElement} element
|
|
204
|
+
* @param {Node | string | null | undefined} content
|
|
205
|
+
*/
|
|
206
|
+
function setContent(element, content) {
|
|
207
|
+
element.replaceChildren();
|
|
208
|
+
if (content instanceof Node) {
|
|
209
|
+
element.append(content);
|
|
210
|
+
} else if (content !== null && content !== undefined) {
|
|
211
|
+
// Strings are text by default. Rich HTML should be returned as DOM Nodes,
|
|
212
|
+
// which keeps the default renderer safe without a global allowHtml mode.
|
|
213
|
+
element.textContent = String(content);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Stable, font-independent icon for generated remove buttons. */
|
|
218
|
+
function createRemoveIcon() {
|
|
219
|
+
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
220
|
+
svg.setAttribute("viewBox", "0 0 20 20");
|
|
221
|
+
svg.setAttribute("aria-hidden", "true");
|
|
222
|
+
svg.setAttribute("focusable", "false");
|
|
223
|
+
|
|
224
|
+
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
225
|
+
path.setAttribute("d", "M4 4l12 12m0-12L4 16");
|
|
226
|
+
svg.append(path);
|
|
227
|
+
return svg;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Snapshot a set of attributes on an element we do not own so `dispose()` can
|
|
232
|
+
* restore the authored state exactly. The returned object has a `restore()`
|
|
233
|
+
* method: attributes that were absent are removed, those that had a value are
|
|
234
|
+
* written back. This makes upgrade/dispose symmetry impossible to forget.
|
|
235
|
+
*
|
|
236
|
+
* @param {Element} element
|
|
237
|
+
* @param {string[]} names
|
|
238
|
+
* @returns {AttributeSnapshot}
|
|
239
|
+
*/
|
|
240
|
+
function captureAttributes(element, names) {
|
|
241
|
+
const original = new Map(names.map((name) => [name, element.getAttribute(name)]));
|
|
242
|
+
return {
|
|
243
|
+
restore() {
|
|
244
|
+
for (const [name, value] of original) {
|
|
245
|
+
if (value === null) element.removeAttribute(name);
|
|
246
|
+
else element.setAttribute(name, value);
|
|
247
|
+
}
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Every attribute the engine may touch on an input it does not own (the source
|
|
253
|
+
// input for input+datalist, or an authored filter input). Snapshot before
|
|
254
|
+
// mutation, restore on dispose().
|
|
255
|
+
const INPUT_ATTRS = [
|
|
256
|
+
"list",
|
|
257
|
+
"name",
|
|
258
|
+
"type",
|
|
259
|
+
"autocomplete",
|
|
260
|
+
"spellcheck",
|
|
261
|
+
"placeholder",
|
|
262
|
+
"hidden",
|
|
263
|
+
"tabindex",
|
|
264
|
+
"role",
|
|
265
|
+
"aria-autocomplete",
|
|
266
|
+
"aria-expanded",
|
|
267
|
+
"aria-controls",
|
|
268
|
+
"aria-activedescendant",
|
|
269
|
+
"aria-invalid",
|
|
270
|
+
"aria-label",
|
|
271
|
+
"aria-labelledby",
|
|
272
|
+
"aria-required",
|
|
273
|
+
"aria-describedby",
|
|
274
|
+
"style",
|
|
275
|
+
];
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Optional bookkeeping snapshot of one element's attributes so `dispose()` can
|
|
279
|
+
* restore the authored state exactly.
|
|
280
|
+
* @typedef {Object} AttributeSnapshot
|
|
281
|
+
* @property {() => void} restore Restore the captured attributes (removes what
|
|
282
|
+
* was absent, rewrites what had a value)
|
|
283
|
+
*/
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* UI status messages (noResults/loading/loadError text, create/position label
|
|
287
|
+
* producers). See messages.js for the canonical catalog and the locale
|
|
288
|
+
* registry.
|
|
289
|
+
* @typedef {import("./messages.js").Messages} Messages
|
|
290
|
+
*/
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Optional DOM-representation hooks. `option`/`group`/`item`/`create`/
|
|
294
|
+
* `noResults`/`loading`/`error` render the respective rows; a returned DOM
|
|
295
|
+
* Node is inserted for rich content, anything else (strings) is text.
|
|
296
|
+
* @typedef {Object} RenderMap
|
|
297
|
+
* @property {ItemRenderer} [option]
|
|
298
|
+
* @property {TextRenderer} [group]
|
|
299
|
+
* @property {ItemRenderer} [item]
|
|
300
|
+
* @property {TextRenderer} [create]
|
|
301
|
+
* @property {TextRenderer} [noResults]
|
|
302
|
+
* @property {TextRenderer} [loading]
|
|
303
|
+
* @property {ErrorRenderer} [error]
|
|
304
|
+
*/
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Async guards keyed by lifecycle phase. `false` is a voluntary refusal that
|
|
308
|
+
* mutates nothing; a rejected promise is an application error surfaced via
|
|
309
|
+
* `combobox:guarderror`.
|
|
310
|
+
* @typedef {Object} GuardMap
|
|
311
|
+
* @property {GuardCallback} [add]
|
|
312
|
+
* @property {GuardCallback} [remove]
|
|
313
|
+
* @property {GuardCallback} [clear]
|
|
314
|
+
*/
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Combobox configuration. All properties are optional; the engine deep-merges
|
|
318
|
+
* with its defaults. Callbacks are the JavaScript-only surface.
|
|
319
|
+
* @typedef {Object} ComboboxOptions
|
|
320
|
+
* @property {MatchStrategy | ((item: import("./helpers.js").ComboboxItem, query: string, context: ComboboxContext) => boolean)} [match]
|
|
321
|
+
* @property {string | string[]} [searchFields]
|
|
322
|
+
* @property {number} [minChars]
|
|
323
|
+
* @property {boolean} [allowEmptyOption]
|
|
324
|
+
* @property {string} [placeholder]
|
|
325
|
+
* @property {Messages} [messages]
|
|
326
|
+
* @property {LoadCallback} [load]
|
|
327
|
+
* @property {boolean} [loadOnEmpty]
|
|
328
|
+
* @property {(query: string, context: ComboboxContext) => boolean} [shouldLoad]
|
|
329
|
+
* @property {number} [debounce]
|
|
330
|
+
* @property {(value: string, context: ComboboxContext) => boolean} [createFilter]
|
|
331
|
+
* @property {boolean | CreateCallback} [create]
|
|
332
|
+
* @property {number} [maxItems]
|
|
333
|
+
* @property {number} [maxOptions]
|
|
334
|
+
* @property {string[]} [separators]
|
|
335
|
+
* @property {TokenizeCallback} [tokenize]
|
|
336
|
+
* @property {boolean} [closeOnSelect]
|
|
337
|
+
* @property {boolean} [createOnBlur]
|
|
338
|
+
* @property {boolean} [autoselectFirst]
|
|
339
|
+
* @property {boolean} [tabSelect]
|
|
340
|
+
* @property {string} [labelField]
|
|
341
|
+
* @property {string} [valueField]
|
|
342
|
+
* @property {GuardMap} [guards]
|
|
343
|
+
* @property {"source" | "selected"} [selectionOrder]
|
|
344
|
+
* @property {boolean} [observeSource]
|
|
345
|
+
* @property {RenderMap} [render]
|
|
346
|
+
* @property {HTMLElement} [anchor] Consumer-authored positioning/control region
|
|
347
|
+
* @property {(a: import("./helpers.js").ComboboxItem, b: import("./helpers.js").ComboboxItem, query: string, context: ComboboxContext) => number} [sort]
|
|
348
|
+
* @property {(item: import("./helpers.js").ComboboxItem, query: string, context: ComboboxContext) => number | false | null} [score]
|
|
349
|
+
* @property {(item: import("./helpers.js").ComboboxItem, query: string, context: ComboboxContext) => boolean} [filter]
|
|
350
|
+
*/
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Internal options after the defaults merge. Every defaulted field is present
|
|
354
|
+
* and readable without optional-chaining; the nullable seams (`load`, `create`
|
|
355
|
+
* and friends) stay nullable and are always guarded by `typeof === "function"`.
|
|
356
|
+
* @typedef {ComboboxOptions & {
|
|
357
|
+
* match: MatchStrategy | ((item: import("./helpers.js").ComboboxItem, query: string, context: ComboboxContext) => boolean),
|
|
358
|
+
* searchFields: string | string[],
|
|
359
|
+
* minChars: number,
|
|
360
|
+
* allowEmptyOption: boolean,
|
|
361
|
+
* placeholder: string,
|
|
362
|
+
* messages: Messages,
|
|
363
|
+
* loadOnEmpty: boolean,
|
|
364
|
+
* debounce: number,
|
|
365
|
+
* maxItems: number,
|
|
366
|
+
* maxOptions: number,
|
|
367
|
+
* separators: string[],
|
|
368
|
+
* createOnBlur: boolean,
|
|
369
|
+
* autoselectFirst: boolean,
|
|
370
|
+
* tabSelect: boolean,
|
|
371
|
+
* guards: GuardMap,
|
|
372
|
+
* selectionOrder: "source" | "selected",
|
|
373
|
+
* observeSource: boolean,
|
|
374
|
+
* render: RenderMap,
|
|
375
|
+
* load: LoadCallback | null,
|
|
376
|
+
* create: boolean | CreateCallback,
|
|
377
|
+
* createFilter: ((value: string, context: ComboboxContext) => boolean) | null,
|
|
378
|
+
* shouldLoad: ((query: string, context: ComboboxContext) => boolean) | null,
|
|
379
|
+
* tokenize: TokenizeCallback | null,
|
|
380
|
+
* sort: ((a: import("./helpers.js").ComboboxItem, b: import("./helpers.js").ComboboxItem, query: string, context: ComboboxContext) => number) | null,
|
|
381
|
+
* score: ((item: import("./helpers.js").ComboboxItem, query: string, context: ComboboxContext) => number | false | null) | null,
|
|
382
|
+
* filter: ((item: import("./helpers.js").ComboboxItem, query: string, context: ComboboxContext) => boolean) | null,
|
|
383
|
+
* anchor: HTMLElement | null,
|
|
384
|
+
* }} ResolvedOptions
|
|
385
|
+
*/
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* State produced by the two initialisation branches (select vs input+datalist).
|
|
389
|
+
* Both return every property so the constructor receives a uniform view; the
|
|
390
|
+
* null-able entries are genuinely branch- or mode-conditional.
|
|
391
|
+
* @typedef {Object} ViewState
|
|
392
|
+
* @property {HTMLElement | null} control Wrapper control (select-only; null for
|
|
393
|
+
* an input whose source input is itself the control)
|
|
394
|
+
* @property {HTMLInputElement} input
|
|
395
|
+
* @property {HTMLElement | null} chips Only meaningful for a select
|
|
396
|
+
* @property {HTMLDataListElement | null} datalist Only meaningful for an input
|
|
397
|
+
* @property {AttributeSnapshot | null} inputSnapshot
|
|
398
|
+
* @property {AttributeSnapshot | null} sourceSnapshot
|
|
399
|
+
*/
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Native-first combobox / filterable-select skeleton.
|
|
403
|
+
*
|
|
404
|
+
* The source element is always the form-value owner:
|
|
405
|
+
* - <input list>: the original input owns arbitrary text.
|
|
406
|
+
* - <select>: the original select owns one constrained value.
|
|
407
|
+
* - <select multiple>: selected <option>s own multiple values.
|
|
408
|
+
*
|
|
409
|
+
* The modern select filter input is a separate, unnamed interaction control.
|
|
410
|
+
* It may be generated, or supplied explicitly through a liaison attribute on
|
|
411
|
+
* the input itself: <input data-filter-for="select-id" hidden>.
|
|
412
|
+
*/
|
|
413
|
+
export class Combobox {
|
|
414
|
+
static supported = supportsModernCombobox();
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Read the current default UI messages. Returns a shallow copy; mutating
|
|
418
|
+
* the result does not affect the engine.
|
|
419
|
+
* @returns {Messages}
|
|
420
|
+
*/
|
|
421
|
+
static getDefaultMessages() {
|
|
422
|
+
return getDefaultMessages();
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Merge application or locale-provided UI text into the default messages.
|
|
427
|
+
* Called by the shipped `locales/*` modules on import. Only comboboxes
|
|
428
|
+
* created *after* this call see the new text: instances resolve their
|
|
429
|
+
* messages as a snapshot at construction time. Per-instance `messages`
|
|
430
|
+
* options always take precedence over these defaults. Missing keys keep
|
|
431
|
+
* their current translation, and producer keys (`create`, `position`) stay
|
|
432
|
+
* functions.
|
|
433
|
+
* @param {Partial<Messages>} messages
|
|
434
|
+
* @returns {void}
|
|
435
|
+
*/
|
|
436
|
+
static setDefaultMessages(messages) {
|
|
437
|
+
setDefaultMessages(messages);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Discover and enhance combobox sources. This is a discovery/creation API
|
|
442
|
+
* only: an element that already has an instance is returned as-is and never
|
|
443
|
+
* reconfigured with new options (idempotence is unambiguous).
|
|
444
|
+
*
|
|
445
|
+
* Valid shapes:
|
|
446
|
+
* init("selector") / init("selector", options)
|
|
447
|
+
* init(root, "selector") / init(root, "selector", options)
|
|
448
|
+
* init([element, ...], options) / init(nodeList, options)
|
|
449
|
+
*
|
|
450
|
+
* Discovery is always explicit: a string root is a CSS selector, an
|
|
451
|
+
* Element/Document root is a scope for the selector, and any other iterable
|
|
452
|
+
* is a list of source elements. An element root without a selector and a bare
|
|
453
|
+
* init() discover nothing (there is no implicit `data-*` marker). Unsupported
|
|
454
|
+
* elements inside collections are ignored without invalidating the call.
|
|
455
|
+
* Returns the array of Combobox instances.
|
|
456
|
+
*
|
|
457
|
+
* @param {string | ParentNode | Iterable<EventTarget> | null | undefined} rootOrSelector
|
|
458
|
+
* @param {string | ComboboxOptions | null | undefined} selectorOrOptions
|
|
459
|
+
* @param {ComboboxOptions} maybeOptions
|
|
460
|
+
* @returns {Combobox[]}
|
|
461
|
+
*/
|
|
462
|
+
static init(rootOrSelector = document, selectorOrOptions = null, maybeOptions = {}) {
|
|
463
|
+
/** @type {ComboboxSource[]} */
|
|
464
|
+
const targets = [];
|
|
465
|
+
/** @type {ComboboxOptions} */
|
|
466
|
+
let options = {};
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* A root either is a Node (scope for a selector) or a collection of sources.
|
|
470
|
+
* @param {unknown} value
|
|
471
|
+
* @returns {value is Node}
|
|
472
|
+
*/
|
|
473
|
+
const isNode = (value) => value instanceof Node;
|
|
474
|
+
/**
|
|
475
|
+
* Accept only plain option objects (not selectors, elements or arrays).
|
|
476
|
+
* @param {unknown} value
|
|
477
|
+
* @returns {ComboboxOptions}
|
|
478
|
+
*/
|
|
479
|
+
const picks = (value) =>
|
|
480
|
+
value !== null && typeof value === "object" && !isNode(value) && !Array.isArray(value)
|
|
481
|
+
? /** @type {ComboboxOptions} */ (value)
|
|
482
|
+
: {};
|
|
483
|
+
|
|
484
|
+
if (typeof rootOrSelector === "string") {
|
|
485
|
+
targets.push(.../** @type {Iterable<ComboboxSource>} */ (document.querySelectorAll(rootOrSelector)));
|
|
486
|
+
options = picks(selectorOrOptions);
|
|
487
|
+
} else if (isNode(rootOrSelector)) {
|
|
488
|
+
const root = /** @type {ParentNode} */ (rootOrSelector);
|
|
489
|
+
if (typeof selectorOrOptions === "string") {
|
|
490
|
+
targets.push(.../** @type {Iterable<ComboboxSource>} */ (root.querySelectorAll(selectorOrOptions)));
|
|
491
|
+
options = maybeOptions;
|
|
492
|
+
} else {
|
|
493
|
+
// An element root needs an explicit selector; options alone do not pick
|
|
494
|
+
// targets. This keeps `data-*` discovery out of the API.
|
|
495
|
+
options = picks(selectorOrOptions);
|
|
496
|
+
}
|
|
497
|
+
} else {
|
|
498
|
+
targets.push(.../** @type {Iterable<ComboboxSource>} */ (Array.from(rootOrSelector ?? [])));
|
|
499
|
+
options = picks(selectorOrOptions);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** @type {Combobox[]} */
|
|
503
|
+
const instances = [];
|
|
504
|
+
for (const element of targets) {
|
|
505
|
+
if (!(element instanceof HTMLInputElement || element instanceof HTMLSelectElement)) continue;
|
|
506
|
+
const instance = Combobox.getOrCreateInstance(element, options);
|
|
507
|
+
if (instance && !instances.includes(instance)) instances.push(instance);
|
|
508
|
+
}
|
|
509
|
+
return instances;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* @param {ComboboxSource} element
|
|
514
|
+
* @returns {Combobox | null}
|
|
515
|
+
*/
|
|
516
|
+
static getInstance(element) {
|
|
517
|
+
return instances.get(element) ?? null;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* @param {ComboboxSource} element
|
|
522
|
+
* @param {ComboboxOptions} options
|
|
523
|
+
* @returns {Combobox}
|
|
524
|
+
*/
|
|
525
|
+
static getOrCreateInstance(element, options = {}) {
|
|
526
|
+
return Combobox.getInstance(element) ?? new Combobox(element, options);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* @param {ComboboxSource} element
|
|
531
|
+
* @param {ComboboxOptions} options
|
|
532
|
+
*/
|
|
533
|
+
constructor(element, options = {}) {
|
|
534
|
+
if (!(element instanceof HTMLInputElement || element instanceof HTMLSelectElement)) {
|
|
535
|
+
throw new TypeError("Combobox expects an input or select element");
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** @type {ComboboxSource} */
|
|
539
|
+
this.source = element;
|
|
540
|
+
this.isSelect = element instanceof HTMLSelectElement;
|
|
541
|
+
this.isMultiple = this.isSelect && element.multiple;
|
|
542
|
+
this.abortController = new AbortController();
|
|
543
|
+
/** @type {AbortController | null} */
|
|
544
|
+
this.loadController = null;
|
|
545
|
+
this.activeIndex = -1;
|
|
546
|
+
/** @type {import("./helpers.js").ComboboxItem[]} */
|
|
547
|
+
this.filteredItems = [];
|
|
548
|
+
// Remote/custom results are deliberately transient. The native select is
|
|
549
|
+
// the selection/value owner, not a cache for every server result.
|
|
550
|
+
/** @type {import("./helpers.js").ComboboxItem[] | null} */
|
|
551
|
+
this.results = null;
|
|
552
|
+
// For a <select>, option identity is the HTMLOptionElement itself; a
|
|
553
|
+
// duplicate `value` does not collapse identities. `selectionOrder` holds
|
|
554
|
+
// option references (in source order initially), `value` is payload only.
|
|
555
|
+
/** @type {HTMLOptionElement[]} */
|
|
556
|
+
this.selectionOrder = this.isSelect ? Array.from(this.#selectSource().selectedOptions) : [];
|
|
557
|
+
/** @type {WeakMap<HTMLElement, HTMLOptionElement>} */
|
|
558
|
+
this._chipOptions = new WeakMap();
|
|
559
|
+
this.searchGeneration = 0;
|
|
560
|
+
/** @type {string | null} */
|
|
561
|
+
this.nextCursor = null;
|
|
562
|
+
this.loading = false;
|
|
563
|
+
/** @type {*} */
|
|
564
|
+
this.loadError = null;
|
|
565
|
+
this.query = "";
|
|
566
|
+
this.id = ++uid;
|
|
567
|
+
this.mode =
|
|
568
|
+
/** @type {ComboboxOptions & { mode?: "fallback" }} */ (options).mode === "fallback" ||
|
|
569
|
+
!Combobox.supported
|
|
570
|
+
? "fallback"
|
|
571
|
+
: "enhanced";
|
|
572
|
+
this.anchorName = `--combobox-${this.id}`;
|
|
573
|
+
this.suppressReopen = false;
|
|
574
|
+
this.composing = false;
|
|
575
|
+
/** @type {MutationObserver | null} */
|
|
576
|
+
this._sourceObserver = null;
|
|
577
|
+
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
578
|
+
this._sourceSyncTimer = null;
|
|
579
|
+
|
|
580
|
+
/** @type {ComboboxOptions} */
|
|
581
|
+
this.explicitOptions = options;
|
|
582
|
+
/** @type {ResolvedOptions} */
|
|
583
|
+
this.options = /** @type {ResolvedOptions} */ ({
|
|
584
|
+
...DEFAULTS,
|
|
585
|
+
...options,
|
|
586
|
+
messages: {
|
|
587
|
+
...DEFAULT_MESSAGES,
|
|
588
|
+
...(options.messages || {}),
|
|
589
|
+
},
|
|
590
|
+
render: {
|
|
591
|
+
...DEFAULTS.render,
|
|
592
|
+
...(options.render || {}),
|
|
593
|
+
},
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
this.original = {
|
|
597
|
+
// explicit filter input
|
|
598
|
+
/** @type {Comment | null} */
|
|
599
|
+
filterInputPlaceholder: null,
|
|
600
|
+
// detached datalist position marker
|
|
601
|
+
/** @type {Comment | null} */
|
|
602
|
+
datalistPlaceholder: null,
|
|
603
|
+
// <label> elements whose id the engine invented for aria-labelledby
|
|
604
|
+
/** @type {Array<{ label: HTMLLabelElement, id: string }>} */
|
|
605
|
+
inventedLabels: [],
|
|
606
|
+
};
|
|
607
|
+
/** @type {HTMLLabelElement[]} */
|
|
608
|
+
this.boundLabels = [];
|
|
609
|
+
this.ownsInput = false;
|
|
610
|
+
/** @type {HTMLElement | null} */
|
|
611
|
+
this.fallbackControl = null;
|
|
612
|
+
|
|
613
|
+
/** @type {HTMLElement | null} */
|
|
614
|
+
this.control = null;
|
|
615
|
+
/** @type {HTMLElement | null} */
|
|
616
|
+
this.anchor = null;
|
|
617
|
+
/** @type {AttributeSnapshot | null} */
|
|
618
|
+
this.anchorSnapshot = null;
|
|
619
|
+
/** @type {HTMLInputElement | null} */
|
|
620
|
+
this.input = null;
|
|
621
|
+
/** @type {HTMLElement | null} */
|
|
622
|
+
this.chips = null;
|
|
623
|
+
/** @type {HTMLDataListElement | null} */
|
|
624
|
+
this.datalist = null;
|
|
625
|
+
/** @type {AttributeSnapshot | null} */
|
|
626
|
+
this.inputSnapshot = null;
|
|
627
|
+
/** @type {AttributeSnapshot | null} */
|
|
628
|
+
this.sourceSnapshot = null;
|
|
629
|
+
/** @type {HTMLElement | null} */
|
|
630
|
+
this.popover = null;
|
|
631
|
+
/** @type {HTMLElement | null} */
|
|
632
|
+
this.listbox = null;
|
|
633
|
+
/** @type {HTMLElement | null} */
|
|
634
|
+
this.status = null;
|
|
635
|
+
|
|
636
|
+
if (this.mode === "fallback") {
|
|
637
|
+
this.#initFallback();
|
|
638
|
+
} else {
|
|
639
|
+
// Registration is transactional: an element whose init throws (e.g. a
|
|
640
|
+
// broken datalist) must not stay associated with a half-built instance.
|
|
641
|
+
try {
|
|
642
|
+
const view =
|
|
643
|
+
element instanceof HTMLSelectElement ? this.#enhanceSelect(element) : this.#enhanceInput(element);
|
|
644
|
+
this.control = view.control;
|
|
645
|
+
this.input = view.input;
|
|
646
|
+
this.chips = view.chips;
|
|
647
|
+
this.datalist = view.datalist;
|
|
648
|
+
this.inputSnapshot = view.inputSnapshot;
|
|
649
|
+
this.sourceSnapshot = view.sourceSnapshot;
|
|
650
|
+
|
|
651
|
+
const requestedAnchor = this.options.anchor;
|
|
652
|
+
this.anchor = requestedAnchor instanceof HTMLElement ? requestedAnchor : view.control || view.input;
|
|
653
|
+
if (this.anchor !== view.control && this.anchor !== view.input) {
|
|
654
|
+
this.anchorSnapshot = captureAttributes(this.anchor, ["style"]);
|
|
655
|
+
}
|
|
656
|
+
this.anchor.style.setProperty("anchor-name", this.anchorName);
|
|
657
|
+
|
|
658
|
+
const picker = this.#createPicker();
|
|
659
|
+
this.popover = picker.popover;
|
|
660
|
+
this.listbox = picker.listbox;
|
|
661
|
+
this.status = picker.status;
|
|
662
|
+
|
|
663
|
+
this.#bind();
|
|
664
|
+
this.refresh();
|
|
665
|
+
this.#watchSource();
|
|
666
|
+
} catch (error) {
|
|
667
|
+
this.dispose();
|
|
668
|
+
throw error;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
instances.set(element, this);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* Discriminate the source to a `<select>`. A select-backed combobox is the
|
|
677
|
+
* only context that calls the select-only operations, so this throws when
|
|
678
|
+
* the invariant is violated rather than casting (an unchecked cast would
|
|
679
|
+
* silently lie to the checker).
|
|
680
|
+
* @returns {HTMLSelectElement}
|
|
681
|
+
*/
|
|
682
|
+
#selectSource() {
|
|
683
|
+
if (!(this.source instanceof HTMLSelectElement)) {
|
|
684
|
+
throw new TypeError("Expected a select-backed combobox");
|
|
685
|
+
}
|
|
686
|
+
return this.source;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* The interaction input. Only enhanced instances call this, and enhanced
|
|
691
|
+
* construction always creates the input — an absent input is an invariant
|
|
692
|
+
* violation, not a normal runtime condition.
|
|
693
|
+
* @returns {HTMLInputElement}
|
|
694
|
+
*/
|
|
695
|
+
#inputEl() {
|
|
696
|
+
return /** @type {HTMLInputElement} */ (this.input);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* The popover picker root. Enhanced instances always have one.
|
|
701
|
+
* @returns {HTMLElement}
|
|
702
|
+
*/
|
|
703
|
+
#popoverEl() {
|
|
704
|
+
return /** @type {HTMLElement} */ (this.popover);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* The listbox container. Enhanced instances always have one.
|
|
709
|
+
* @returns {HTMLElement}
|
|
710
|
+
*/
|
|
711
|
+
#listEl() {
|
|
712
|
+
return /** @type {HTMLElement} */ (this.listbox);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* The live status region. Enhanced instances always have one.
|
|
717
|
+
* @returns {HTMLElement}
|
|
718
|
+
*/
|
|
719
|
+
#statusEl() {
|
|
720
|
+
return /** @type {HTMLElement} */ (this.status);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* The chips container. Only select-backed enhanced instances have chips.
|
|
725
|
+
* @returns {HTMLElement | null}
|
|
726
|
+
*/
|
|
727
|
+
#chipsEl() {
|
|
728
|
+
return this.chips;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/* ---------------------------------------------------------------------- */
|
|
732
|
+
/* Progressive fallback */
|
|
733
|
+
/* ---------------------------------------------------------------------- */
|
|
734
|
+
|
|
735
|
+
#initFallback() {
|
|
736
|
+
// Native input+datalist and native selects already work. The only cheap
|
|
737
|
+
// enhancement worth retaining is creation: keep the native multiple
|
|
738
|
+
// select visible and add an unnamed text input + Add button.
|
|
739
|
+
if (!this.isSelect || !this.options.create) return;
|
|
740
|
+
|
|
741
|
+
const control = document.createElement("div");
|
|
742
|
+
control.className = "cb-fallback-create";
|
|
743
|
+
|
|
744
|
+
const input = document.createElement("input");
|
|
745
|
+
input.type = "text";
|
|
746
|
+
input.className = "cb-fallback-input";
|
|
747
|
+
input.placeholder = this.options.placeholder ?? "";
|
|
748
|
+
input.autocomplete = "off";
|
|
749
|
+
input.setAttribute("aria-label", this.options.placeholder ?? "");
|
|
750
|
+
// Deliberately no name: the select remains the only successful control.
|
|
751
|
+
|
|
752
|
+
const button = document.createElement("button");
|
|
753
|
+
button.type = "button";
|
|
754
|
+
button.className = "cb-fallback-add";
|
|
755
|
+
button.textContent = "Add";
|
|
756
|
+
|
|
757
|
+
const add = async () => {
|
|
758
|
+
const label = input.value.trim();
|
|
759
|
+
if (!this.#canCreate(label)) return;
|
|
760
|
+
await this.#createFallbackOption(label, input);
|
|
761
|
+
input.value = "";
|
|
762
|
+
input.focus();
|
|
763
|
+
};
|
|
764
|
+
|
|
765
|
+
button.addEventListener("click", add, { signal: this.abortController.signal });
|
|
766
|
+
input.addEventListener(
|
|
767
|
+
"keydown",
|
|
768
|
+
(event) => {
|
|
769
|
+
if (event.key === "Enter") {
|
|
770
|
+
event.preventDefault();
|
|
771
|
+
add();
|
|
772
|
+
}
|
|
773
|
+
},
|
|
774
|
+
{ signal: this.abortController.signal },
|
|
775
|
+
);
|
|
776
|
+
|
|
777
|
+
control.append(input, button);
|
|
778
|
+
this.source.after(control);
|
|
779
|
+
this.fallbackControl = control;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* Native-fallback creation: run the guard/create pipeline and materialize the
|
|
784
|
+
* option on the visible select.
|
|
785
|
+
* @param {string} label
|
|
786
|
+
* @param {HTMLInputElement} input The fallback Add control
|
|
787
|
+
* @returns {Promise<HTMLOptionElement | null>}
|
|
788
|
+
*/
|
|
789
|
+
async #createFallbackOption(label, input) {
|
|
790
|
+
const guard = await this.#runGuard("add", { label });
|
|
791
|
+
if (!guard.ok) return null;
|
|
792
|
+
|
|
793
|
+
const before = emit(
|
|
794
|
+
this.source,
|
|
795
|
+
"combobox:beforecreate",
|
|
796
|
+
{ combobox: this, label },
|
|
797
|
+
{ cancelable: true },
|
|
798
|
+
);
|
|
799
|
+
if (before.defaultPrevented) return null;
|
|
800
|
+
|
|
801
|
+
/** @type {import("./helpers.js").ComboboxItem} */
|
|
802
|
+
let created = { value: label, label };
|
|
803
|
+
try {
|
|
804
|
+
if (typeof this.options.create === "function") {
|
|
805
|
+
const result = await this.options.create(label, {
|
|
806
|
+
signal: this.abortController.signal,
|
|
807
|
+
combobox: this,
|
|
808
|
+
source: this.source,
|
|
809
|
+
input,
|
|
810
|
+
fallback: true,
|
|
811
|
+
});
|
|
812
|
+
if (!result) return null;
|
|
813
|
+
created = /** @type {import("./helpers.js").ComboboxItem} */ (toItem(result, this.#fields()));
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
let option = this.#findOption(created.value);
|
|
817
|
+
if (!option) {
|
|
818
|
+
// Creation changes live form state, never the form-reset baseline.
|
|
819
|
+
option = new Option(created.label, created.value, false, true);
|
|
820
|
+
if (created.data) Object.assign(option.dataset, created.data);
|
|
821
|
+
this.#selectSource().add(option);
|
|
822
|
+
} else {
|
|
823
|
+
option.selected = true;
|
|
824
|
+
}
|
|
825
|
+
this.#rememberSelection(option);
|
|
826
|
+
this.#dispatchNativeValueEvents();
|
|
827
|
+
emit(this.source, "combobox:create", { combobox: this, item: { ...created, option, selected: true } });
|
|
828
|
+
return option;
|
|
829
|
+
} catch (/** @type {any} */ error) {
|
|
830
|
+
if (error?.name !== "AbortError") {
|
|
831
|
+
emit(this.source, "combobox:createerror", { combobox: this, label, error });
|
|
832
|
+
}
|
|
833
|
+
return null;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/* ---------------------------------------------------------------------- */
|
|
838
|
+
/* Source adapters */
|
|
839
|
+
/* ---------------------------------------------------------------------- */
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Prepare the input+datalist view. The input is its own control (there is no
|
|
843
|
+
* wrapper), so `control` is null and `input` is the source itself.
|
|
844
|
+
* @param {HTMLInputElement} source
|
|
845
|
+
* @returns {ViewState}
|
|
846
|
+
*/
|
|
847
|
+
#enhanceInput(source) {
|
|
848
|
+
const listId = source.getAttribute("list");
|
|
849
|
+
if (!listId) throw new TypeError("Input combobox expects an input with a datalist");
|
|
850
|
+
|
|
851
|
+
const datalist = document.getElementById(listId);
|
|
852
|
+
if (!(datalist instanceof HTMLDataListElement)) {
|
|
853
|
+
throw new TypeError(`No datalist found for #${listId}`);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
const inputSnapshot = captureAttributes(source, INPUT_ATTRS);
|
|
857
|
+
|
|
858
|
+
// In enhanced mode the datalist is a data source only. Detach it so the UA
|
|
859
|
+
// picker can never flash/race our popover. dispose() restores it exactly.
|
|
860
|
+
source.removeAttribute("list");
|
|
861
|
+
source.autocomplete = "off";
|
|
862
|
+
const datalistPlaceholder = document.createComment(`combobox-datalist-${this.id}`);
|
|
863
|
+
this.original.datalistPlaceholder = datalistPlaceholder;
|
|
864
|
+
datalist.before(datalistPlaceholder);
|
|
865
|
+
datalist.remove();
|
|
866
|
+
|
|
867
|
+
source.classList.add("cb-text-control");
|
|
868
|
+
return {
|
|
869
|
+
control: null,
|
|
870
|
+
input: source,
|
|
871
|
+
chips: null,
|
|
872
|
+
datalist,
|
|
873
|
+
inputSnapshot,
|
|
874
|
+
sourceSnapshot: null,
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/**
|
|
879
|
+
* Prepare the select view: a wrapper control holding chips and the filter
|
|
880
|
+
* input, plus a snapshot of the untouched source attributes.
|
|
881
|
+
* @param {HTMLSelectElement} source
|
|
882
|
+
* @returns {ViewState}
|
|
883
|
+
*/
|
|
884
|
+
#enhanceSelect(source) {
|
|
885
|
+
source.classList.add("cb-source-hidden");
|
|
886
|
+
const sourceSnapshot = captureAttributes(source, ["aria-hidden", "tabindex"]);
|
|
887
|
+
source.tabIndex = -1;
|
|
888
|
+
source.setAttribute("aria-hidden", "true");
|
|
889
|
+
|
|
890
|
+
const control = document.createElement("div");
|
|
891
|
+
control.className = `cb-control ${this.isMultiple ? "cb-control-multiple" : "cb-control-single"}`;
|
|
892
|
+
const chips = document.createElement("span");
|
|
893
|
+
chips.className = "cb-chips";
|
|
894
|
+
control.append(chips);
|
|
895
|
+
|
|
896
|
+
const { input, inputSnapshot } = this.#resolveFilterInput();
|
|
897
|
+
input.classList.add("cb-input");
|
|
898
|
+
input.type = "text";
|
|
899
|
+
input.autocomplete = "off";
|
|
900
|
+
input.spellcheck = false;
|
|
901
|
+
input.removeAttribute("name");
|
|
902
|
+
|
|
903
|
+
if (!input.placeholder) input.placeholder = this.options.placeholder ?? "";
|
|
904
|
+
|
|
905
|
+
this.#copyAccessibleName(input);
|
|
906
|
+
control.append(input);
|
|
907
|
+
source.after(control);
|
|
908
|
+
|
|
909
|
+
return {
|
|
910
|
+
control,
|
|
911
|
+
input,
|
|
912
|
+
chips,
|
|
913
|
+
datalist: null,
|
|
914
|
+
inputSnapshot,
|
|
915
|
+
sourceSnapshot,
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* Resolve the interaction filter input, preferring an author-supplied one
|
|
921
|
+
* declared via `<input data-filter-for="select-id">`. Returns the input together with
|
|
922
|
+
* the snapshot needed by dispose() to restore the authored state.
|
|
923
|
+
* @returns {{ input: HTMLInputElement, inputSnapshot: AttributeSnapshot | null }}
|
|
924
|
+
*/
|
|
925
|
+
#resolveFilterInput() {
|
|
926
|
+
let input = null;
|
|
927
|
+
// An author-supplied filter input is declared with a liaison attribute on
|
|
928
|
+
// the input itself: <input data-filter-for="select-id">. Configuration stays
|
|
929
|
+
// on <combo-box> attributes or JS — the source select never carries data-*.
|
|
930
|
+
if (this.source.id) {
|
|
931
|
+
input = document.querySelector(`input[data-filter-for="${CSS.escape(this.source.id)}"]`);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
if (input instanceof HTMLInputElement) {
|
|
935
|
+
const inputSnapshot = captureAttributes(input, INPUT_ATTRS);
|
|
936
|
+
const filterInputPlaceholder = document.createComment(`combobox-filter-input-${this.id}`);
|
|
937
|
+
this.original.filterInputPlaceholder = filterInputPlaceholder;
|
|
938
|
+
input.before(filterInputPlaceholder);
|
|
939
|
+
input.hidden = false;
|
|
940
|
+
return { input, inputSnapshot };
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
this.ownsInput = true;
|
|
944
|
+
// An owned control is disposed with the wrapper: there is nothing to restore.
|
|
945
|
+
return { input: document.createElement("input"), inputSnapshot: null };
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* Copy the source select's accessible name/description/required state onto
|
|
950
|
+
* the interaction input. Must run before the input is attached to the
|
|
951
|
+
* control so aria-labelledby/aria-describedby reference live labels.
|
|
952
|
+
* @param {HTMLInputElement} input
|
|
953
|
+
*/
|
|
954
|
+
#copyAccessibleName(input) {
|
|
955
|
+
// The source's own aria-labelledby (e.g. a grid naming its filter select
|
|
956
|
+
// after the column header) is the authoritative accessible name — an
|
|
957
|
+
// association that no <label> traversal can rediscover. Only when it is
|
|
958
|
+
// absent do we fall back to derived labels, then aria-label.
|
|
959
|
+
const labelledBy = this.source.getAttribute("aria-labelledby");
|
|
960
|
+
if (labelledBy) {
|
|
961
|
+
input.setAttribute("aria-labelledby", labelledBy);
|
|
962
|
+
} else {
|
|
963
|
+
this.#copyLabeledNames(input);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
const ariaLabel = this.source.getAttribute("aria-label");
|
|
967
|
+
if (!input.hasAttribute("aria-labelledby") && ariaLabel) {
|
|
968
|
+
input.setAttribute("aria-label", ariaLabel);
|
|
969
|
+
}
|
|
970
|
+
if (this.source.required) input.setAttribute("aria-required", "true");
|
|
971
|
+
const describedBy = this.source.getAttribute("aria-describedby");
|
|
972
|
+
if (describedBy) {
|
|
973
|
+
input.setAttribute("aria-describedby", describedBy);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* Derive the accessible name from <label> associations: explicit
|
|
979
|
+
* `label[for=source]` links plus a wrapping label. Invented ids are recorded
|
|
980
|
+
* so dispose() can strip them again.
|
|
981
|
+
* @param {HTMLInputElement} input
|
|
982
|
+
*/
|
|
983
|
+
#copyLabeledNames(input) {
|
|
984
|
+
const labels = [];
|
|
985
|
+
if (this.source.id) {
|
|
986
|
+
labels.push(...document.querySelectorAll(`label[for="${CSS.escape(this.source.id)}"]`));
|
|
987
|
+
}
|
|
988
|
+
// A label wrapping the select (no `for`) still names it accessibly. Clicks
|
|
989
|
+
// are already redirected to the filter input by the focus forwarding on the
|
|
990
|
+
// hidden select; here we propagate the name itself.
|
|
991
|
+
const wrapped = this.source.closest("label");
|
|
992
|
+
if (wrapped) labels.push(wrapped);
|
|
993
|
+
|
|
994
|
+
const seen = new Set();
|
|
995
|
+
this.boundLabels = /** @type {HTMLLabelElement[]} */ (
|
|
996
|
+
labels.filter((label) => {
|
|
997
|
+
if (seen.has(label)) return false;
|
|
998
|
+
seen.add(label);
|
|
999
|
+
return true;
|
|
1000
|
+
})
|
|
1001
|
+
);
|
|
1002
|
+
|
|
1003
|
+
const labelIds = this.boundLabels.map((label, index) => {
|
|
1004
|
+
if (!label.id) {
|
|
1005
|
+
label.id = `combobox-label-${this.id}-${index}`;
|
|
1006
|
+
this.original.inventedLabels.push({ label, id: label.id });
|
|
1007
|
+
}
|
|
1008
|
+
return label.id;
|
|
1009
|
+
});
|
|
1010
|
+
if (labelIds.length) input.setAttribute("aria-labelledby", labelIds.join(" "));
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
#sourceItems() {
|
|
1014
|
+
if (this.isSelect) {
|
|
1015
|
+
return Array.from(this.#selectSource().options)
|
|
1016
|
+
.filter((option) => option.value || this.options.allowEmptyOption)
|
|
1017
|
+
.map((option) => ({
|
|
1018
|
+
value: option.value,
|
|
1019
|
+
label: option.textContent.trim(),
|
|
1020
|
+
disabled:
|
|
1021
|
+
option.disabled ||
|
|
1022
|
+
(option.parentElement instanceof HTMLOptGroupElement ? option.parentElement.disabled : false),
|
|
1023
|
+
selected: option.selected,
|
|
1024
|
+
group: option.parentElement instanceof HTMLOptGroupElement ? option.parentElement.label : "",
|
|
1025
|
+
option,
|
|
1026
|
+
data: { ...option.dataset },
|
|
1027
|
+
}));
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
if (!this.datalist) return [];
|
|
1031
|
+
return Array.from(this.datalist.options).map((option) => ({
|
|
1032
|
+
value: option.value,
|
|
1033
|
+
label: option.label || option.value,
|
|
1034
|
+
disabled: option.disabled,
|
|
1035
|
+
selected: this.source.value === option.value,
|
|
1036
|
+
group: option.dataset.group || "",
|
|
1037
|
+
option,
|
|
1038
|
+
data: { ...option.dataset },
|
|
1039
|
+
}));
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
#items() {
|
|
1043
|
+
if (!this.results) return this.#sourceItems();
|
|
1044
|
+
|
|
1045
|
+
if (!this.isSelect) return this.results;
|
|
1046
|
+
|
|
1047
|
+
return this.results.map((item) => {
|
|
1048
|
+
// Option identity is the element, never the value string, so transient
|
|
1049
|
+
// items resolve their selected state through their exact option. A plain
|
|
1050
|
+
// value falls back to the first matching source option.
|
|
1051
|
+
const option = item.option || this.#findOption(item.value);
|
|
1052
|
+
return { ...item, selected: option?.selected ?? false, option };
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/** Map data objects to canonical items when label/value fields are set. */
|
|
1057
|
+
#fields() {
|
|
1058
|
+
const { labelField, valueField } = this.options;
|
|
1059
|
+
return labelField || valueField ? { labelField, valueField } : null;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
// maxOptions is a rendering cap only: the result store (filteredItems) may
|
|
1063
|
+
// be large, but at most maxOptions options are ever rendered/navigated.
|
|
1064
|
+
get visibleItems() {
|
|
1065
|
+
return this.options.maxOptions > 0
|
|
1066
|
+
? this.filteredItems.slice(0, this.options.maxOptions)
|
|
1067
|
+
: this.filteredItems;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/** Set transient picker results without turning the select into a remote cache. */
|
|
1071
|
+
/**
|
|
1072
|
+
* @param {Array<any>} items
|
|
1073
|
+
* @returns {this}
|
|
1074
|
+
*/
|
|
1075
|
+
setResults(items) {
|
|
1076
|
+
this.results = Array.from(items || [], (item) => toItem(item, this.#fields())).filter(
|
|
1077
|
+
(item) => item !== null,
|
|
1078
|
+
);
|
|
1079
|
+
return this;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
clearResults() {
|
|
1083
|
+
this.results = null;
|
|
1084
|
+
// A failed load is scoped to the newest search: any later local query,
|
|
1085
|
+
// successful load or explicit clear drops the stale error row.
|
|
1086
|
+
this.loadError = null;
|
|
1087
|
+
return this;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/**
|
|
1091
|
+
* @param {*} value
|
|
1092
|
+
* @returns {HTMLOptionElement | null}
|
|
1093
|
+
*/
|
|
1094
|
+
#findOption(value) {
|
|
1095
|
+
if (!this.isSelect) return null;
|
|
1096
|
+
const select = this.#selectSource();
|
|
1097
|
+
return Array.from(select.options).find((option) => option.value === String(value)) || null;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
/**
|
|
1101
|
+
* Resolve a plain value to the option a fresh selection should land on: the
|
|
1102
|
+
* first non-disabled match, skipping already-selected options in multiple
|
|
1103
|
+
* mode (each native option is selected at most once; identical values on
|
|
1104
|
+
* distinct options are distinct choices). Single-select returns the first
|
|
1105
|
+
* non-disabled match regardless of the current selection.
|
|
1106
|
+
* @param {*} value
|
|
1107
|
+
* @returns {HTMLOptionElement | null}
|
|
1108
|
+
*/
|
|
1109
|
+
#findSelectableOption(value) {
|
|
1110
|
+
if (!this.isSelect) return null;
|
|
1111
|
+
const wanted = String(value);
|
|
1112
|
+
return (
|
|
1113
|
+
Array.from(this.#selectSource().options).find(
|
|
1114
|
+
(option) =>
|
|
1115
|
+
option.value === wanted && !option.disabled && (this.isMultiple && option.selected) === false,
|
|
1116
|
+
) || null
|
|
1117
|
+
);
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/** Match a token to an existing native option by value or label. */
|
|
1121
|
+
/**
|
|
1122
|
+
* @param {string} label
|
|
1123
|
+
* @returns {import("./helpers.js").ComboboxItem | null}
|
|
1124
|
+
*/
|
|
1125
|
+
#findCreateMatch(label) {
|
|
1126
|
+
const lookup = normalize(label);
|
|
1127
|
+
for (const item of this.#sourceItems()) {
|
|
1128
|
+
if (normalize(item.value) === lookup || normalize(item.label) === lookup) return item;
|
|
1129
|
+
}
|
|
1130
|
+
return null;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
/** Replace the native catalogue explicitly. Prefer setResults() for remote search. */
|
|
1134
|
+
/**
|
|
1135
|
+
* @param {Array<any>} items
|
|
1136
|
+
* @param {{ preserveSelected?: boolean }} [options]
|
|
1137
|
+
* @returns {this}
|
|
1138
|
+
*/
|
|
1139
|
+
setOptions(items, { preserveSelected = this.isSelect } = {}) {
|
|
1140
|
+
const normalized = Array.from(items || [], (item) => toItem(item, this.#fields())).filter(
|
|
1141
|
+
(item) => item !== null,
|
|
1142
|
+
);
|
|
1143
|
+
|
|
1144
|
+
if (this.isSelect) {
|
|
1145
|
+
const select = this.#selectSource();
|
|
1146
|
+
/** @type {import("./helpers.js").ComboboxItem[]} */
|
|
1147
|
+
const preserved = preserveSelected
|
|
1148
|
+
? Array.from(select.selectedOptions).map((option) => ({
|
|
1149
|
+
value: option.value,
|
|
1150
|
+
label: option.textContent.trim(),
|
|
1151
|
+
selected: true,
|
|
1152
|
+
disabled: option.disabled,
|
|
1153
|
+
group: option.parentElement instanceof HTMLOptGroupElement ? option.parentElement.label : "",
|
|
1154
|
+
}))
|
|
1155
|
+
: [];
|
|
1156
|
+
|
|
1157
|
+
const emptyOption = Array.from(select.options).find((option) => !option.value);
|
|
1158
|
+
select.replaceChildren();
|
|
1159
|
+
if (emptyOption && !this.isMultiple) select.append(emptyOption);
|
|
1160
|
+
|
|
1161
|
+
// No value-based dedupe: catalogue identity is the <option> element, so
|
|
1162
|
+
// repeated values in the payload map to their own options.
|
|
1163
|
+
const catalog = [...preserved, ...normalized];
|
|
1164
|
+
|
|
1165
|
+
const groups = new Map();
|
|
1166
|
+
for (const item of catalog) {
|
|
1167
|
+
// An empty value is a legitimate option only when allowEmptyOption
|
|
1168
|
+
// admits it; otherwise it would shadow the collection's real entries.
|
|
1169
|
+
if (!item.value && !this.options.allowEmptyOption) continue;
|
|
1170
|
+
const option = new Option(item.label, item.value, Boolean(item.selected), Boolean(item.selected));
|
|
1171
|
+
option.disabled = Boolean(item.disabled);
|
|
1172
|
+
if (item.data) Object.assign(option.dataset, item.data);
|
|
1173
|
+
|
|
1174
|
+
if (item.group) {
|
|
1175
|
+
let group = groups.get(item.group);
|
|
1176
|
+
if (!group) {
|
|
1177
|
+
group = document.createElement("optgroup");
|
|
1178
|
+
group.label = item.group;
|
|
1179
|
+
groups.set(item.group, group);
|
|
1180
|
+
select.append(group);
|
|
1181
|
+
}
|
|
1182
|
+
group.append(option);
|
|
1183
|
+
} else {
|
|
1184
|
+
select.append(option);
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
} else {
|
|
1188
|
+
if (!this.datalist) return this;
|
|
1189
|
+
this.datalist.replaceChildren();
|
|
1190
|
+
for (const item of normalized) {
|
|
1191
|
+
const option = document.createElement("option");
|
|
1192
|
+
option.value = item.value;
|
|
1193
|
+
if (item.label !== item.value) option.label = item.label;
|
|
1194
|
+
if (item.data) Object.assign(option.dataset, item.data);
|
|
1195
|
+
this.datalist.append(option);
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
this.clearResults();
|
|
1200
|
+
if (this.mode === "enhanced") this.refresh();
|
|
1201
|
+
this.#markEngineMutation();
|
|
1202
|
+
return this;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
/** Explicit sync point for external DOM mutations. */
|
|
1206
|
+
sync() {
|
|
1207
|
+
// External source mutations invalidate transient results unless the caller
|
|
1208
|
+
// explicitly sets them again. This keeps catalogue and result-store roles clear.
|
|
1209
|
+
this.clearResults();
|
|
1210
|
+
this.refresh();
|
|
1211
|
+
return this;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
/**
|
|
1215
|
+
* Engine-driven native mutations are never observed. The engine drops the
|
|
1216
|
+
* observer (discarding queued records and any pending debounced sync) right
|
|
1217
|
+
* before it re-renders from the native source, and reconnects on the next
|
|
1218
|
+
* microtask. A refresh after an engine mutation re-reads the source anyway,
|
|
1219
|
+
* so any external change that landed in the same window is still reflected.
|
|
1220
|
+
*/
|
|
1221
|
+
#markEngineMutation() {
|
|
1222
|
+
if (this._sourceSyncTimer) {
|
|
1223
|
+
clearTimeout(this._sourceSyncTimer);
|
|
1224
|
+
this._sourceSyncTimer = null;
|
|
1225
|
+
}
|
|
1226
|
+
this._sourceObserver?.disconnect();
|
|
1227
|
+
this._sourceObserver = null;
|
|
1228
|
+
queueMicrotask(() => {
|
|
1229
|
+
if (instances.get(this.source) === this && this.options.observeSource && this.mode === "enhanced") {
|
|
1230
|
+
this.#watchSource();
|
|
1231
|
+
}
|
|
1232
|
+
});
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
/**
|
|
1236
|
+
* Opt-in automatic source sync. `observeSource` watches the native catalogue
|
|
1237
|
+
* (select's <option>/<optgroup> structure and selected/disabled/required/
|
|
1238
|
+
* readonly state; the detached datalist's <option> set for inputs) and calls
|
|
1239
|
+
* `sync()` once per debounced batch. `multiple` is deliberately not observed:
|
|
1240
|
+
* the value model is fixed at init time.
|
|
1241
|
+
*/
|
|
1242
|
+
#watchSource() {
|
|
1243
|
+
if (!this.options.observeSource || this._sourceObserver) return;
|
|
1244
|
+
|
|
1245
|
+
const config = this.isSelect
|
|
1246
|
+
? {
|
|
1247
|
+
childList: true,
|
|
1248
|
+
subtree: true,
|
|
1249
|
+
attributes: true,
|
|
1250
|
+
attributeFilter: ["selected", "disabled", "required", "readonly"],
|
|
1251
|
+
}
|
|
1252
|
+
: {
|
|
1253
|
+
childList: true,
|
|
1254
|
+
attributes: true,
|
|
1255
|
+
attributeFilter: ["value", "disabled", "label"],
|
|
1256
|
+
};
|
|
1257
|
+
|
|
1258
|
+
this._sourceObserver = new MutationObserver(() => this.#scheduleSourceSync());
|
|
1259
|
+
// For input+datalist the datalist is detached in enhanced mode; a
|
|
1260
|
+
// MutationObserver can observe a detached node just fine.
|
|
1261
|
+
const target = this.isSelect ? this.source : this.datalist;
|
|
1262
|
+
if (target) this._sourceObserver.observe(target, config);
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
#scheduleSourceSync() {
|
|
1266
|
+
if (this._sourceSyncTimer) clearTimeout(this._sourceSyncTimer);
|
|
1267
|
+
this._sourceSyncTimer = setTimeout(() => {
|
|
1268
|
+
this._sourceSyncTimer = null;
|
|
1269
|
+
if (instances.get(this.source) !== this) return;
|
|
1270
|
+
this.sync();
|
|
1271
|
+
}, 50);
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
/* ---------------------------------------------------------------------- */
|
|
1275
|
+
/* Picker / interaction */
|
|
1276
|
+
/* ---------------------------------------------------------------------- */
|
|
1277
|
+
|
|
1278
|
+
/**
|
|
1279
|
+
* Build the popover picker (top layer) plus its listbox and live status
|
|
1280
|
+
* region. The popover is appended to the nearest ancestor dialog (or body)
|
|
1281
|
+
* so a modal <dialog> does not make it inert.
|
|
1282
|
+
* @returns {{ popover: HTMLElement, listbox: HTMLElement, status: HTMLElement }}
|
|
1283
|
+
*/
|
|
1284
|
+
#createPicker() {
|
|
1285
|
+
const popover = document.createElement("div");
|
|
1286
|
+
popover.className = "cb-popover";
|
|
1287
|
+
popover.popover = "manual";
|
|
1288
|
+
popover.style.setProperty("position-anchor", this.anchorName);
|
|
1289
|
+
|
|
1290
|
+
const listbox = document.createElement("div");
|
|
1291
|
+
listbox.className = "cb-listbox";
|
|
1292
|
+
listbox.role = "listbox";
|
|
1293
|
+
listbox.id = `combobox-listbox-${this.id}`;
|
|
1294
|
+
if (this.isMultiple) listbox.setAttribute("aria-multiselectable", "true");
|
|
1295
|
+
|
|
1296
|
+
const status = document.createElement("div");
|
|
1297
|
+
status.className = "cb-status";
|
|
1298
|
+
status.setAttribute("role", "status");
|
|
1299
|
+
status.setAttribute("aria-live", "polite");
|
|
1300
|
+
|
|
1301
|
+
popover.append(listbox, status);
|
|
1302
|
+
// Popover renders in the top layer, but a modal <dialog> makes everything
|
|
1303
|
+
// outside it (including a body-level popover) inert. Stay a descendant of
|
|
1304
|
+
// an ancestor dialog so the picker stays interactive inside showModal().
|
|
1305
|
+
const dialog = this.source.closest("dialog");
|
|
1306
|
+
(dialog || document.body).append(popover);
|
|
1307
|
+
|
|
1308
|
+
// The popover renders in the top layer, outside the control's subtree, so
|
|
1309
|
+
// it cannot inherit the control's typography. Adopt the interaction
|
|
1310
|
+
// input's resolved font to avoid falling back to the page-level font.
|
|
1311
|
+
popover.style.font = getComputedStyle(this.#inputEl()).font;
|
|
1312
|
+
|
|
1313
|
+
this.#inputEl().setAttribute("role", "combobox");
|
|
1314
|
+
this.#inputEl().setAttribute("aria-autocomplete", "list");
|
|
1315
|
+
this.#inputEl().setAttribute("aria-expanded", "false");
|
|
1316
|
+
this.#inputEl().setAttribute("aria-controls", listbox.id);
|
|
1317
|
+
|
|
1318
|
+
return { popover, listbox, status };
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
#bind() {
|
|
1322
|
+
const signal = this.abortController.signal;
|
|
1323
|
+
|
|
1324
|
+
// Persistent listeners go through #handleEvent so no per-render closure is
|
|
1325
|
+
// ever added. #renderList/#renderChips stay listener-free.
|
|
1326
|
+
this.#inputEl().addEventListener("focus", this, { signal });
|
|
1327
|
+
this.#inputEl().addEventListener("input", this, { signal });
|
|
1328
|
+
this.#inputEl().addEventListener("compositionstart", this, { signal });
|
|
1329
|
+
this.#inputEl().addEventListener("compositionend", this, { signal });
|
|
1330
|
+
this.#inputEl().addEventListener("keydown", this, { signal });
|
|
1331
|
+
this.#inputEl().addEventListener("blur", this, { signal });
|
|
1332
|
+
|
|
1333
|
+
this.#listEl().addEventListener("pointerdown", (event) => event.preventDefault(), { signal });
|
|
1334
|
+
this.#listEl().addEventListener("pointermove", this, { signal });
|
|
1335
|
+
this.#listEl().addEventListener("click", this, { signal });
|
|
1336
|
+
|
|
1337
|
+
if (this.chips) {
|
|
1338
|
+
this.chips.addEventListener("keydown", this, { signal });
|
|
1339
|
+
this.chips.addEventListener("click", this, { signal });
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
this.control?.addEventListener("click", this, { signal });
|
|
1343
|
+
|
|
1344
|
+
document.addEventListener(
|
|
1345
|
+
"pointerdown",
|
|
1346
|
+
(event) => {
|
|
1347
|
+
if (!this.isOpen()) return;
|
|
1348
|
+
const path = event.composedPath();
|
|
1349
|
+
const control = this.anchor || this.control || this.#inputEl();
|
|
1350
|
+
if (path.includes(control) || path.includes(this.#popoverEl())) return;
|
|
1351
|
+
this.hide();
|
|
1352
|
+
},
|
|
1353
|
+
{ capture: true, signal },
|
|
1354
|
+
);
|
|
1355
|
+
|
|
1356
|
+
this.#popoverEl().addEventListener(
|
|
1357
|
+
"toggle",
|
|
1358
|
+
(event) => {
|
|
1359
|
+
const open = event.newState === "open";
|
|
1360
|
+
this.#inputEl().setAttribute("aria-expanded", String(open));
|
|
1361
|
+
emit(this.source, open ? "combobox:open" : "combobox:close", { combobox: this });
|
|
1362
|
+
if (!open) {
|
|
1363
|
+
this.#setActive(-1);
|
|
1364
|
+
if (this.isSelect && !this.isMultiple) this.#syncSingleLabel();
|
|
1365
|
+
}
|
|
1366
|
+
},
|
|
1367
|
+
{ signal },
|
|
1368
|
+
);
|
|
1369
|
+
|
|
1370
|
+
if (this.isSelect) {
|
|
1371
|
+
this.source.addEventListener("change", () => this.refresh(), { signal });
|
|
1372
|
+
this.source.addEventListener("focus", () => this.#inputEl().focus(), { signal });
|
|
1373
|
+
for (const label of this.boundLabels) {
|
|
1374
|
+
label.addEventListener(
|
|
1375
|
+
"click",
|
|
1376
|
+
(event) => {
|
|
1377
|
+
event.preventDefault();
|
|
1378
|
+
this.#inputEl().focus();
|
|
1379
|
+
},
|
|
1380
|
+
{ signal },
|
|
1381
|
+
);
|
|
1382
|
+
}
|
|
1383
|
+
this.source.form?.addEventListener("reset", () => queueMicrotask(() => this.refresh()), { signal });
|
|
1384
|
+
if (
|
|
1385
|
+
this.isMultiple &&
|
|
1386
|
+
this.options.selectionOrder === "selected" &&
|
|
1387
|
+
this.source.name &&
|
|
1388
|
+
this.source.form
|
|
1389
|
+
) {
|
|
1390
|
+
this.source.form.addEventListener(
|
|
1391
|
+
"formdata",
|
|
1392
|
+
(event) => {
|
|
1393
|
+
event.formData.delete(this.source.name);
|
|
1394
|
+
for (const value of this.getSelectedValues()) event.formData.append(this.source.name, value);
|
|
1395
|
+
},
|
|
1396
|
+
{ signal },
|
|
1397
|
+
);
|
|
1398
|
+
}
|
|
1399
|
+
this.source.addEventListener(
|
|
1400
|
+
"invalid",
|
|
1401
|
+
(event) => {
|
|
1402
|
+
event.preventDefault();
|
|
1403
|
+
this.#inputEl().setAttribute("aria-invalid", "true");
|
|
1404
|
+
this.#inputEl().focus();
|
|
1405
|
+
},
|
|
1406
|
+
{ signal },
|
|
1407
|
+
);
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
/** Single entry point for all listeners bound with `this` as the handler. */
|
|
1412
|
+
/**
|
|
1413
|
+
* @param {Event} event
|
|
1414
|
+
*/
|
|
1415
|
+
handleEvent(event) {
|
|
1416
|
+
if (event.currentTarget === this.#inputEl()) return this.#onInputEvent(event);
|
|
1417
|
+
if (event.currentTarget === this.control) return this.#onControlEvent(event);
|
|
1418
|
+
if (event.currentTarget === this.#listEl()) return this.#onListboxEvent(event);
|
|
1419
|
+
if (event.currentTarget === this.chips) return this.#onChipsEvent(event);
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
/**
|
|
1423
|
+
* @param {Event} event
|
|
1424
|
+
*/
|
|
1425
|
+
#onInputEvent(event) {
|
|
1426
|
+
switch (event.type) {
|
|
1427
|
+
case "focus": {
|
|
1428
|
+
if (this.isSelect && !this.isMultiple && this.#selectSource().selectedOptions.length)
|
|
1429
|
+
this.#inputEl().select();
|
|
1430
|
+
const query = this.isSelect && !this.isMultiple ? "" : this.#inputEl().value;
|
|
1431
|
+
this.search(query, { show: true, reason: "focus" });
|
|
1432
|
+
return;
|
|
1433
|
+
}
|
|
1434
|
+
case "input": {
|
|
1435
|
+
// Separator tokens are consumed as they complete (typing or paste).
|
|
1436
|
+
// IME composition feeds search but never tokenizes/creates.
|
|
1437
|
+
const inputEvent = /** @type {InputEvent} */ (event);
|
|
1438
|
+
if (this.isMultiple && !inputEvent.isComposing && this.#separatorsActive()) {
|
|
1439
|
+
void this.#handleTokenInput();
|
|
1440
|
+
return;
|
|
1441
|
+
}
|
|
1442
|
+
this.search(this.#inputEl().value, { show: true, reason: "input" });
|
|
1443
|
+
return;
|
|
1444
|
+
}
|
|
1445
|
+
case "compositionstart":
|
|
1446
|
+
this.composing = true;
|
|
1447
|
+
return;
|
|
1448
|
+
case "compositionend":
|
|
1449
|
+
this.composing = false;
|
|
1450
|
+
return;
|
|
1451
|
+
case "keydown":
|
|
1452
|
+
return this.#onInputKeyDown(/** @type {KeyboardEvent} */ (event));
|
|
1453
|
+
case "blur":
|
|
1454
|
+
return this.#onInputBlur();
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
/**
|
|
1459
|
+
* @param {Event} event
|
|
1460
|
+
*/
|
|
1461
|
+
#onControlEvent(event) {
|
|
1462
|
+
const target = /** @type {HTMLElement} */ (event.target);
|
|
1463
|
+
if (target.closest("button")) return;
|
|
1464
|
+
this.#inputEl().focus();
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
/**
|
|
1468
|
+
* @param {Event} event
|
|
1469
|
+
*/
|
|
1470
|
+
#onListboxEvent(event) {
|
|
1471
|
+
const target = /** @type {HTMLElement} */ (event.target);
|
|
1472
|
+
if (event.type === "pointermove") {
|
|
1473
|
+
const option = target.closest(".cb-option[data-index]");
|
|
1474
|
+
if (option) this.#setActive(Number(/** @type {HTMLElement} */ (option).dataset.index));
|
|
1475
|
+
return;
|
|
1476
|
+
}
|
|
1477
|
+
if (event.type === "click") {
|
|
1478
|
+
const option = target.closest(".cb-option");
|
|
1479
|
+
if (!option) return;
|
|
1480
|
+
if (option.classList.contains("cb-create")) {
|
|
1481
|
+
const query = this.#inputEl().value.trim();
|
|
1482
|
+
if (query) void this.#createItem(query);
|
|
1483
|
+
return;
|
|
1484
|
+
}
|
|
1485
|
+
const item = this.visibleItems[Number(/** @type {HTMLElement} */ (option).dataset.index)];
|
|
1486
|
+
if (item) this.#selectItem(item);
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
/**
|
|
1491
|
+
* @param {Event} event
|
|
1492
|
+
*/
|
|
1493
|
+
#onChipsEvent(event) {
|
|
1494
|
+
const target = /** @type {HTMLElement} */ (event.target);
|
|
1495
|
+
if (event.type === "click") {
|
|
1496
|
+
const remove = target.closest(".cb-chip-remove");
|
|
1497
|
+
if (!remove) return;
|
|
1498
|
+
const chip = /** @type {HTMLElement} */ (remove.closest(".cb-chip"));
|
|
1499
|
+
if (!chip) return;
|
|
1500
|
+
// The chip's exact option is authoritative (duplicate values share a
|
|
1501
|
+
// data-value but never an option). data-value is only a fallback.
|
|
1502
|
+
const option = this._chipOptions.get(chip);
|
|
1503
|
+
void this.remove(option ?? chip.dataset.value ?? "").then((removed) => {
|
|
1504
|
+
if (removed) this.#inputEl().focus();
|
|
1505
|
+
});
|
|
1506
|
+
return;
|
|
1507
|
+
}
|
|
1508
|
+
if (event.type === "keydown") {
|
|
1509
|
+
const chip = /** @type {HTMLElement} */ (target.closest(".cb-chip"));
|
|
1510
|
+
if (!chip) return;
|
|
1511
|
+
const option = this._chipOptions.get(chip);
|
|
1512
|
+
const item = option
|
|
1513
|
+
? this.getSelectedItems().find((entry) => entry.option === option) || {
|
|
1514
|
+
value: option.value,
|
|
1515
|
+
label: option.textContent.trim(),
|
|
1516
|
+
option,
|
|
1517
|
+
}
|
|
1518
|
+
: { value: chip.dataset.value ?? "", label: chip.dataset.value ?? "" };
|
|
1519
|
+
this.#onChipKeyDown(/** @type {KeyboardEvent} */ (event), item);
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
#onInputBlur() {
|
|
1524
|
+
queueMicrotask(async () => {
|
|
1525
|
+
const active = document.activeElement;
|
|
1526
|
+
// Blur caused by internal interaction (picker click, adornment,
|
|
1527
|
+
// chip removal, clear) never closes and never blur-creates.
|
|
1528
|
+
const stillInside =
|
|
1529
|
+
active === this.#inputEl() ||
|
|
1530
|
+
(this.#popoverEl()?.contains(active) ?? false) ||
|
|
1531
|
+
(this.anchor && active && this.anchor.contains(active)) ||
|
|
1532
|
+
(this.control && active && this.control.contains(active));
|
|
1533
|
+
if (this.isOpen() && stillInside) return;
|
|
1534
|
+
|
|
1535
|
+
if (this.isOpen() || this.options.createOnBlur) {
|
|
1536
|
+
if (this.isSelect && this.isMultiple && this.options.createOnBlur && !this.composing) {
|
|
1537
|
+
const value = this.#inputEl().value;
|
|
1538
|
+
this.suppressReopen = true;
|
|
1539
|
+
try {
|
|
1540
|
+
if (this.#separatorsActive()) {
|
|
1541
|
+
const result = await this.#processTokens(value, { final: true });
|
|
1542
|
+
if (result?.consumed) this.#inputEl().value = result.rest;
|
|
1543
|
+
} else if (value.trim()) {
|
|
1544
|
+
this.#inputEl().value = "";
|
|
1545
|
+
await this.#createItem(value.trim());
|
|
1546
|
+
}
|
|
1547
|
+
} finally {
|
|
1548
|
+
this.suppressReopen = false;
|
|
1549
|
+
}
|
|
1550
|
+
this.refresh();
|
|
1551
|
+
}
|
|
1552
|
+
if (this.isSelect && !this.isMultiple) this.#syncSingleLabel();
|
|
1553
|
+
this.hide();
|
|
1554
|
+
}
|
|
1555
|
+
});
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
/**
|
|
1559
|
+
* @param {KeyboardEvent} event
|
|
1560
|
+
*/
|
|
1561
|
+
#onInputKeyDown(event) {
|
|
1562
|
+
if (event.key === "ArrowDown") {
|
|
1563
|
+
event.preventDefault();
|
|
1564
|
+
if (!this.isOpen()) this.search(this.#inputEl().value, { show: true, reason: "keyboard" });
|
|
1565
|
+
this.#moveActive(1);
|
|
1566
|
+
return;
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
if (event.key === "ArrowUp") {
|
|
1570
|
+
event.preventDefault();
|
|
1571
|
+
if (!this.isOpen()) this.search(this.#inputEl().value, { show: true, reason: "keyboard" });
|
|
1572
|
+
this.#moveActive(-1);
|
|
1573
|
+
return;
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
if (event.key === "PageUp" || event.key === "PageDown") {
|
|
1577
|
+
event.preventDefault();
|
|
1578
|
+
if (!this.isOpen()) this.search(this.#inputEl().value, { show: true, reason: "keyboard" });
|
|
1579
|
+
const down = event.key === "PageDown";
|
|
1580
|
+
const base = this.activeIndex < 0 ? (down ? -1 : 0) : this.activeIndex;
|
|
1581
|
+
const distance = base + (down ? this.#pageSize() : -this.#pageSize());
|
|
1582
|
+
this.#setActive(this.#nearestSelectable(distance, down ? 1 : -1));
|
|
1583
|
+
return;
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
if (event.key === "Enter" && this.isOpen()) {
|
|
1587
|
+
// IME composition owns the key: returning first never steals Enter from a
|
|
1588
|
+
// composing input, and never preventDefault()s it.
|
|
1589
|
+
if (event.isComposing || this.composing) return;
|
|
1590
|
+
event.preventDefault();
|
|
1591
|
+
if (this.isMultiple && this.#separatorsActive()) {
|
|
1592
|
+
void this.#commitEnterTokens();
|
|
1593
|
+
return;
|
|
1594
|
+
}
|
|
1595
|
+
const active = this.visibleItems[this.activeIndex];
|
|
1596
|
+
if (active) this.#selectItem(active);
|
|
1597
|
+
else if (this.#canCreate(this.#inputEl().value)) void this.#createItem(this.#inputEl().value.trim());
|
|
1598
|
+
return;
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
// tabSelect deals with an open picker, not with the open state itself.
|
|
1602
|
+
// preventDefault() only fires when a commit is actually possible; otherwise
|
|
1603
|
+
// Tab keeps its native focus-traversal behavior. IME composition is never a
|
|
1604
|
+
// commit (this.composing mirrors the blur handler) and always falls through
|
|
1605
|
+
// to native Tab. An open top-layer popover traps sequential focus in some
|
|
1606
|
+
// engines (Firefox keeps focus inside an open manual popover), so whenever
|
|
1607
|
+
// Tab does not commit it still closes the picker before letting traversal
|
|
1608
|
+
// proceed — without ever preventDefault()ing.
|
|
1609
|
+
if (event.key === "Tab" && this.isOpen()) {
|
|
1610
|
+
if (this.options.tabSelect) {
|
|
1611
|
+
if (event.isComposing || this.composing) return;
|
|
1612
|
+
if (this.isMultiple && this.#separatorsActive() && this.#inputEl().value.trim()) {
|
|
1613
|
+
event.preventDefault();
|
|
1614
|
+
void this.#commitEnterTokens();
|
|
1615
|
+
return;
|
|
1616
|
+
}
|
|
1617
|
+
const active = this.visibleItems[this.activeIndex];
|
|
1618
|
+
if (active) {
|
|
1619
|
+
event.preventDefault();
|
|
1620
|
+
this.#selectItem(active);
|
|
1621
|
+
return;
|
|
1622
|
+
}
|
|
1623
|
+
if (this.#canCreate(this.#inputEl().value)) {
|
|
1624
|
+
event.preventDefault();
|
|
1625
|
+
void this.#createItem(this.#inputEl().value.trim());
|
|
1626
|
+
return;
|
|
1627
|
+
}
|
|
1628
|
+
this.hide();
|
|
1629
|
+
return;
|
|
1630
|
+
}
|
|
1631
|
+
this.hide();
|
|
1632
|
+
return;
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
if (event.key === "Escape" && this.isOpen()) {
|
|
1636
|
+
event.preventDefault();
|
|
1637
|
+
this.hide();
|
|
1638
|
+
return;
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
if (event.key === "ArrowLeft" && this.isMultiple && !this.#inputEl().value) {
|
|
1642
|
+
const chips = Array.from(this.chips?.querySelectorAll(".cb-chip") || []);
|
|
1643
|
+
if (chips.length) {
|
|
1644
|
+
event.preventDefault();
|
|
1645
|
+
/** @type {HTMLElement} */ (chips[chips.length - 1]).focus();
|
|
1646
|
+
return;
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
if (
|
|
1651
|
+
event.key === "Backspace" &&
|
|
1652
|
+
this.isMultiple &&
|
|
1653
|
+
!this.#inputEl().value &&
|
|
1654
|
+
this.#selectSource().selectedOptions.length
|
|
1655
|
+
) {
|
|
1656
|
+
const selected = this.#selectedOptionsInOrder();
|
|
1657
|
+
const last = selected[selected.length - 1];
|
|
1658
|
+
if (last && !last.disabled) void this.remove(last);
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
/* ---------------------------------------------------------------------- */
|
|
1663
|
+
/* Filtering / loading */
|
|
1664
|
+
/* ---------------------------------------------------------------------- */
|
|
1665
|
+
|
|
1666
|
+
/**
|
|
1667
|
+
* Run the normal filtering pipeline: beforefilter -> optional load -> filter.
|
|
1668
|
+
* @param {string} [query]
|
|
1669
|
+
* @param {{ show?: boolean, reason?: string }} [options]
|
|
1670
|
+
*/
|
|
1671
|
+
async search(query = "", { show = false, reason = "api" } = {}) {
|
|
1672
|
+
if (this.mode !== "enhanced") return;
|
|
1673
|
+
|
|
1674
|
+
const generation = ++this.searchGeneration;
|
|
1675
|
+
this.query = String(query ?? "");
|
|
1676
|
+
|
|
1677
|
+
const before = emit(
|
|
1678
|
+
this.#inputEl(),
|
|
1679
|
+
"beforefilter",
|
|
1680
|
+
{
|
|
1681
|
+
query: this.query,
|
|
1682
|
+
combobox: this,
|
|
1683
|
+
source: this.source,
|
|
1684
|
+
reason,
|
|
1685
|
+
},
|
|
1686
|
+
{ cancelable: true },
|
|
1687
|
+
);
|
|
1688
|
+
|
|
1689
|
+
// Open UI semantics: cancel beforefilter and script fully owns filtering.
|
|
1690
|
+
if (before.defaultPrevented) return;
|
|
1691
|
+
|
|
1692
|
+
if (this.#shouldLoad(this.query)) {
|
|
1693
|
+
await this.#load(this.query, { debounce: reason === "input" });
|
|
1694
|
+
if (generation !== this.searchGeneration) return;
|
|
1695
|
+
} else {
|
|
1696
|
+
// A local query should not keep stale remote results around.
|
|
1697
|
+
if (typeof this.options.load === "function") this.clearResults();
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
this.#applyFilter(this.query);
|
|
1701
|
+
emit(this.#inputEl(), "filter", {
|
|
1702
|
+
query: this.query,
|
|
1703
|
+
combobox: this,
|
|
1704
|
+
items: this.filteredItems,
|
|
1705
|
+
source: this.source,
|
|
1706
|
+
});
|
|
1707
|
+
|
|
1708
|
+
if (show) this.show();
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
/**
|
|
1712
|
+
* Update the visible interaction text and run the normal search pipeline.
|
|
1713
|
+
* Unlike search(), this keeps the DOM input and `query` in sync. Programmatic
|
|
1714
|
+
* assignment follows the platform and does not dispatch native input/change
|
|
1715
|
+
* events.
|
|
1716
|
+
* @param {*} value
|
|
1717
|
+
* @param {{ show?: boolean, reason?: string }} [options]
|
|
1718
|
+
* @returns {Promise<void>}
|
|
1719
|
+
*/
|
|
1720
|
+
setQuery(value, { show = true, reason = "api" } = {}) {
|
|
1721
|
+
const query = String(value ?? "");
|
|
1722
|
+
if (this.mode === "fallback") {
|
|
1723
|
+
this.query = query;
|
|
1724
|
+
if (!this.isSelect) this.source.value = query;
|
|
1725
|
+
return Promise.resolve();
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
this.#inputEl().value = query;
|
|
1729
|
+
return this.search(query, { show, reason });
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
/**
|
|
1733
|
+
* Clear the visible interaction text and run the normal search pipeline.
|
|
1734
|
+
* The picker is not opened when it was closed unless `{ show: true }` is
|
|
1735
|
+
* requested explicitly.
|
|
1736
|
+
* @param {{ show?: boolean, reason?: string }} [options]
|
|
1737
|
+
* @returns {Promise<void>}
|
|
1738
|
+
*/
|
|
1739
|
+
clearQuery({ show = false, reason = "api" } = {}) {
|
|
1740
|
+
return this.setQuery("", { show, reason });
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
/**
|
|
1744
|
+
* Apply the local filter directly, without re-firing beforefilter or load.
|
|
1745
|
+
* This is the escape hatch intended for a canceled beforefilter handler:
|
|
1746
|
+
* event.preventDefault();
|
|
1747
|
+
* combobox.setResults(results).applyFilter(event.query, { show: true });
|
|
1748
|
+
* @param {string} [query]
|
|
1749
|
+
* @param {{ show?: boolean }} [options]
|
|
1750
|
+
* @returns {this | undefined}
|
|
1751
|
+
*/
|
|
1752
|
+
applyFilter(query = "", { show = false } = {}) {
|
|
1753
|
+
if (this.mode !== "enhanced") return this;
|
|
1754
|
+
this.query = String(query ?? "");
|
|
1755
|
+
this.#applyFilter(this.query);
|
|
1756
|
+
emit(this.#inputEl(), "filter", {
|
|
1757
|
+
query: this.query,
|
|
1758
|
+
combobox: this,
|
|
1759
|
+
items: this.filteredItems,
|
|
1760
|
+
source: this.source,
|
|
1761
|
+
manual: true,
|
|
1762
|
+
});
|
|
1763
|
+
if (show) this.show();
|
|
1764
|
+
return this;
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
/**
|
|
1768
|
+
* @param {string} query
|
|
1769
|
+
* @returns {boolean}
|
|
1770
|
+
*/
|
|
1771
|
+
#shouldLoad(query) {
|
|
1772
|
+
if (
|
|
1773
|
+
typeof this.options.shouldLoad === "function" &&
|
|
1774
|
+
!this.options.shouldLoad(query, { combobox: this, source: this.source, input: this.#inputEl() })
|
|
1775
|
+
) {
|
|
1776
|
+
return false;
|
|
1777
|
+
}
|
|
1778
|
+
return (
|
|
1779
|
+
typeof this.options.load === "function" &&
|
|
1780
|
+
query.length >= Number(this.options.minChars || 0) &&
|
|
1781
|
+
(query.length > 0 || this.options.loadOnEmpty)
|
|
1782
|
+
);
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
/**
|
|
1786
|
+
* @param {string} query
|
|
1787
|
+
* @param {{ cursor?: string | null, append?: boolean, debounce?: boolean }} [options]
|
|
1788
|
+
*/
|
|
1789
|
+
async #load(query, { cursor = null, append = false, debounce = false } = {}) {
|
|
1790
|
+
this.loadController?.abort();
|
|
1791
|
+
this.loadController = new AbortController();
|
|
1792
|
+
const signal = this.loadController.signal;
|
|
1793
|
+
this.loadError = null;
|
|
1794
|
+
|
|
1795
|
+
if (debounce && Number(this.options.debounce) > 0) {
|
|
1796
|
+
try {
|
|
1797
|
+
await wait(Number(this.options.debounce), signal);
|
|
1798
|
+
} catch {
|
|
1799
|
+
return;
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
const before = emit(
|
|
1804
|
+
this.source,
|
|
1805
|
+
"combobox:beforeload",
|
|
1806
|
+
{
|
|
1807
|
+
query,
|
|
1808
|
+
cursor,
|
|
1809
|
+
combobox: this,
|
|
1810
|
+
signal,
|
|
1811
|
+
},
|
|
1812
|
+
{ cancelable: true },
|
|
1813
|
+
);
|
|
1814
|
+
if (before.defaultPrevented) return;
|
|
1815
|
+
|
|
1816
|
+
this.loading = true;
|
|
1817
|
+
this.#renderLoading();
|
|
1818
|
+
this.show();
|
|
1819
|
+
|
|
1820
|
+
try {
|
|
1821
|
+
const result = await this.options.load(query, {
|
|
1822
|
+
signal,
|
|
1823
|
+
cursor,
|
|
1824
|
+
combobox: this,
|
|
1825
|
+
source: this.source,
|
|
1826
|
+
input: this.#inputEl(),
|
|
1827
|
+
});
|
|
1828
|
+
if (signal.aborted) return;
|
|
1829
|
+
|
|
1830
|
+
const items = Array.isArray(result) ? result : result?.items;
|
|
1831
|
+
if (items) {
|
|
1832
|
+
const merged = append && this.results ? [...this.results, ...items] : items;
|
|
1833
|
+
this.setResults(merged);
|
|
1834
|
+
}
|
|
1835
|
+
// Keep the cursor contract open for future paged loading without
|
|
1836
|
+
// implementing virtual/infinite scrolling in the core.
|
|
1837
|
+
this.nextCursor = Array.isArray(result) ? null : (result?.cursor ?? null);
|
|
1838
|
+
|
|
1839
|
+
emit(this.source, "combobox:load", {
|
|
1840
|
+
query,
|
|
1841
|
+
combobox: this,
|
|
1842
|
+
result,
|
|
1843
|
+
});
|
|
1844
|
+
} catch (error) {
|
|
1845
|
+
const caught = /** @type {any} */ (error);
|
|
1846
|
+
if (signal.aborted || caught?.name === "AbortError") return;
|
|
1847
|
+
// The error row mirrors loading: it replaces the list for this query and
|
|
1848
|
+
// is cleared by the next successful load or local search. Long-lived
|
|
1849
|
+
// selection lives on the native source and is untouched by a failed load.
|
|
1850
|
+
this.loadError = caught;
|
|
1851
|
+
emit(this.source, "combobox:loaderror", {
|
|
1852
|
+
query,
|
|
1853
|
+
combobox: this,
|
|
1854
|
+
error: caught,
|
|
1855
|
+
});
|
|
1856
|
+
} finally {
|
|
1857
|
+
if (!signal.aborted) this.loading = false;
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
/**
|
|
1862
|
+
* @param {string} query
|
|
1863
|
+
*/
|
|
1864
|
+
#applyFilter(query) {
|
|
1865
|
+
const items = this.#items();
|
|
1866
|
+
|
|
1867
|
+
let visible = items.filter((item) => {
|
|
1868
|
+
if (this.isMultiple && item.selected) return false;
|
|
1869
|
+
return this.#matches(item, query);
|
|
1870
|
+
});
|
|
1871
|
+
|
|
1872
|
+
const context = { combobox: this, source: this.source, input: this.#inputEl() };
|
|
1873
|
+
|
|
1874
|
+
if (typeof this.options.filter === "function") {
|
|
1875
|
+
visible = visible.filter((item) => this.options.filter(item, query, context));
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
if (typeof this.options.score === "function") {
|
|
1879
|
+
visible = rankByScore(visible, (item, _index) => this.options.score(item, query, context));
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
if (typeof this.options.sort === "function") {
|
|
1883
|
+
visible.sort((a, b) => this.options.sort(a, b, query, context));
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
this.filteredItems = visible;
|
|
1887
|
+
|
|
1888
|
+
// Mirror the proposed :filtered state on the actual source option so the
|
|
1889
|
+
// migration path to the platform primitive is explicit.
|
|
1890
|
+
const visibleOptions = new Set(visible.map((item) => item.option));
|
|
1891
|
+
for (const item of items) {
|
|
1892
|
+
item.option?.toggleAttribute("data-filtered", !visibleOptions.has(item.option));
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
this.#renderList();
|
|
1896
|
+
this.#setActive(
|
|
1897
|
+
this.options.autoselectFirst ? this.visibleItems.findIndex((item) => !item.disabled) : -1,
|
|
1898
|
+
);
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
/**
|
|
1902
|
+
* Decision helper: an empty query is "no textual search", so the matcher
|
|
1903
|
+
* (including a custom match) has nothing to decide — everything passes the
|
|
1904
|
+
* match stage (`filter` admissibility still applies independently).
|
|
1905
|
+
* For any other query, the strategy is applied **per `searchField` value**:
|
|
1906
|
+
* `matchesField` owns every strategy and receives exactly one value, so a
|
|
1907
|
+
* match can never cross field boundaries.
|
|
1908
|
+
* @param {import("./helpers.js").ComboboxItem} item
|
|
1909
|
+
* @param {string} query
|
|
1910
|
+
* @returns {boolean}
|
|
1911
|
+
*/
|
|
1912
|
+
#matches(item, query) {
|
|
1913
|
+
if (!query) return true;
|
|
1914
|
+
|
|
1915
|
+
if (typeof this.options.match === "function") {
|
|
1916
|
+
return this.options.match(item, query, { combobox: this, source: this.source, input: this.#inputEl() });
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
const fields = Array.isArray(this.options.searchFields)
|
|
1920
|
+
? this.options.searchFields
|
|
1921
|
+
: this.options.searchFields
|
|
1922
|
+
? [this.options.searchFields]
|
|
1923
|
+
: [];
|
|
1924
|
+
const values = fields.map((field) => {
|
|
1925
|
+
if (field in item) return String(item[field] ?? "");
|
|
1926
|
+
return String(item.data?.[field] ?? "");
|
|
1927
|
+
});
|
|
1928
|
+
return values.some((value) => matchesField(value, query, this.options.match));
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
/**
|
|
1932
|
+
* @param {string | null | undefined} label
|
|
1933
|
+
* @returns {boolean}
|
|
1934
|
+
*/
|
|
1935
|
+
#canCreate(label) {
|
|
1936
|
+
const value = String(label ?? "").trim();
|
|
1937
|
+
if (!this.isSelect || !this.options.create || !value) return false;
|
|
1938
|
+
if (
|
|
1939
|
+
this.options.maxItems > 0 &&
|
|
1940
|
+
this.isMultiple &&
|
|
1941
|
+
this.#selectSource().selectedOptions.length >= this.options.maxItems
|
|
1942
|
+
)
|
|
1943
|
+
return false;
|
|
1944
|
+
if (typeof this.options.createFilter === "function") {
|
|
1945
|
+
return (
|
|
1946
|
+
this.options.createFilter(value, { combobox: this, source: this.source, input: this.#inputEl() }) !==
|
|
1947
|
+
false
|
|
1948
|
+
);
|
|
1949
|
+
}
|
|
1950
|
+
return true;
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
/* ---------------------------------------------------------------------- */
|
|
1954
|
+
/* Rendering */
|
|
1955
|
+
/* ---------------------------------------------------------------------- */
|
|
1956
|
+
|
|
1957
|
+
#renderList() {
|
|
1958
|
+
this.#listEl().replaceChildren();
|
|
1959
|
+
this.#statusEl().textContent = "";
|
|
1960
|
+
|
|
1961
|
+
if (this.loading) {
|
|
1962
|
+
this.#renderLoading();
|
|
1963
|
+
return;
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
if (this.loadError) {
|
|
1967
|
+
this.#renderError();
|
|
1968
|
+
return;
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
let previousGroup = null;
|
|
1972
|
+
for (const [index, item] of this.visibleItems.entries()) {
|
|
1973
|
+
if (item.group && item.group !== previousGroup) {
|
|
1974
|
+
const group = document.createElement("div");
|
|
1975
|
+
group.className = "cb-group";
|
|
1976
|
+
group.setAttribute("role", "presentation");
|
|
1977
|
+
setContent(group, this.options.render.group?.(item.group, { combobox: this }) ?? item.group);
|
|
1978
|
+
this.#listEl().append(group);
|
|
1979
|
+
previousGroup = item.group;
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
const option = document.createElement("div");
|
|
1983
|
+
option.className = "cb-option";
|
|
1984
|
+
option.id = `combobox-option-${this.id}-${index}`;
|
|
1985
|
+
option.role = "option";
|
|
1986
|
+
option.tabIndex = -1;
|
|
1987
|
+
option.dataset.index = String(index);
|
|
1988
|
+
option.setAttribute("aria-selected", String(Boolean(item.selected)));
|
|
1989
|
+
// Preserve native <option title="…"> tooltips on the row.
|
|
1990
|
+
if (item.option?.title || item.title) option.title = item.option?.title ?? item.title ?? "";
|
|
1991
|
+
if (item.disabled) {
|
|
1992
|
+
option.setAttribute("aria-disabled", "true");
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
const rendered = this.options.render.option?.(item, {
|
|
1996
|
+
query: this.query,
|
|
1997
|
+
selected: item.selected,
|
|
1998
|
+
combobox: this,
|
|
1999
|
+
});
|
|
2000
|
+
const label = document.createElement("span");
|
|
2001
|
+
label.className = "cb-option-label";
|
|
2002
|
+
setContent(label, rendered ?? item.label);
|
|
2003
|
+
option.append(label);
|
|
2004
|
+
this.#listEl().append(option);
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
if (!this.filteredItems.length) {
|
|
2008
|
+
if (this.#canCreate(this.#inputEl().value)) {
|
|
2009
|
+
const create = document.createElement("div");
|
|
2010
|
+
create.className = "cb-option cb-create";
|
|
2011
|
+
create.tabIndex = -1;
|
|
2012
|
+
create.role = "option";
|
|
2013
|
+
const query = this.#inputEl().value.trim();
|
|
2014
|
+
const rendered = this.options.render.create?.(query, { combobox: this });
|
|
2015
|
+
const createLabel = document.createElement("span");
|
|
2016
|
+
createLabel.className = "cb-option-label";
|
|
2017
|
+
setContent(
|
|
2018
|
+
createLabel,
|
|
2019
|
+
rendered ??
|
|
2020
|
+
this.options.messages.create?.(query, {
|
|
2021
|
+
combobox: this,
|
|
2022
|
+
source: this.source,
|
|
2023
|
+
input: this.#inputEl(),
|
|
2024
|
+
}) ??
|
|
2025
|
+
query,
|
|
2026
|
+
);
|
|
2027
|
+
create.append(createLabel);
|
|
2028
|
+
this.#listEl().append(create);
|
|
2029
|
+
} else {
|
|
2030
|
+
const empty = document.createElement("div");
|
|
2031
|
+
empty.className = "cb-empty";
|
|
2032
|
+
const rendered = this.options.render.noResults?.(this.query, { combobox: this });
|
|
2033
|
+
setContent(empty, rendered ?? this.options.messages.noResults);
|
|
2034
|
+
this.#listEl().append(empty);
|
|
2035
|
+
this.#statusEl().textContent = this.options.messages.noResults ?? "";
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
#renderLoading() {
|
|
2041
|
+
this.#listEl().replaceChildren();
|
|
2042
|
+
// A loading/error row replaces the option list, so no row may stay active:
|
|
2043
|
+
// the previous aria-activedescendant would point at removed DOM.
|
|
2044
|
+
this.#setActive(-1);
|
|
2045
|
+
const loading = document.createElement("div");
|
|
2046
|
+
loading.className = "cb-empty cb-loading";
|
|
2047
|
+
const rendered = this.options.render.loading?.(this.query, { combobox: this });
|
|
2048
|
+
setContent(loading, rendered ?? this.options.messages.loading);
|
|
2049
|
+
this.#listEl().append(loading);
|
|
2050
|
+
this.#statusEl().textContent = this.options.messages.loading ?? "";
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
#renderError() {
|
|
2054
|
+
this.#listEl().replaceChildren();
|
|
2055
|
+
this.#setActive(-1);
|
|
2056
|
+
const error = document.createElement("div");
|
|
2057
|
+
error.className = "cb-empty cb-error";
|
|
2058
|
+
const rendered = this.options.render.error?.(this.query, { error: this.loadError, combobox: this });
|
|
2059
|
+
setContent(error, rendered ?? this.options.messages.loadError);
|
|
2060
|
+
this.#listEl().append(error);
|
|
2061
|
+
this.#statusEl().textContent = this.options.messages.loadError ?? "";
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
#renderChips() {
|
|
2065
|
+
const chips = this.#chipsEl();
|
|
2066
|
+
if (!chips) return;
|
|
2067
|
+
chips.replaceChildren();
|
|
2068
|
+
|
|
2069
|
+
for (const option of this.#selectedOptionsInOrder()) {
|
|
2070
|
+
// An empty value is not a real selection unless allowEmptyOption is on;
|
|
2071
|
+
// the old-demo placeholder pattern (<option value="" selected disabled
|
|
2072
|
+
// hidden>) is never a chip.
|
|
2073
|
+
const placeholder = option.disabled && option.hidden;
|
|
2074
|
+
if (!option.value && (!this.options.allowEmptyOption || placeholder)) continue;
|
|
2075
|
+
|
|
2076
|
+
const item = {
|
|
2077
|
+
value: option.value,
|
|
2078
|
+
label: option.textContent.trim(),
|
|
2079
|
+
selected: true,
|
|
2080
|
+
disabled: option.disabled,
|
|
2081
|
+
option,
|
|
2082
|
+
data: { ...option.dataset },
|
|
2083
|
+
};
|
|
2084
|
+
|
|
2085
|
+
const chip = document.createElement("span");
|
|
2086
|
+
chip.className = "cb-chip";
|
|
2087
|
+
chip.tabIndex = -1;
|
|
2088
|
+
chip.dataset.value = item.value;
|
|
2089
|
+
// data-value is for inspection/debug only; the authoritative identity is
|
|
2090
|
+
// the option link kept in the #chipOptions WeakMap.
|
|
2091
|
+
this._chipOptions.set(chip, option);
|
|
2092
|
+
if (option.title) chip.title = option.title;
|
|
2093
|
+
|
|
2094
|
+
const label = document.createElement("span");
|
|
2095
|
+
label.className = "cb-chip-label";
|
|
2096
|
+
const rendered = this.options.render.item?.(item, { combobox: this });
|
|
2097
|
+
setContent(label, rendered ?? item.label);
|
|
2098
|
+
chip.append(label);
|
|
2099
|
+
|
|
2100
|
+
if (!option.disabled && !this.source.disabled) {
|
|
2101
|
+
const remove = document.createElement("button");
|
|
2102
|
+
remove.type = "button";
|
|
2103
|
+
remove.className = "cb-chip-remove";
|
|
2104
|
+
remove.append(createRemoveIcon());
|
|
2105
|
+
remove.setAttribute("aria-label", `Remove ${item.label}`);
|
|
2106
|
+
chip.append(remove);
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
chips.append(chip);
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
#selectedOptionsInOrder() {
|
|
2114
|
+
const selected = Array.from(this.#selectSource().selectedOptions);
|
|
2115
|
+
if (this.options.selectionOrder !== "selected") return selected;
|
|
2116
|
+
|
|
2117
|
+
// Reconcile the remembered order against the actual selection. Options no
|
|
2118
|
+
// longer selected are dropped; options selected by external DOM mutations
|
|
2119
|
+
// and absent from the remembered order are appended in native order. Set
|
|
2120
|
+
// membership is object identity, so duplicate values stay distinct.
|
|
2121
|
+
return reconcileSelected(selected, this.selectionOrder);
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
/**
|
|
2125
|
+
* @param {HTMLOptionElement} option
|
|
2126
|
+
*/
|
|
2127
|
+
#rememberSelection(option) {
|
|
2128
|
+
if (!this.selectionOrder.includes(option)) this.selectionOrder.push(option);
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
/**
|
|
2132
|
+
* @param {HTMLOptionElement} option
|
|
2133
|
+
*/
|
|
2134
|
+
#forgetSelection(option) {
|
|
2135
|
+
const index = this.selectionOrder.indexOf(option);
|
|
2136
|
+
if (index >= 0) this.selectionOrder.splice(index, 1);
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
/**
|
|
2140
|
+
* Keyboard interaction on a focused chip.
|
|
2141
|
+
* @param {KeyboardEvent} event
|
|
2142
|
+
* @param {import("./helpers.js").ComboboxItem} item
|
|
2143
|
+
*/
|
|
2144
|
+
#onChipKeyDown(event, item) {
|
|
2145
|
+
const chips = /** @type {HTMLElement[]} */ (
|
|
2146
|
+
Array.from(this.#chipsEl()?.querySelectorAll(".cb-chip") || [])
|
|
2147
|
+
);
|
|
2148
|
+
const current =
|
|
2149
|
+
event.target instanceof HTMLElement
|
|
2150
|
+
? /** @type {HTMLElement | null} */ (event.target.closest(".cb-chip"))
|
|
2151
|
+
: null;
|
|
2152
|
+
const index = current ? chips.indexOf(current) : -1;
|
|
2153
|
+
|
|
2154
|
+
// Ordered-mode keyboard reorder: Alt+Arrow/Home/End reorders a focused chip
|
|
2155
|
+
// without changing navigation keys. The gesture is consumed only when a real
|
|
2156
|
+
// move is possible; otherwise it falls through untouched (mirroring the
|
|
2157
|
+
// tabSelect "preventDefault only when a commit is possible" rule).
|
|
2158
|
+
if (event.altKey && index >= 0 && this.options.selectionOrder === "selected") {
|
|
2159
|
+
const target =
|
|
2160
|
+
event.key === "ArrowLeft"
|
|
2161
|
+
? index - 1
|
|
2162
|
+
: event.key === "ArrowRight"
|
|
2163
|
+
? index + 1
|
|
2164
|
+
: event.key === "Home"
|
|
2165
|
+
? 0
|
|
2166
|
+
: event.key === "End"
|
|
2167
|
+
? chips.length - 1
|
|
2168
|
+
: index;
|
|
2169
|
+
if (target !== index && target >= 0 && target < chips.length) {
|
|
2170
|
+
event.preventDefault();
|
|
2171
|
+
this.#reorderChip(item, target);
|
|
2172
|
+
return;
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
if (
|
|
2177
|
+
event.key === "ArrowLeft" ||
|
|
2178
|
+
event.key === "ArrowRight" ||
|
|
2179
|
+
event.key === "Home" ||
|
|
2180
|
+
event.key === "End"
|
|
2181
|
+
) {
|
|
2182
|
+
event.preventDefault();
|
|
2183
|
+
let next = index;
|
|
2184
|
+
if (event.key === "ArrowLeft") next = Math.max(0, index - 1);
|
|
2185
|
+
if (event.key === "ArrowRight") next = index + 1;
|
|
2186
|
+
if (event.key === "Home") next = 0;
|
|
2187
|
+
if (event.key === "End") next = chips.length - 1;
|
|
2188
|
+
if (next >= chips.length) this.#inputEl().focus();
|
|
2189
|
+
else chips[next]?.focus();
|
|
2190
|
+
return;
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
if (event.key === "Delete" || event.key === "Backspace") {
|
|
2194
|
+
event.preventDefault();
|
|
2195
|
+
void this.remove(item.option ?? item.value).then((removed) => {
|
|
2196
|
+
if (!removed) return;
|
|
2197
|
+
queueMicrotask(() => {
|
|
2198
|
+
const remaining = /** @type {HTMLElement[]} */ (
|
|
2199
|
+
Array.from(this.#chipsEl()?.querySelectorAll(".cb-chip") || [])
|
|
2200
|
+
);
|
|
2201
|
+
remaining[Math.min(index, remaining.length - 1)]?.focus();
|
|
2202
|
+
if (!remaining.length) this.#inputEl().focus();
|
|
2203
|
+
});
|
|
2204
|
+
});
|
|
2205
|
+
return;
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
if (event.key === "Escape") {
|
|
2209
|
+
event.preventDefault();
|
|
2210
|
+
this.#inputEl().focus();
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
/** Move a focused chip to an absolute position, keep it focused and announce. */
|
|
2215
|
+
/**
|
|
2216
|
+
* @param {import("./helpers.js").ComboboxItem} item
|
|
2217
|
+
* @param {number} target
|
|
2218
|
+
*/
|
|
2219
|
+
#reorderChip(item, target) {
|
|
2220
|
+
const identity = item.option ?? item.value;
|
|
2221
|
+
if (!this.move(identity, target)) return;
|
|
2222
|
+
const chips = /** @type {HTMLElement[]} */ (
|
|
2223
|
+
Array.from(this.#chipsEl()?.querySelectorAll(".cb-chip") || [])
|
|
2224
|
+
);
|
|
2225
|
+
const chip = item.option
|
|
2226
|
+
? chips.find((candidate) => this._chipOptions.get(candidate) === item.option)
|
|
2227
|
+
: chips.find((candidate) => candidate.dataset.value === item.value);
|
|
2228
|
+
chip?.focus();
|
|
2229
|
+
this.#statusEl().textContent =
|
|
2230
|
+
this.options.messages.position?.(
|
|
2231
|
+
item.label,
|
|
2232
|
+
chips.indexOf(/** @type {HTMLElement} */ (chip)) + 1,
|
|
2233
|
+
chips.length,
|
|
2234
|
+
{ combobox: this, source: this.source, input: this.#inputEl() },
|
|
2235
|
+
) ?? "";
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
/* ---------------------------------------------------------------------- */
|
|
2239
|
+
/* Selection / creation */
|
|
2240
|
+
/* ---------------------------------------------------------------------- */
|
|
2241
|
+
|
|
2242
|
+
/**
|
|
2243
|
+
* @param {number} delta
|
|
2244
|
+
*/
|
|
2245
|
+
#moveActive(delta) {
|
|
2246
|
+
const visible = this.visibleItems;
|
|
2247
|
+
if (!visible.length) return;
|
|
2248
|
+
|
|
2249
|
+
let next = this.activeIndex < 0 ? (delta > 0 ? -1 : 0) : this.activeIndex;
|
|
2250
|
+
for (let checked = 0; checked < visible.length; checked++) {
|
|
2251
|
+
next = (next + delta + visible.length) % visible.length;
|
|
2252
|
+
if (!visible[next].disabled) {
|
|
2253
|
+
this.#setActive(next);
|
|
2254
|
+
return;
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
/** Nearest selectable row at/after (`direction > 0`) or at/before an index, or -1. */
|
|
2260
|
+
/**
|
|
2261
|
+
* @param {number} from
|
|
2262
|
+
* @param {number} direction
|
|
2263
|
+
* @returns {number}
|
|
2264
|
+
*/
|
|
2265
|
+
#nearestSelectable(from, direction) {
|
|
2266
|
+
const visible = this.visibleItems;
|
|
2267
|
+
const len = visible.length;
|
|
2268
|
+
if (!len) return -1;
|
|
2269
|
+
from = Math.max(0, Math.min(from, len - 1));
|
|
2270
|
+
for (let i = from; i >= 0 && i < len; i += direction) {
|
|
2271
|
+
if (!visible[i]?.disabled) return i;
|
|
2272
|
+
}
|
|
2273
|
+
if (direction > 0) {
|
|
2274
|
+
for (let i = from - 1; i >= 0; i--) if (!visible[i]?.disabled) return i;
|
|
2275
|
+
} else {
|
|
2276
|
+
for (let i = from + 1; i < len; i++) if (!visible[i]?.disabled) return i;
|
|
2277
|
+
}
|
|
2278
|
+
return -1;
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
/** Rendered page height in selectable rows, used by PageUp/PageDown. */
|
|
2282
|
+
#pageSize() {
|
|
2283
|
+
const first = this.#listEl().querySelector(".cb-option");
|
|
2284
|
+
if (!first) return 1;
|
|
2285
|
+
const row = /** @type {HTMLElement} */ (first).offsetHeight || 48;
|
|
2286
|
+
const view = this.#popoverEl().clientHeight || 0;
|
|
2287
|
+
return Math.max(1, Math.floor(view / row));
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
/**
|
|
2291
|
+
* @param {number} index
|
|
2292
|
+
*/
|
|
2293
|
+
#setActive(index) {
|
|
2294
|
+
if (index >= this.visibleItems.length) index = -1;
|
|
2295
|
+
this.activeIndex = index;
|
|
2296
|
+
|
|
2297
|
+
for (const item of this.#sourceItems()) item.option?.removeAttribute("data-active-option");
|
|
2298
|
+
|
|
2299
|
+
for (const option of this.#listEl().querySelectorAll(".cb-option[data-index]")) {
|
|
2300
|
+
const el = /** @type {HTMLElement} */ (option);
|
|
2301
|
+
const active = Number(el.dataset.index) === index;
|
|
2302
|
+
el.toggleAttribute("data-active", active);
|
|
2303
|
+
if (active) {
|
|
2304
|
+
this.#inputEl().setAttribute("aria-activedescendant", el.id);
|
|
2305
|
+
this.visibleItems[index]?.option?.setAttribute("data-active-option", "");
|
|
2306
|
+
el.scrollIntoView({ block: "nearest" });
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
if (index < 0) this.#inputEl().removeAttribute("aria-activedescendant");
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
/**
|
|
2314
|
+
* @param {import("./helpers.js").ComboboxItem} item
|
|
2315
|
+
* @param {{ materialize?: boolean }} [options]
|
|
2316
|
+
* @returns {boolean}
|
|
2317
|
+
*/
|
|
2318
|
+
#selectItem(item, { materialize = true } = {}) {
|
|
2319
|
+
if (item.disabled) return false;
|
|
2320
|
+
|
|
2321
|
+
let option = null;
|
|
2322
|
+
if (this.isSelect) {
|
|
2323
|
+
// Option identity is authoritative: an exact item.option always wins over
|
|
2324
|
+
// a value lookup, so the third `value="2"` row a user picks never
|
|
2325
|
+
// collapses into the first one. A plain value resolves to the first
|
|
2326
|
+
// selectable match (unselected in multiple mode); materialization only
|
|
2327
|
+
// creates a native option when the value is genuinely absent.
|
|
2328
|
+
option =
|
|
2329
|
+
item.option instanceof HTMLOptionElement ? item.option : this.#findSelectableOption(item.value);
|
|
2330
|
+
if (option?.disabled || (!option && !materialize)) return false;
|
|
2331
|
+
|
|
2332
|
+
const unchanged = option
|
|
2333
|
+
? this.isMultiple
|
|
2334
|
+
? option.selected
|
|
2335
|
+
: this.#selectSource().selectedOptions[0] === option
|
|
2336
|
+
: false;
|
|
2337
|
+
if (unchanged) {
|
|
2338
|
+
if (!this.isMultiple) this.hide();
|
|
2339
|
+
return false;
|
|
2340
|
+
}
|
|
2341
|
+
if (
|
|
2342
|
+
this.isMultiple &&
|
|
2343
|
+
this.options.maxItems > 0 &&
|
|
2344
|
+
this.#selectSource().selectedOptions.length >= this.options.maxItems
|
|
2345
|
+
) {
|
|
2346
|
+
return false;
|
|
2347
|
+
}
|
|
2348
|
+
if (option) item = { ...item, option, selected: true };
|
|
2349
|
+
} else if (this.source.value === item.value) {
|
|
2350
|
+
this.hide();
|
|
2351
|
+
return false;
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
const before = emit(
|
|
2355
|
+
this.source,
|
|
2356
|
+
"combobox:beforeselect",
|
|
2357
|
+
{
|
|
2358
|
+
combobox: this,
|
|
2359
|
+
item,
|
|
2360
|
+
},
|
|
2361
|
+
{ cancelable: true },
|
|
2362
|
+
);
|
|
2363
|
+
if (before.defaultPrevented) return false;
|
|
2364
|
+
|
|
2365
|
+
if (this.isSelect) {
|
|
2366
|
+
// A transient result becomes native state only after beforeselect has
|
|
2367
|
+
// allowed the operation. Cancellation therefore has zero catalogue or
|
|
2368
|
+
// value side effects, as a synchronous `before*` contract promises.
|
|
2369
|
+
if (!option) option = this.addOption(item);
|
|
2370
|
+
const selectOption = /** @type {HTMLOptionElement} */ (option);
|
|
2371
|
+
item = { ...item, option: selectOption, selected: true };
|
|
2372
|
+
if (this.isMultiple) {
|
|
2373
|
+
selectOption.selected = true;
|
|
2374
|
+
this.#rememberSelection(selectOption);
|
|
2375
|
+
this.#inputEl().value = "";
|
|
2376
|
+
this.#commit();
|
|
2377
|
+
if (this.suppressReopen) this.refresh();
|
|
2378
|
+
else if (this.#closeOnSelect()) this.hide();
|
|
2379
|
+
else this.search("", { show: true, reason: "select" });
|
|
2380
|
+
} else {
|
|
2381
|
+
// Select the exact option rather than assigning source.value, so a
|
|
2382
|
+
// duplicate value keeps its own label and selectedIndex.
|
|
2383
|
+
selectOption.selected = true;
|
|
2384
|
+
this.selectionOrder = [selectOption];
|
|
2385
|
+
this.#inputEl().value = item.label;
|
|
2386
|
+
this.#commit();
|
|
2387
|
+
if (this.#closeOnSelect()) this.hide();
|
|
2388
|
+
}
|
|
2389
|
+
} else {
|
|
2390
|
+
this.source.value = item.value;
|
|
2391
|
+
this.#dispatchNativeValueEvents();
|
|
2392
|
+
this.hide();
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
emit(this.source, "combobox:select", { combobox: this, item });
|
|
2396
|
+
this.#markEngineMutation();
|
|
2397
|
+
return true;
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
/**
|
|
2401
|
+
* @param {string} label
|
|
2402
|
+
* @returns {Promise<HTMLOptionElement | null>}
|
|
2403
|
+
*/
|
|
2404
|
+
async #createItem(label) {
|
|
2405
|
+
if (!this.#canCreate(label)) return null;
|
|
2406
|
+
|
|
2407
|
+
const existing = this.#findCreateMatch(label);
|
|
2408
|
+
if (existing) {
|
|
2409
|
+
this.#selectItem(existing);
|
|
2410
|
+
return existing.option ?? null;
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
// guards.add is about creating a brand-new item; existing matches are
|
|
2414
|
+
// selected above without running it.
|
|
2415
|
+
const guard = await this.#runGuard("add", { label });
|
|
2416
|
+
if (!guard.ok) return null;
|
|
2417
|
+
|
|
2418
|
+
const before = emit(
|
|
2419
|
+
this.source,
|
|
2420
|
+
"combobox:beforecreate",
|
|
2421
|
+
{
|
|
2422
|
+
combobox: this,
|
|
2423
|
+
label,
|
|
2424
|
+
},
|
|
2425
|
+
{ cancelable: true },
|
|
2426
|
+
);
|
|
2427
|
+
if (before.defaultPrevented) return null;
|
|
2428
|
+
|
|
2429
|
+
/** @type {import("./helpers.js").ComboboxItem} */
|
|
2430
|
+
let created = { value: label, label };
|
|
2431
|
+
if (typeof this.options.create === "function") {
|
|
2432
|
+
this.loading = true;
|
|
2433
|
+
this.#renderLoading();
|
|
2434
|
+
try {
|
|
2435
|
+
const result = await this.options.create(label, {
|
|
2436
|
+
signal: this.abortController.signal,
|
|
2437
|
+
combobox: this,
|
|
2438
|
+
source: this.source,
|
|
2439
|
+
input: this.#inputEl(),
|
|
2440
|
+
});
|
|
2441
|
+
if (!result) return null;
|
|
2442
|
+
created = /** @type {import("./helpers.js").ComboboxItem} */ (toItem(result, this.#fields()));
|
|
2443
|
+
} catch (/** @type {any} */ error) {
|
|
2444
|
+
if (error?.name !== "AbortError")
|
|
2445
|
+
emit(this.source, "combobox:createerror", { combobox: this, label, error });
|
|
2446
|
+
return null;
|
|
2447
|
+
} finally {
|
|
2448
|
+
this.loading = false;
|
|
2449
|
+
}
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
const option = this.addOption(created, { selected: true });
|
|
2453
|
+
this.#rememberSelection(option);
|
|
2454
|
+
this.#inputEl().value = "";
|
|
2455
|
+
this.#commit();
|
|
2456
|
+
|
|
2457
|
+
emit(this.source, "combobox:create", {
|
|
2458
|
+
combobox: this,
|
|
2459
|
+
item: { ...created, option, selected: true },
|
|
2460
|
+
});
|
|
2461
|
+
|
|
2462
|
+
if (this.isMultiple) {
|
|
2463
|
+
if (this.suppressReopen) this.refresh();
|
|
2464
|
+
else if (this.#closeOnSelect()) this.hide();
|
|
2465
|
+
else this.search("", { show: true, reason: "create" });
|
|
2466
|
+
} else {
|
|
2467
|
+
this.hide();
|
|
2468
|
+
}
|
|
2469
|
+
this.#markEngineMutation();
|
|
2470
|
+
return option;
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2473
|
+
/**
|
|
2474
|
+
* Run an async guard. `false` is a voluntary refusal (no mutation, no
|
|
2475
|
+
* error). A rejected promise is an application error: a generic
|
|
2476
|
+
* `combobox:guarderror` event is emitted and the operation is blocked.
|
|
2477
|
+
* @param {"add" | "remove" | "clear"} name
|
|
2478
|
+
* @param {any} payload
|
|
2479
|
+
* @returns {Promise<{ ok: boolean, refused?: boolean, error?: any }>}
|
|
2480
|
+
*/
|
|
2481
|
+
async #runGuard(name, payload) {
|
|
2482
|
+
const guards = this.options.guards;
|
|
2483
|
+
const guard = guards[name];
|
|
2484
|
+
if (typeof guard !== "function") return { ok: true };
|
|
2485
|
+
try {
|
|
2486
|
+
const result = await guard(payload, {
|
|
2487
|
+
combobox: this,
|
|
2488
|
+
source: this.source,
|
|
2489
|
+
input: this.#inputEl(),
|
|
2490
|
+
signal: this.abortController.signal,
|
|
2491
|
+
});
|
|
2492
|
+
return { ok: result !== false, refused: result === false };
|
|
2493
|
+
} catch (error) {
|
|
2494
|
+
emit(this.source, "combobox:guarderror", { combobox: this, guard: name, error });
|
|
2495
|
+
return { ok: false, refused: false, error };
|
|
2496
|
+
}
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2499
|
+
/**
|
|
2500
|
+
* @returns {boolean}
|
|
2501
|
+
*/
|
|
2502
|
+
#closeOnSelect() {
|
|
2503
|
+
return this.options.closeOnSelect ?? !this.isMultiple;
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2506
|
+
/**
|
|
2507
|
+
* @returns {boolean}
|
|
2508
|
+
*/
|
|
2509
|
+
#separatorsActive() {
|
|
2510
|
+
return this.isMultiple && Array.isArray(this.options.separators) && this.options.separators.length > 0;
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2513
|
+
/**
|
|
2514
|
+
* Resolve the input value into token entries. Honors the optional `tokenize`
|
|
2515
|
+
* seam: `tokenize(value, ctx) => { tokens: string[], rest?: string }`.
|
|
2516
|
+
* `tokens` are complete tokens to consume; `rest` is the trailing
|
|
2517
|
+
* incomplete text that must keep living in the input (defaults to `""`).
|
|
2518
|
+
* @param {string} value
|
|
2519
|
+
* @param {boolean} [final]
|
|
2520
|
+
* @returns {{ entries: Array<{ text: string, sep: string }>, rest: string }}
|
|
2521
|
+
*/
|
|
2522
|
+
#resolveTokens(value, final = false) {
|
|
2523
|
+
const custom = this.options.tokenize;
|
|
2524
|
+
if (typeof custom === "function") {
|
|
2525
|
+
const result = custom(value, { combobox: this, source: this.source, input: this.#inputEl() });
|
|
2526
|
+
const tokens = result && Array.isArray(result.tokens) ? result.tokens : [];
|
|
2527
|
+
const entries = tokens.map((text) => ({ text: String(text), sep: "" }));
|
|
2528
|
+
return { entries, rest: final ? "" : String(result?.rest ?? "") };
|
|
2529
|
+
}
|
|
2530
|
+
|
|
2531
|
+
const { done, rest } = splitTokens(value, this.options.separators);
|
|
2532
|
+
const entries = final && rest.trim() ? [...done, { text: rest.trim(), sep: "" }] : done;
|
|
2533
|
+
return { entries, rest: final ? "" : rest };
|
|
2534
|
+
}
|
|
2535
|
+
|
|
2536
|
+
/**
|
|
2537
|
+
* Consume completed tokens sequentially. Never Promise.all a batch: each
|
|
2538
|
+
* token runs existing -> guard -> create -> select in order, re-evaluating
|
|
2539
|
+
* maxItems between tokens. On refusal/error/maxItems the unprocessed
|
|
2540
|
+
* remainder stays in the input; a trailing incomplete token stays too.
|
|
2541
|
+
* @param {string} value
|
|
2542
|
+
* @param {{ final?: boolean }} [options]
|
|
2543
|
+
* @returns {Promise<{ consumed: boolean, rest: string } | null>}
|
|
2544
|
+
*/
|
|
2545
|
+
async #processTokens(value, { final = false } = {}) {
|
|
2546
|
+
if (!this.#separatorsActive()) return null;
|
|
2547
|
+
|
|
2548
|
+
const { entries, rest } = this.#resolveTokens(value, final);
|
|
2549
|
+
if (!entries.length) return { consumed: false, rest };
|
|
2550
|
+
|
|
2551
|
+
let consumedLength = 0;
|
|
2552
|
+
for (let index = 0; index < entries.length; index++) {
|
|
2553
|
+
const entry = entries[index];
|
|
2554
|
+
if (this.options.maxItems > 0 && this.#selectSource().selectedOptions.length >= this.options.maxItems) {
|
|
2555
|
+
return { consumed: false, rest: value.slice(consumedLength) };
|
|
2556
|
+
}
|
|
2557
|
+
if (!(await this.#applyToken(entry.text))) {
|
|
2558
|
+
return { consumed: false, rest: value.slice(consumedLength) };
|
|
2559
|
+
}
|
|
2560
|
+
consumedLength += entry.text.length + entry.sep.length;
|
|
2561
|
+
}
|
|
2562
|
+
return { consumed: true, rest: final ? "" : rest };
|
|
2563
|
+
}
|
|
2564
|
+
|
|
2565
|
+
/** Apply one token: existing option wins, otherwise guarded creation. */
|
|
2566
|
+
/**
|
|
2567
|
+
* @param {string | null | undefined} text
|
|
2568
|
+
* @returns {Promise<boolean>}
|
|
2569
|
+
*/
|
|
2570
|
+
async #applyToken(text) {
|
|
2571
|
+
const term = String(text ?? "").trim();
|
|
2572
|
+
if (!term) return true;
|
|
2573
|
+
|
|
2574
|
+
const existing = this.#findCreateMatch(term);
|
|
2575
|
+
if (existing) {
|
|
2576
|
+
this.#selectItem(existing);
|
|
2577
|
+
return true;
|
|
2578
|
+
}
|
|
2579
|
+
if (!this.#canCreate(term)) return false;
|
|
2580
|
+
const created = await this.#createItem(term);
|
|
2581
|
+
return created !== null;
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2584
|
+
async #handleTokenInput() {
|
|
2585
|
+
const result = await this.#processTokens(this.#inputEl().value);
|
|
2586
|
+
// #processTokens always returns the computed remainder (`rest`): the
|
|
2587
|
+
// trailing incomplete token on a full consumption, or the unconsumed tail
|
|
2588
|
+
// (including a refused token) on a refusal/maxItems stop. #createItem
|
|
2589
|
+
// clears the interaction input for each created token, so the remainder
|
|
2590
|
+
// must be written back in both cases — otherwise a mid-batch refusal
|
|
2591
|
+
// would silently swallow the rest of the pasted text.
|
|
2592
|
+
if (result) this.#inputEl().value = result.rest;
|
|
2593
|
+
this.search(this.#inputEl().value, { show: true, reason: "input" });
|
|
2594
|
+
}
|
|
2595
|
+
|
|
2596
|
+
async #commitEnterTokens() {
|
|
2597
|
+
const result = await this.#processTokens(this.#inputEl().value, { final: true });
|
|
2598
|
+
if (result?.consumed) {
|
|
2599
|
+
this.#inputEl().value = result.rest;
|
|
2600
|
+
this.search("", { show: true, reason: "create" });
|
|
2601
|
+
}
|
|
2602
|
+
}
|
|
2603
|
+
|
|
2604
|
+
#dispatchNativeValueEvents() {
|
|
2605
|
+
this.source.dispatchEvent(new Event("input", { bubbles: true }));
|
|
2606
|
+
this.source.dispatchEvent(new Event("change", { bubbles: true }));
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
#commit() {
|
|
2610
|
+
this.source.removeAttribute("aria-invalid");
|
|
2611
|
+
this.#inputEl()?.removeAttribute("aria-invalid");
|
|
2612
|
+
this.#dispatchNativeValueEvents();
|
|
2613
|
+
}
|
|
2614
|
+
|
|
2615
|
+
/* ---------------------------------------------------------------------- */
|
|
2616
|
+
/* Public state */
|
|
2617
|
+
/* ---------------------------------------------------------------------- */
|
|
2618
|
+
|
|
2619
|
+
/**
|
|
2620
|
+
* Add a catalogue option to the select.
|
|
2621
|
+
* @param {*} rawItem
|
|
2622
|
+
* @param {{ selected?: boolean }} [options]
|
|
2623
|
+
* @returns {HTMLOptionElement}
|
|
2624
|
+
*/
|
|
2625
|
+
addOption(rawItem, { selected = false } = {}) {
|
|
2626
|
+
if (!this.isSelect) throw new TypeError("addOption() is only available for select-backed comboboxes");
|
|
2627
|
+
const item = toItem(rawItem, this.#fields());
|
|
2628
|
+
if (!item) throw new TypeError("Option requires a value");
|
|
2629
|
+
// `""` is a legitimate value only when allowEmptyOption admits it; the
|
|
2630
|
+
// empty-placeholder convention stays the single-select default otherwise.
|
|
2631
|
+
if (item.value === "" && !this.options.allowEmptyOption) throw new TypeError("Option requires a value");
|
|
2632
|
+
|
|
2633
|
+
// Each catalogue entry is its own identity: an existing value never
|
|
2634
|
+
// short-circuits a fresh option, so two distinct {value: "2"} entries stay
|
|
2635
|
+
// distinct choices. An explicit item.option is adopted as-is instead.
|
|
2636
|
+
const option =
|
|
2637
|
+
item.option instanceof HTMLOptionElement
|
|
2638
|
+
? item.option
|
|
2639
|
+
: // `selected` is live state only. `defaultSelected` belongs to authored
|
|
2640
|
+
// markup (or an explicit setOptions catalogue replacement), otherwise
|
|
2641
|
+
// a dynamic selection would silently rewrite form.reset()'s baseline.
|
|
2642
|
+
new Option(item.label, item.value, false, selected);
|
|
2643
|
+
if (!(item.option instanceof HTMLOptionElement)) {
|
|
2644
|
+
option.disabled = Boolean(item.disabled);
|
|
2645
|
+
if (item.data) Object.assign(option.dataset, item.data);
|
|
2646
|
+
if (item.group) {
|
|
2647
|
+
let group = /** @type {HTMLOptGroupElement | undefined} */ (
|
|
2648
|
+
Array.from(this.#selectSource().children).find(
|
|
2649
|
+
(node) => node instanceof HTMLOptGroupElement && node.label === item.group,
|
|
2650
|
+
)
|
|
2651
|
+
);
|
|
2652
|
+
if (!group) {
|
|
2653
|
+
group = document.createElement("optgroup");
|
|
2654
|
+
group.label = item.group;
|
|
2655
|
+
this.#selectSource().append(group);
|
|
2656
|
+
}
|
|
2657
|
+
group.append(option);
|
|
2658
|
+
} else {
|
|
2659
|
+
this.#selectSource().add(option);
|
|
2660
|
+
}
|
|
2661
|
+
}
|
|
2662
|
+
if (selected && !option.selected) option.selected = true;
|
|
2663
|
+
if (selected) this.#rememberSelection(option);
|
|
2664
|
+
return option;
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
/**
|
|
2668
|
+
* Select an item. Bare values resolve to existing catalogue entries (never
|
|
2669
|
+
* materialising new options); objects/options may materialise.
|
|
2670
|
+
* @param {string | number | import("./helpers.js").ComboboxItem | HTMLOptionElement} itemOrValue
|
|
2671
|
+
* @returns {boolean}
|
|
2672
|
+
*/
|
|
2673
|
+
select(itemOrValue) {
|
|
2674
|
+
const isObject = typeof itemOrValue === "object" && itemOrValue !== null;
|
|
2675
|
+
|
|
2676
|
+
if (this.mode === "fallback" && this.isSelect) {
|
|
2677
|
+
const item = isObject
|
|
2678
|
+
? toItem(itemOrValue, this.#fields())
|
|
2679
|
+
: { value: String(itemOrValue), label: String(itemOrValue) };
|
|
2680
|
+
if (!item) return false;
|
|
2681
|
+
const option =
|
|
2682
|
+
(this.isMultiple ? this.#findSelectableOption(item.value) : null) || this.#findOption(item.value);
|
|
2683
|
+
if (!option) {
|
|
2684
|
+
if (!isObject) return false;
|
|
2685
|
+
const created = this.addOption(item, { selected: true });
|
|
2686
|
+
this.#dispatchNativeValueEvents();
|
|
2687
|
+
return created !== null;
|
|
2688
|
+
}
|
|
2689
|
+
if (option.disabled) return false;
|
|
2690
|
+
const unchanged = this.isMultiple ? option.selected : this.source.value === option.value;
|
|
2691
|
+
if (unchanged) return false;
|
|
2692
|
+
if (!this.isMultiple) {
|
|
2693
|
+
for (const other of this.#selectSource().options) other.selected = false;
|
|
2694
|
+
}
|
|
2695
|
+
option.selected = true;
|
|
2696
|
+
this.#rememberSelection(option);
|
|
2697
|
+
this.#dispatchNativeValueEvents();
|
|
2698
|
+
return true;
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2701
|
+
if (isObject) {
|
|
2702
|
+
const item = toItem(itemOrValue, this.#fields());
|
|
2703
|
+
if (!item) return false;
|
|
2704
|
+
// An exact <option> passed to select() keeps its identity, so a
|
|
2705
|
+
// duplicate value is never resolved back to the first occurrence.
|
|
2706
|
+
if (itemOrValue instanceof HTMLOptionElement) item.option = itemOrValue;
|
|
2707
|
+
return this.#selectItem(item, { materialize: true });
|
|
2708
|
+
}
|
|
2709
|
+
|
|
2710
|
+
// A bare string means "select an existing catalogue value": no implicit
|
|
2711
|
+
// creation, and each call resolves to the next selectable occurrence (see
|
|
2712
|
+
// #findSelectableOption), so select("2") x3 picks three distinct options.
|
|
2713
|
+
const value = String(itemOrValue);
|
|
2714
|
+
const foundRaw =
|
|
2715
|
+
this.#items().find((candidate) => candidate.value === value) ||
|
|
2716
|
+
this.#sourceItems().find((candidate) => candidate.value === value);
|
|
2717
|
+
const found = foundRaw ? /** @type {import("./helpers.js").ComboboxItem} */ (foundRaw) : null;
|
|
2718
|
+
if (!found) return false;
|
|
2719
|
+
return this.#selectItem({ value: found.value, label: found.label }, { materialize: false });
|
|
2720
|
+
}
|
|
2721
|
+
|
|
2722
|
+
/**
|
|
2723
|
+
* @param {string | HTMLOptionElement} valueOrOption
|
|
2724
|
+
* @returns {Promise<boolean>}
|
|
2725
|
+
*/
|
|
2726
|
+
async remove(valueOrOption) {
|
|
2727
|
+
if (!this.isSelect) return false;
|
|
2728
|
+
// An exact option is authoritative (the chip a user clicked); a bare value
|
|
2729
|
+
// resolves to the first selected occurrence in the current order.
|
|
2730
|
+
const option =
|
|
2731
|
+
valueOrOption instanceof HTMLOptionElement
|
|
2732
|
+
? valueOrOption
|
|
2733
|
+
: this.#selectedOptionsInOrder().find((entry) => entry.value === String(valueOrOption));
|
|
2734
|
+
if (!option?.selected || option.disabled) return false;
|
|
2735
|
+
const item = {
|
|
2736
|
+
value: option.value,
|
|
2737
|
+
label: option.textContent.trim(),
|
|
2738
|
+
option,
|
|
2739
|
+
selected: true,
|
|
2740
|
+
data: { ...option.dataset },
|
|
2741
|
+
};
|
|
2742
|
+
const guard = await this.#runGuard("remove", { item });
|
|
2743
|
+
if (!guard.ok) return false;
|
|
2744
|
+
const before = emit(this.source, "combobox:beforeremove", { combobox: this, item }, { cancelable: true });
|
|
2745
|
+
if (before.defaultPrevented) return false;
|
|
2746
|
+
option.selected = false;
|
|
2747
|
+
this.#forgetSelection(option);
|
|
2748
|
+
this.#commit();
|
|
2749
|
+
emit(this.source, "combobox:remove", { combobox: this, item });
|
|
2750
|
+
this.refresh();
|
|
2751
|
+
this.#markEngineMutation();
|
|
2752
|
+
return true;
|
|
2753
|
+
}
|
|
2754
|
+
|
|
2755
|
+
async clear() {
|
|
2756
|
+
if (!this.isSelect) {
|
|
2757
|
+
if (!this.source.value) return false;
|
|
2758
|
+
const guard = await this.#runGuard("clear", {});
|
|
2759
|
+
if (!guard.ok) return false;
|
|
2760
|
+
const before = emit(this.source, "combobox:beforeclear", { combobox: this }, { cancelable: true });
|
|
2761
|
+
if (before.defaultPrevented) return false;
|
|
2762
|
+
this.source.value = "";
|
|
2763
|
+
this.#dispatchNativeValueEvents();
|
|
2764
|
+
emit(this.source, "combobox:clear", { combobox: this });
|
|
2765
|
+
return true;
|
|
2766
|
+
}
|
|
2767
|
+
|
|
2768
|
+
const selected = Array.from(this.#selectSource().selectedOptions).filter((option) => !option.disabled);
|
|
2769
|
+
if (!selected.length) return false;
|
|
2770
|
+
const guard = await this.#runGuard("clear", {});
|
|
2771
|
+
if (!guard.ok) return false;
|
|
2772
|
+
const before = emit(this.source, "combobox:beforeclear", { combobox: this }, { cancelable: true });
|
|
2773
|
+
if (before.defaultPrevented) return false;
|
|
2774
|
+
for (const option of selected) option.selected = false;
|
|
2775
|
+
this.selectionOrder = this.selectionOrder.filter((option) => option.selected);
|
|
2776
|
+
this.#commit();
|
|
2777
|
+
emit(this.source, "combobox:clear", { combobox: this });
|
|
2778
|
+
this.refresh();
|
|
2779
|
+
this.#markEngineMutation();
|
|
2780
|
+
return true;
|
|
2781
|
+
}
|
|
2782
|
+
|
|
2783
|
+
/**
|
|
2784
|
+
* @returns {string[]}
|
|
2785
|
+
*/
|
|
2786
|
+
getSelectedValues() {
|
|
2787
|
+
if (!this.isSelect) return [this.source.value].filter(Boolean);
|
|
2788
|
+
return this.#selectedOptionsInOrder().map((option) => option.value);
|
|
2789
|
+
}
|
|
2790
|
+
|
|
2791
|
+
/**
|
|
2792
|
+
* @returns {import("./helpers.js").ComboboxItem[]}
|
|
2793
|
+
*/
|
|
2794
|
+
getSelectedItems() {
|
|
2795
|
+
if (!this.isSelect)
|
|
2796
|
+
return [{ value: this.source.value, label: this.source.value }].filter((item) => item.value);
|
|
2797
|
+
return this.#selectedOptionsInOrder().map((option) => ({
|
|
2798
|
+
value: option.value,
|
|
2799
|
+
label: option.textContent.trim(),
|
|
2800
|
+
option,
|
|
2801
|
+
data: { ...option.dataset },
|
|
2802
|
+
}));
|
|
2803
|
+
}
|
|
2804
|
+
|
|
2805
|
+
/**
|
|
2806
|
+
* @param {string | HTMLOptionElement} itemOrValue
|
|
2807
|
+
* @param {number} index
|
|
2808
|
+
* @returns {boolean}
|
|
2809
|
+
*/
|
|
2810
|
+
move(itemOrValue, index) {
|
|
2811
|
+
if (!this.isMultiple || this.options.selectionOrder !== "selected") return false;
|
|
2812
|
+
// An exact option moves that identity (duplicate values stay distinct); a
|
|
2813
|
+
// bare value moves the first selected occurrence in the ordered model.
|
|
2814
|
+
const option =
|
|
2815
|
+
itemOrValue instanceof HTMLOptionElement
|
|
2816
|
+
? itemOrValue
|
|
2817
|
+
: this.selectionOrder.find((entry) => entry.value === String(itemOrValue));
|
|
2818
|
+
if (!option?.selected) return false;
|
|
2819
|
+
|
|
2820
|
+
const moved = moveValueInOrder(this.selectionOrder, option, index);
|
|
2821
|
+
if (!moved) return false;
|
|
2822
|
+
const { order: nextOrder, from, to } = moved;
|
|
2823
|
+
|
|
2824
|
+
const before = emit(
|
|
2825
|
+
this.source,
|
|
2826
|
+
"combobox:beforereorder",
|
|
2827
|
+
{ combobox: this, value: option.value, from, to },
|
|
2828
|
+
{ cancelable: true },
|
|
2829
|
+
);
|
|
2830
|
+
if (before.defaultPrevented) return false;
|
|
2831
|
+
this.selectionOrder = nextOrder;
|
|
2832
|
+
this.#renderChips();
|
|
2833
|
+
emit(this.source, "combobox:reorder", {
|
|
2834
|
+
combobox: this,
|
|
2835
|
+
value: option.value,
|
|
2836
|
+
from,
|
|
2837
|
+
to,
|
|
2838
|
+
values: this.getSelectedValues(),
|
|
2839
|
+
});
|
|
2840
|
+
return true;
|
|
2841
|
+
}
|
|
2842
|
+
|
|
2843
|
+
async loadMore() {
|
|
2844
|
+
if (!this.nextCursor || typeof this.options.load !== "function") return false;
|
|
2845
|
+
const cursor = this.nextCursor;
|
|
2846
|
+
await this.#load(this.query, { cursor, append: true, debounce: false });
|
|
2847
|
+
this.#applyFilter(this.query);
|
|
2848
|
+
return true;
|
|
2849
|
+
}
|
|
2850
|
+
|
|
2851
|
+
refresh() {
|
|
2852
|
+
if (this.mode !== "enhanced") return this;
|
|
2853
|
+
|
|
2854
|
+
if (this.isSelect) {
|
|
2855
|
+
this.#inputEl().disabled = this.source.disabled;
|
|
2856
|
+
this.#inputEl().readOnly = this.source.hasAttribute("readonly");
|
|
2857
|
+
for (const option of this.#selectSource().selectedOptions) this.#rememberSelection(option);
|
|
2858
|
+
if (this.source.required) this.#inputEl().setAttribute("aria-required", "true");
|
|
2859
|
+
else this.#inputEl().removeAttribute("aria-required");
|
|
2860
|
+
if (this.isMultiple) this.#renderChips();
|
|
2861
|
+
else this.#syncSingleLabel();
|
|
2862
|
+
}
|
|
2863
|
+
|
|
2864
|
+
this.#applyFilter(this.isSelect && !this.isMultiple ? "" : this.#inputEl().value);
|
|
2865
|
+
return this;
|
|
2866
|
+
}
|
|
2867
|
+
|
|
2868
|
+
#syncSingleLabel() {
|
|
2869
|
+
const selected = this.#selectSource().selectedOptions[0];
|
|
2870
|
+
this.#inputEl().value = selected?.value ? selected.textContent.trim() : "";
|
|
2871
|
+
}
|
|
2872
|
+
|
|
2873
|
+
show() {
|
|
2874
|
+
if (this.mode !== "enhanced" || this.isOpen()) return false;
|
|
2875
|
+
if (openCombobox && openCombobox !== this) {
|
|
2876
|
+
openCombobox.hide();
|
|
2877
|
+
if (openCombobox?.isOpen()) return false;
|
|
2878
|
+
}
|
|
2879
|
+
const before = emit(this.source, "combobox:beforeopen", { combobox: this }, { cancelable: true });
|
|
2880
|
+
if (before.defaultPrevented) return false;
|
|
2881
|
+
|
|
2882
|
+
try {
|
|
2883
|
+
this.#popoverEl().showPopover({ source: this.#inputEl() });
|
|
2884
|
+
} catch {
|
|
2885
|
+
this.#popoverEl().showPopover();
|
|
2886
|
+
}
|
|
2887
|
+
openCombobox = this;
|
|
2888
|
+
return true;
|
|
2889
|
+
}
|
|
2890
|
+
|
|
2891
|
+
hide() {
|
|
2892
|
+
if (this.mode !== "enhanced" || !this.isOpen()) return false;
|
|
2893
|
+
const before = emit(this.source, "combobox:beforeclose", { combobox: this }, { cancelable: true });
|
|
2894
|
+
if (before.defaultPrevented) return false;
|
|
2895
|
+
this.#popoverEl().hidePopover();
|
|
2896
|
+
if (openCombobox === this) openCombobox = null;
|
|
2897
|
+
return true;
|
|
2898
|
+
}
|
|
2899
|
+
|
|
2900
|
+
isOpen() {
|
|
2901
|
+
return this.mode === "enhanced" && this.#popoverEl().matches(":popover-open");
|
|
2902
|
+
}
|
|
2903
|
+
|
|
2904
|
+
dispose() {
|
|
2905
|
+
instances.delete(this.source);
|
|
2906
|
+
this.loadController?.abort();
|
|
2907
|
+
this.abortController.abort();
|
|
2908
|
+
this._sourceObserver?.disconnect();
|
|
2909
|
+
this._sourceObserver = null;
|
|
2910
|
+
if (this._sourceSyncTimer) {
|
|
2911
|
+
clearTimeout(this._sourceSyncTimer);
|
|
2912
|
+
this._sourceSyncTimer = null;
|
|
2913
|
+
}
|
|
2914
|
+
|
|
2915
|
+
if (this.mode === "fallback") {
|
|
2916
|
+
this.fallbackControl?.remove();
|
|
2917
|
+
return;
|
|
2918
|
+
}
|
|
2919
|
+
|
|
2920
|
+
if (openCombobox === this) openCombobox = null;
|
|
2921
|
+
this.#popoverEl()?.remove();
|
|
2922
|
+
this.anchorSnapshot?.restore();
|
|
2923
|
+
|
|
2924
|
+
// Cleanup only touches real source options. An init that failed before the
|
|
2925
|
+
// datalist resolved (input without a valid `list`) never produced mirror
|
|
2926
|
+
// state, and #sourceItems would crash on the null datalist.
|
|
2927
|
+
if (this.isSelect || this.datalist instanceof HTMLDataListElement) {
|
|
2928
|
+
for (const item of this.#sourceItems()) {
|
|
2929
|
+
item.option?.removeAttribute("data-filtered");
|
|
2930
|
+
item.option?.removeAttribute("data-active-option");
|
|
2931
|
+
}
|
|
2932
|
+
}
|
|
2933
|
+
|
|
2934
|
+
// <label> ids invented by #copyAccessibleName are stripped again unless the
|
|
2935
|
+
// application has since reused them.
|
|
2936
|
+
for (const { label, id } of this.original.inventedLabels) {
|
|
2937
|
+
if (label.id === id) label.removeAttribute("id");
|
|
2938
|
+
}
|
|
2939
|
+
|
|
2940
|
+
// Restore every attribute the engine touched on elements it does not own.
|
|
2941
|
+
if (this.isSelect) {
|
|
2942
|
+
this.control?.remove();
|
|
2943
|
+
this.source.classList.remove("cb-source-hidden");
|
|
2944
|
+
this.sourceSnapshot?.restore();
|
|
2945
|
+
|
|
2946
|
+
// A placeholder that was consumed by a previous dispose() has no parent.
|
|
2947
|
+
// Restoring must also work when the whole wrapper subtree is detached.
|
|
2948
|
+
if (!this.ownsInput && this.#inputEl() && this.original.filterInputPlaceholder?.parentNode) {
|
|
2949
|
+
this.original.filterInputPlaceholder.replaceWith(this.#inputEl());
|
|
2950
|
+
}
|
|
2951
|
+
this.#inputEl()?.classList.remove("cb-input");
|
|
2952
|
+
this.inputSnapshot?.restore();
|
|
2953
|
+
} else {
|
|
2954
|
+
this.source.classList.remove("cb-text-control");
|
|
2955
|
+
// A placeholder that was consumed by a previous dispose() has no parent.
|
|
2956
|
+
// Restoring must also work when the whole wrapper subtree is detached.
|
|
2957
|
+
const datalist = this.datalist;
|
|
2958
|
+
if (datalist && this.original.datalistPlaceholder?.parentNode) {
|
|
2959
|
+
this.original.datalistPlaceholder.replaceWith(datalist);
|
|
2960
|
+
}
|
|
2961
|
+
this.inputSnapshot?.restore();
|
|
2962
|
+
}
|
|
2963
|
+
}
|
|
2964
|
+
}
|
|
2965
|
+
|
|
2966
|
+
// TODO / platform migration notes:
|
|
2967
|
+
// - When Open UI's native `search`, `beforefilter`, `:filtered`, and
|
|
2968
|
+
// `:active-option` primitives ship broadly, the matching state above can be
|
|
2969
|
+
// progressively delegated to the browser without changing the public API.
|
|
2970
|
+
// - When filterable-select settles on a declarative input/select relationship,
|
|
2971
|
+
// #resolveFilterInput() is the adapter boundary to map to it.
|
|
2972
|
+
// - If native customizable select multiple becomes interoperable, chips can
|
|
2973
|
+
// remain script-owned while the picker/listbox implementation shrinks.
|
|
2974
|
+
|
|
2975
|
+
export default Combobox;
|