forge-select 0.3.0 → 0.5.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/README.md +24 -15
- package/dist/index.cjs +667 -63
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +178 -9
- package/dist/index.d.ts +178 -9
- package/dist/index.global.js +1 -1
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +667 -63
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/styles/forge-select.css +37 -1
package/dist/index.cjs
CHANGED
|
@@ -51,6 +51,17 @@ var Emitter = class {
|
|
|
51
51
|
}
|
|
52
52
|
};
|
|
53
53
|
|
|
54
|
+
// src/dropdown-position.ts
|
|
55
|
+
function computeDropdownPlacement(controlRect, dropdownHeight, viewportHeight, gap = 4) {
|
|
56
|
+
const spaceBelow = viewportHeight - controlRect.bottom;
|
|
57
|
+
const spaceAbove = controlRect.top;
|
|
58
|
+
const dropUp = dropdownHeight > spaceBelow && spaceAbove > spaceBelow;
|
|
59
|
+
return {
|
|
60
|
+
dropUp,
|
|
61
|
+
top: dropUp ? controlRect.top - dropdownHeight - gap : controlRect.bottom + gap
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
54
65
|
// src/i18n.ts
|
|
55
66
|
var locales = {
|
|
56
67
|
en: {
|
|
@@ -62,7 +73,9 @@ var locales = {
|
|
|
62
73
|
clearSelection: "Clear selection",
|
|
63
74
|
removeItem: "Remove {label}",
|
|
64
75
|
search: "Search",
|
|
65
|
-
reorderHint: "{label}. Press Alt+Left or Alt+Right to reorder."
|
|
76
|
+
reorderHint: "{label}. Press Alt+Left or Alt+Right to reorder.",
|
|
77
|
+
minSearchLength: "Type {count} or more characters to search",
|
|
78
|
+
maximumSelected: "Maximum of {count} selections reached"
|
|
66
79
|
},
|
|
67
80
|
vi: {
|
|
68
81
|
noResults: "Kh\xF4ng t\xECm th\u1EA5y k\u1EBFt qu\u1EA3",
|
|
@@ -73,7 +86,9 @@ var locales = {
|
|
|
73
86
|
clearSelection: "X\xF3a l\u1EF1a ch\u1ECDn",
|
|
74
87
|
removeItem: "X\xF3a {label}",
|
|
75
88
|
search: "T\xECm ki\u1EBFm",
|
|
76
|
-
reorderHint: "{label}. Nh\u1EA5n Alt+Tr\xE1i ho\u1EB7c Alt+Ph\u1EA3i \u0111\u1EC3 s\u1EAFp x\u1EBFp l\u1EA1i."
|
|
89
|
+
reorderHint: "{label}. Nh\u1EA5n Alt+Tr\xE1i ho\u1EB7c Alt+Ph\u1EA3i \u0111\u1EC3 s\u1EAFp x\u1EBFp l\u1EA1i.",
|
|
90
|
+
minSearchLength: "Nh\u1EADp th\xEAm {count} k\xFD t\u1EF1 \u0111\u1EC3 t\xECm ki\u1EBFm",
|
|
91
|
+
maximumSelected: "\u0110\xE3 \u0111\u1EA1t t\u1ED1i \u0111a {count} l\u1EF1a ch\u1ECDn"
|
|
77
92
|
}
|
|
78
93
|
};
|
|
79
94
|
function getStrings(language) {
|
|
@@ -149,6 +164,7 @@ function renderOptionContent(container, option, template, variant = "row") {
|
|
|
149
164
|
|
|
150
165
|
// src/remote.ts
|
|
151
166
|
function buildUrl(ajax, query, page) {
|
|
167
|
+
if (!ajax.url) throw new Error("ForgeSelect: ajax requires either url or request.");
|
|
152
168
|
if (typeof ajax.url === "function") return ajax.url(query, page);
|
|
153
169
|
if (!ajax.params) return ajax.url;
|
|
154
170
|
const params = new URLSearchParams();
|
|
@@ -167,22 +183,106 @@ function normalizeRemoteResult(ajax, response) {
|
|
|
167
183
|
return { options: result.options, hasMore: ajax.pagination ? Boolean(result.hasMore) : false };
|
|
168
184
|
}
|
|
169
185
|
|
|
186
|
+
// src/remote-cache.ts
|
|
187
|
+
var RemoteCache = class {
|
|
188
|
+
constructor() {
|
|
189
|
+
this.entries = /* @__PURE__ */ new Map();
|
|
190
|
+
}
|
|
191
|
+
get(key, now = Date.now()) {
|
|
192
|
+
const entry = this.entries.get(key);
|
|
193
|
+
if (!entry) return void 0;
|
|
194
|
+
if (entry.expiresAt <= now) {
|
|
195
|
+
this.entries.delete(key);
|
|
196
|
+
return void 0;
|
|
197
|
+
}
|
|
198
|
+
return entry.value;
|
|
199
|
+
}
|
|
200
|
+
set(key, value, ttl, now = Date.now()) {
|
|
201
|
+
if (ttl > 0) this.entries.set(key, { value, expiresAt: now + ttl });
|
|
202
|
+
}
|
|
203
|
+
clear() {
|
|
204
|
+
this.entries.clear();
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
// src/search.ts
|
|
209
|
+
function normalizeSearchText(value, accentInsensitive = true) {
|
|
210
|
+
const lower = value.toLocaleLowerCase();
|
|
211
|
+
return accentInsensitive ? lower.normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/đ/g, "d") : lower;
|
|
212
|
+
}
|
|
213
|
+
function getSearchField(option, field) {
|
|
214
|
+
if (field === "label") return option.label;
|
|
215
|
+
if (field === "description") return option.description ?? "";
|
|
216
|
+
const path = field.slice(5).split(".");
|
|
217
|
+
let value = option.meta;
|
|
218
|
+
for (const key of path) {
|
|
219
|
+
if (!value || typeof value !== "object") return "";
|
|
220
|
+
value = value[key];
|
|
221
|
+
}
|
|
222
|
+
return value == null ? "" : String(value);
|
|
223
|
+
}
|
|
224
|
+
var SearchIndex = class {
|
|
225
|
+
constructor() {
|
|
226
|
+
this.cache = /* @__PURE__ */ new WeakMap();
|
|
227
|
+
}
|
|
228
|
+
clear() {
|
|
229
|
+
this.cache = /* @__PURE__ */ new WeakMap();
|
|
230
|
+
}
|
|
231
|
+
score(option, query, config) {
|
|
232
|
+
const normalizedQuery = normalizeSearchText(query.trim(), config.accentInsensitive);
|
|
233
|
+
if (!normalizedQuery) return 1;
|
|
234
|
+
if (config.scorer) return config.scorer(option, query.trim(), normalizedQuery);
|
|
235
|
+
const key = `${config.accentInsensitive ? "1" : "0"}:${config.fields.join("\0")}`;
|
|
236
|
+
let variants = this.cache.get(option);
|
|
237
|
+
if (!variants) {
|
|
238
|
+
variants = /* @__PURE__ */ new Map();
|
|
239
|
+
this.cache.set(option, variants);
|
|
240
|
+
}
|
|
241
|
+
let haystacks = variants.get(key);
|
|
242
|
+
if (!haystacks) {
|
|
243
|
+
haystacks = config.fields.map(
|
|
244
|
+
(field) => normalizeSearchText(getSearchField(option, field), config.accentInsensitive)
|
|
245
|
+
);
|
|
246
|
+
variants.set(key, haystacks);
|
|
247
|
+
}
|
|
248
|
+
const tokens = config.tokenSearch ? normalizedQuery.split(/\s+/).filter(Boolean) : [normalizedQuery];
|
|
249
|
+
if (!tokens.every((token) => haystacks.some((field) => field.includes(token)))) return 0;
|
|
250
|
+
const label = haystacks[config.fields.indexOf("label")];
|
|
251
|
+
if (label === normalizedQuery) return 4;
|
|
252
|
+
if (label?.startsWith(normalizedQuery)) return 3;
|
|
253
|
+
if (label?.includes(normalizedQuery)) return 2;
|
|
254
|
+
return 1;
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
function findNormalizedRanges(label, query, accentInsensitive = true) {
|
|
258
|
+
const tokens = normalizeSearchText(query.trim(), accentInsensitive).split(/\s+/).filter(Boolean);
|
|
259
|
+
if (!tokens.length) return [];
|
|
260
|
+
const normalized = normalizeSearchText(label, accentInsensitive);
|
|
261
|
+
const ranges = [];
|
|
262
|
+
for (const token of tokens) {
|
|
263
|
+
const index = normalized.indexOf(token);
|
|
264
|
+
if (index >= 0) ranges.push([index, index + token.length]);
|
|
265
|
+
}
|
|
266
|
+
return ranges.sort((a, b) => a[0] - b[0]);
|
|
267
|
+
}
|
|
268
|
+
|
|
170
269
|
// src/selection.ts
|
|
171
270
|
function isGroup(item) {
|
|
172
271
|
return item.options !== void 0;
|
|
173
272
|
}
|
|
174
|
-
|
|
273
|
+
var defaultIsDisabled = (option) => !!option.disabled;
|
|
274
|
+
function collectDescendantValues(option, isDisabled = defaultIsDisabled) {
|
|
175
275
|
if (!option.children) return [];
|
|
176
276
|
const values = [];
|
|
177
277
|
for (const child of option.children) {
|
|
178
|
-
if (!child
|
|
179
|
-
values.push(...collectDescendantValues(child));
|
|
278
|
+
if (!isDisabled(child)) values.push(child.value);
|
|
279
|
+
values.push(...collectDescendantValues(child, isDisabled));
|
|
180
280
|
}
|
|
181
281
|
return values;
|
|
182
282
|
}
|
|
183
|
-
function computeCheckState(option, selected) {
|
|
283
|
+
function computeCheckState(option, selected, isDisabled = defaultIsDisabled) {
|
|
184
284
|
if (!option.children?.length) return selected.includes(option.value) ? "all" : "none";
|
|
185
|
-
const states = option.children.filter((child) => !child
|
|
285
|
+
const states = option.children.filter((child) => !isDisabled(child)).map((child) => computeCheckState(child, selected, isDisabled));
|
|
186
286
|
if (states.length === 0) return "none";
|
|
187
287
|
if (states.every((state) => state === "all")) return "all";
|
|
188
288
|
if (states.every((state) => state === "none")) return "none";
|
|
@@ -203,11 +303,11 @@ function findOption(items, value) {
|
|
|
203
303
|
}
|
|
204
304
|
return void 0;
|
|
205
305
|
}
|
|
206
|
-
function syncTreeAncestors(items, selected) {
|
|
306
|
+
function syncTreeAncestors(items, selected, isDisabled = defaultIsDisabled) {
|
|
207
307
|
const sync = (option) => {
|
|
208
308
|
if (!option.children?.length) return;
|
|
209
309
|
for (const child of option.children) sync(child);
|
|
210
|
-
const state = computeCheckState(option, selected);
|
|
310
|
+
const state = computeCheckState(option, selected, isDisabled);
|
|
211
311
|
const index = selected.indexOf(option.value);
|
|
212
312
|
if (state === "all" && index === -1) selected.push(option.value);
|
|
213
313
|
else if (state !== "all" && index !== -1) selected.splice(index, 1);
|
|
@@ -241,6 +341,7 @@ var ForgeSelect = class {
|
|
|
241
341
|
this.emitter = new Emitter();
|
|
242
342
|
this.uid = `forge-select-${++uidCounter}`;
|
|
243
343
|
this.searchInput = null;
|
|
344
|
+
this.portalHost = null;
|
|
244
345
|
this.isOpen = false;
|
|
245
346
|
this.isDisabled = false;
|
|
246
347
|
this.destroyed = false;
|
|
@@ -249,6 +350,8 @@ var ForgeSelect = class {
|
|
|
249
350
|
this.navItems = [];
|
|
250
351
|
this.highlightedIndex = -1;
|
|
251
352
|
this.rowContentCache = /* @__PURE__ */ new Map();
|
|
353
|
+
this.rowHeightCache = /* @__PURE__ */ new Map();
|
|
354
|
+
this.searchIndex = new SearchIndex();
|
|
252
355
|
this.expandedValues = /* @__PURE__ */ new Set();
|
|
253
356
|
this.loading = false;
|
|
254
357
|
this.loadingMore = false;
|
|
@@ -258,14 +361,33 @@ var ForgeSelect = class {
|
|
|
258
361
|
this.ajaxRequestId = 0;
|
|
259
362
|
this.ajaxController = null;
|
|
260
363
|
this.remoteLoaded = false;
|
|
364
|
+
this.remoteCache = new RemoteCache();
|
|
261
365
|
this.loadError = null;
|
|
262
366
|
this.originalDisplay = "";
|
|
263
367
|
this.originalDisabled = false;
|
|
264
368
|
this.nativeSelect = null;
|
|
265
369
|
this.nativeForm = null;
|
|
266
370
|
this.syncingNative = false;
|
|
371
|
+
/** Combines the static `disabled` field with the dynamic `isOptionDisabled` callback. */
|
|
372
|
+
this.isOptionDisabled = (option) => option.disabled === true || (this.opts.isOptionDisabled?.(option) ?? false);
|
|
373
|
+
this.pointerDownOnControl = false;
|
|
267
374
|
this.onDocumentMouseDown = (event) => {
|
|
268
|
-
|
|
375
|
+
const target = event.target;
|
|
376
|
+
if (!this.root.contains(target) && !this.portalHost?.contains(target)) this.close();
|
|
377
|
+
};
|
|
378
|
+
this.onWindowResize = () => {
|
|
379
|
+
this.positionDropdown();
|
|
380
|
+
};
|
|
381
|
+
this.onAncestorScroll = () => {
|
|
382
|
+
if (this.portalHost) this.positionDropdown();
|
|
383
|
+
};
|
|
384
|
+
this.onNativeInvalid = (event) => {
|
|
385
|
+
event.preventDefault();
|
|
386
|
+
this.control.classList.add("forge-select__control--invalid");
|
|
387
|
+
this.control.setAttribute("aria-invalid", "true");
|
|
388
|
+
if (!this.isOpen) this.open();
|
|
389
|
+
this.control.focus();
|
|
390
|
+
this.emitter.emit("invalid", this.nativeSelect?.validationMessage ?? "");
|
|
269
391
|
};
|
|
270
392
|
this.onNativeChange = () => {
|
|
271
393
|
if (!this.nativeSelect || this.destroyed || this.syncingNative) return;
|
|
@@ -294,19 +416,35 @@ var ForgeSelect = class {
|
|
|
294
416
|
clearable: options.clearable ?? false,
|
|
295
417
|
allowCreate: options.allowCreate ?? false,
|
|
296
418
|
sortable: options.sortable ?? false,
|
|
419
|
+
closeOnSelect: options.closeOnSelect ?? false,
|
|
420
|
+
maxSelections: options.maxSelections == null || !Number.isFinite(options.maxSelections) ? void 0 : Math.max(0, Math.floor(options.maxSelections)),
|
|
297
421
|
theme: options.theme ?? "default",
|
|
298
422
|
disabled: options.disabled ?? nativeSelect?.disabled ?? false,
|
|
423
|
+
required: options.required ?? nativeSelect?.required ?? false,
|
|
299
424
|
data: options.data,
|
|
300
425
|
ajax: options.ajax,
|
|
301
426
|
templateResult: options.templateResult,
|
|
302
427
|
templateSelection: options.templateSelection,
|
|
428
|
+
filterOption: options.filterOption,
|
|
429
|
+
searchFields: options.searchFields ?? ["label", "description"],
|
|
430
|
+
tokenSearch: options.tokenSearch ?? true,
|
|
431
|
+
accentInsensitive: options.accentInsensitive ?? true,
|
|
432
|
+
searchScorer: options.searchScorer,
|
|
433
|
+
highlightSearch: options.highlightSearch ?? false,
|
|
434
|
+
minSearchLength: Math.max(0, Math.floor(options.minSearchLength ?? 0)),
|
|
435
|
+
minResultsForSearch: Math.max(0, Math.floor(options.minResultsForSearch ?? 0)),
|
|
436
|
+
isOptionDisabled: options.isOptionDisabled,
|
|
303
437
|
virtualScroll: options.virtualScroll,
|
|
304
|
-
itemHeight: options.itemHeight
|
|
438
|
+
itemHeight: typeof options.itemHeight === "number" ? Math.max(1, options.itemHeight) : DEFAULT_ITEM_HEIGHT,
|
|
439
|
+
variableItemHeight: options.itemHeight === "auto",
|
|
305
440
|
language: options.language ?? "en",
|
|
306
|
-
plugins: options.plugins ?? []
|
|
441
|
+
plugins: options.plugins ?? [],
|
|
442
|
+
openOnFocus: options.openOnFocus ?? false,
|
|
443
|
+
dropdownParent: options.dropdownParent
|
|
307
444
|
};
|
|
308
445
|
this.strings = getStrings(this.opts.language);
|
|
309
446
|
this.plugins = this.opts.plugins;
|
|
447
|
+
if (nativeSelect) nativeSelect.required = this.opts.required;
|
|
310
448
|
this.data = this.opts.data ?? (nativeSelect ? parseNativeOptions(nativeSelect) : []);
|
|
311
449
|
if (nativeSelect && !this.opts.data) {
|
|
312
450
|
const nativeOptions = Array.from(nativeSelect.options);
|
|
@@ -319,8 +457,10 @@ var ForgeSelect = class {
|
|
|
319
457
|
this.renderValue();
|
|
320
458
|
if (this.opts.disabled) this.disable();
|
|
321
459
|
nativeSelect?.addEventListener("change", this.onNativeChange);
|
|
460
|
+
nativeSelect?.addEventListener("invalid", this.onNativeInvalid);
|
|
322
461
|
this.nativeForm?.addEventListener("reset", this.onFormReset);
|
|
323
462
|
for (const plugin of this.plugins) plugin.onInit?.(this);
|
|
463
|
+
for (const query of this.opts.ajax?.prefetch ?? []) void this.prefetchRemote(query);
|
|
324
464
|
}
|
|
325
465
|
applyNativeValues(values) {
|
|
326
466
|
this.selected = [];
|
|
@@ -337,11 +477,14 @@ var ForgeSelect = class {
|
|
|
337
477
|
this.root.classList.add("forge-select--open");
|
|
338
478
|
this.control.setAttribute("aria-expanded", "true");
|
|
339
479
|
document.addEventListener("mousedown", this.onDocumentMouseDown);
|
|
340
|
-
if (this.opts.ajax && !this.remoteLoaded) {
|
|
480
|
+
if (this.opts.ajax && (this.opts.ajax.loadOnOpen ?? true) && !this.remoteLoaded) {
|
|
341
481
|
this.scheduleRemoteLoad(this.query, 0);
|
|
342
482
|
}
|
|
343
483
|
this.renderList();
|
|
344
|
-
|
|
484
|
+
this.positionDropdown();
|
|
485
|
+
window.addEventListener("resize", this.onWindowResize);
|
|
486
|
+
document.addEventListener("scroll", this.onAncestorScroll, true);
|
|
487
|
+
if (this.searchInput && !this.searchInput.hidden) this.searchInput.focus();
|
|
345
488
|
this.emitter.emit("open");
|
|
346
489
|
for (const plugin of this.plugins) plugin.onOpen?.(this);
|
|
347
490
|
}
|
|
@@ -350,8 +493,11 @@ var ForgeSelect = class {
|
|
|
350
493
|
this.isOpen = false;
|
|
351
494
|
this.dropdown.hidden = true;
|
|
352
495
|
this.root.classList.remove("forge-select--open");
|
|
496
|
+
this.root.classList.remove("forge-select--drop-up");
|
|
353
497
|
this.control.setAttribute("aria-expanded", "false");
|
|
354
498
|
document.removeEventListener("mousedown", this.onDocumentMouseDown);
|
|
499
|
+
window.removeEventListener("resize", this.onWindowResize);
|
|
500
|
+
document.removeEventListener("scroll", this.onAncestorScroll, true);
|
|
355
501
|
this.highlightedIndex = -1;
|
|
356
502
|
if (this.searchInput) {
|
|
357
503
|
this.searchInput.value = "";
|
|
@@ -360,6 +506,23 @@ var ForgeSelect = class {
|
|
|
360
506
|
this.emitter.emit("close");
|
|
361
507
|
for (const plugin of this.plugins) plugin.onClose?.(this);
|
|
362
508
|
}
|
|
509
|
+
/**
|
|
510
|
+
* Flips the dropdown above the control when there isn't enough room below
|
|
511
|
+
* but there is above. Recomputed on open() and on window resize — the
|
|
512
|
+
* dropdown is positioned absolutely inside the relatively-positioned root,
|
|
513
|
+
* so it already tracks the control correctly on page scroll without
|
|
514
|
+
* needing a scroll listener.
|
|
515
|
+
*/
|
|
516
|
+
positionDropdown() {
|
|
517
|
+
const controlRect = this.control.getBoundingClientRect();
|
|
518
|
+
const placement = computeDropdownPlacement(controlRect, this.dropdown.offsetHeight, window.innerHeight);
|
|
519
|
+
this.root.classList.toggle("forge-select--drop-up", placement.dropUp);
|
|
520
|
+
if (this.portalHost) {
|
|
521
|
+
this.portalHost.style.top = `${placement.top}px`;
|
|
522
|
+
this.portalHost.style.left = `${controlRect.left}px`;
|
|
523
|
+
this.portalHost.style.width = `${controlRect.width}px`;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
363
526
|
destroy() {
|
|
364
527
|
if (this.destroyed) return;
|
|
365
528
|
this.close();
|
|
@@ -368,8 +531,12 @@ var ForgeSelect = class {
|
|
|
368
531
|
if (this.ajaxTimer) clearTimeout(this.ajaxTimer);
|
|
369
532
|
this.ajaxController?.abort();
|
|
370
533
|
this.nativeSelect?.removeEventListener("change", this.onNativeChange);
|
|
534
|
+
this.nativeSelect?.removeEventListener("invalid", this.onNativeInvalid);
|
|
371
535
|
this.nativeForm?.removeEventListener("reset", this.onFormReset);
|
|
372
536
|
this.rowContentCache.clear();
|
|
537
|
+
this.rowHeightCache.clear();
|
|
538
|
+
this.searchIndex.clear();
|
|
539
|
+
this.portalHost?.remove();
|
|
373
540
|
this.root.remove();
|
|
374
541
|
this.el.style.display = this.originalDisplay;
|
|
375
542
|
if (this.nativeSelect) this.nativeSelect.disabled = this.originalDisabled;
|
|
@@ -379,6 +546,106 @@ var ForgeSelect = class {
|
|
|
379
546
|
if (this.opts.multiple) return [...this.selected];
|
|
380
547
|
return this.selected[0] ?? null;
|
|
381
548
|
}
|
|
549
|
+
getSearchQuery() {
|
|
550
|
+
return this.query;
|
|
551
|
+
}
|
|
552
|
+
setSearchQuery(query, options = {}) {
|
|
553
|
+
this.applySearchQuery(query, options.emitSearch ?? true);
|
|
554
|
+
}
|
|
555
|
+
isDropdownOpen() {
|
|
556
|
+
return this.isOpen;
|
|
557
|
+
}
|
|
558
|
+
updateOptions(options) {
|
|
559
|
+
if (options.data) this.setData(options.data);
|
|
560
|
+
if ("ajax" in options && options.ajax !== this.opts.ajax) {
|
|
561
|
+
this.opts.ajax = options.ajax;
|
|
562
|
+
this.remoteLoaded = false;
|
|
563
|
+
this.clearRemoteCache();
|
|
564
|
+
}
|
|
565
|
+
if (options.placeholder !== void 0) this.opts.placeholder = options.placeholder;
|
|
566
|
+
if (options.clearable !== void 0) this.opts.clearable = options.clearable;
|
|
567
|
+
if (options.allowCreate !== void 0) this.opts.allowCreate = options.allowCreate;
|
|
568
|
+
if (options.sortable !== void 0) this.opts.sortable = options.sortable;
|
|
569
|
+
if (options.closeOnSelect !== void 0) this.opts.closeOnSelect = options.closeOnSelect;
|
|
570
|
+
if ("maxSelections" in options)
|
|
571
|
+
this.opts.maxSelections = options.maxSelections == null || !Number.isFinite(options.maxSelections) ? void 0 : Math.max(0, Math.floor(options.maxSelections));
|
|
572
|
+
if (options.theme !== void 0) {
|
|
573
|
+
this.opts.theme = options.theme;
|
|
574
|
+
this.root.dataset.theme = options.theme;
|
|
575
|
+
if (this.portalHost) this.portalHost.dataset.theme = options.theme;
|
|
576
|
+
}
|
|
577
|
+
if (options.required !== void 0) {
|
|
578
|
+
this.opts.required = options.required;
|
|
579
|
+
if (options.required) this.control.setAttribute("aria-required", "true");
|
|
580
|
+
else this.control.removeAttribute("aria-required");
|
|
581
|
+
if (this.nativeSelect) this.nativeSelect.required = options.required;
|
|
582
|
+
}
|
|
583
|
+
if (options.templateResult !== void 0) this.opts.templateResult = options.templateResult;
|
|
584
|
+
if (options.templateSelection !== void 0) this.opts.templateSelection = options.templateSelection;
|
|
585
|
+
if (options.filterOption !== void 0) this.opts.filterOption = options.filterOption;
|
|
586
|
+
if (options.searchFields !== void 0) this.opts.searchFields = options.searchFields;
|
|
587
|
+
if (options.tokenSearch !== void 0) this.opts.tokenSearch = options.tokenSearch;
|
|
588
|
+
if (options.accentInsensitive !== void 0) this.opts.accentInsensitive = options.accentInsensitive;
|
|
589
|
+
if (options.searchScorer !== void 0) this.opts.searchScorer = options.searchScorer;
|
|
590
|
+
if (options.highlightSearch !== void 0) this.opts.highlightSearch = options.highlightSearch;
|
|
591
|
+
if (options.minSearchLength !== void 0)
|
|
592
|
+
this.opts.minSearchLength = Math.max(0, Math.floor(options.minSearchLength));
|
|
593
|
+
if (options.minResultsForSearch !== void 0)
|
|
594
|
+
this.opts.minResultsForSearch = Math.max(0, Math.floor(options.minResultsForSearch));
|
|
595
|
+
if (options.isOptionDisabled !== void 0) this.opts.isOptionDisabled = options.isOptionDisabled;
|
|
596
|
+
if (options.virtualScroll !== void 0) this.opts.virtualScroll = options.virtualScroll;
|
|
597
|
+
if (options.itemHeight !== void 0) {
|
|
598
|
+
this.opts.variableItemHeight = options.itemHeight === "auto";
|
|
599
|
+
if (typeof options.itemHeight === "number") this.opts.itemHeight = Math.max(1, options.itemHeight);
|
|
600
|
+
this.root.style.setProperty("--fs-item-height", `${this.opts.itemHeight}px`);
|
|
601
|
+
this.portalHost?.style.setProperty("--fs-item-height", `${this.opts.itemHeight}px`);
|
|
602
|
+
}
|
|
603
|
+
if (options.language !== void 0) {
|
|
604
|
+
this.opts.language = options.language;
|
|
605
|
+
this.strings = getStrings(options.language);
|
|
606
|
+
this.clearBtn.setAttribute("aria-label", this.strings.clearSelection);
|
|
607
|
+
this.searchInput?.setAttribute("aria-label", this.strings.search);
|
|
608
|
+
}
|
|
609
|
+
if (options.openOnFocus !== void 0) this.opts.openOnFocus = options.openOnFocus;
|
|
610
|
+
if (options.disabled !== void 0) {
|
|
611
|
+
if (options.disabled) this.disable();
|
|
612
|
+
else this.enable();
|
|
613
|
+
}
|
|
614
|
+
this.root.classList.toggle("forge-select--sortable", this.opts.sortable && this.opts.multiple);
|
|
615
|
+
this.updateSearchVisibility();
|
|
616
|
+
this.rowContentCache.clear();
|
|
617
|
+
this.searchIndex.clear();
|
|
618
|
+
this.renderValue();
|
|
619
|
+
if (this.isOpen) this.renderList();
|
|
620
|
+
}
|
|
621
|
+
validate() {
|
|
622
|
+
const valid = (!this.opts.required || this.selected.length > 0) && (this.control.dataset.validationMessage ?? "") === "";
|
|
623
|
+
this.control.classList.toggle("forge-select__control--invalid", !valid);
|
|
624
|
+
this.control.setAttribute("aria-invalid", String(!valid));
|
|
625
|
+
return valid;
|
|
626
|
+
}
|
|
627
|
+
setCustomValidity(message) {
|
|
628
|
+
this.nativeSelect?.setCustomValidity(message);
|
|
629
|
+
this.control.dataset.validationMessage = message;
|
|
630
|
+
}
|
|
631
|
+
reportValidity() {
|
|
632
|
+
const valid = this.validate() && (this.nativeSelect?.checkValidity() ?? true);
|
|
633
|
+
if (!valid) {
|
|
634
|
+
const message = this.nativeSelect?.validationMessage ?? this.control.dataset.validationMessage ?? "";
|
|
635
|
+
if (this.nativeSelect) return this.nativeSelect.reportValidity();
|
|
636
|
+
this.emitter.emit("invalid", message);
|
|
637
|
+
}
|
|
638
|
+
return valid;
|
|
639
|
+
}
|
|
640
|
+
reload() {
|
|
641
|
+
if (!this.opts.ajax) return;
|
|
642
|
+
this.clearRemoteCache();
|
|
643
|
+
this.remoteLoaded = false;
|
|
644
|
+
this.scheduleRemoteLoad(this.query, 0);
|
|
645
|
+
}
|
|
646
|
+
clearRemoteCache() {
|
|
647
|
+
this.remoteCache.clear();
|
|
648
|
+
}
|
|
382
649
|
setValue(value, options = {}) {
|
|
383
650
|
const values = value == null ? [] : Array.isArray(value) ? value : [value];
|
|
384
651
|
const next = this.opts.multiple ? values : values.slice(0, 1);
|
|
@@ -387,6 +654,53 @@ var ForgeSelect = class {
|
|
|
387
654
|
for (const v of next) this.selectValue(v, false);
|
|
388
655
|
this.afterSelectionChange(options.emitChange ?? true);
|
|
389
656
|
}
|
|
657
|
+
/**
|
|
658
|
+
* Replaces the option list after construction. An open dropdown re-renders
|
|
659
|
+
* immediately; a selection whose value isn't in the new data stays
|
|
660
|
+
* selected (rendered via the already-selected option's own label/avatar,
|
|
661
|
+
* the same fallback used for values selected from a stale ajax page).
|
|
662
|
+
*/
|
|
663
|
+
setData(data) {
|
|
664
|
+
if (this.ajaxTimer) {
|
|
665
|
+
clearTimeout(this.ajaxTimer);
|
|
666
|
+
this.ajaxTimer = null;
|
|
667
|
+
}
|
|
668
|
+
this.ajaxController?.abort();
|
|
669
|
+
this.ajaxController = null;
|
|
670
|
+
this.ajaxRequestId += 1;
|
|
671
|
+
this.setLoading(false);
|
|
672
|
+
this.loadingMore = false;
|
|
673
|
+
this.loadError = null;
|
|
674
|
+
this.remoteLoaded = true;
|
|
675
|
+
this.page = 0;
|
|
676
|
+
this.hasMore = false;
|
|
677
|
+
this.data = data;
|
|
678
|
+
this.opts.data = data;
|
|
679
|
+
this.updateSearchVisibility();
|
|
680
|
+
this.rowContentCache.clear();
|
|
681
|
+
this.searchIndex.clear();
|
|
682
|
+
this.highlightedIndex = -1;
|
|
683
|
+
if (this.isOpen) this.renderList();
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* Multi-select only: selects every currently non-disabled option, including
|
|
687
|
+
* nested tree descendants and options inside groups. If `maxSelections` is
|
|
688
|
+
* set, stops once the cap is reached rather than exceeding it. A no-op for
|
|
689
|
+
* single-select.
|
|
690
|
+
*/
|
|
691
|
+
selectAll() {
|
|
692
|
+
if (!this.opts.multiple) return;
|
|
693
|
+
this.selected = [];
|
|
694
|
+
for (const value of this.allSelectableValues()) {
|
|
695
|
+
const option = this.findOption(value);
|
|
696
|
+
if (option && this.canSelectOption(option)) this.selectValue(value, false);
|
|
697
|
+
}
|
|
698
|
+
this.afterSelectionChange();
|
|
699
|
+
}
|
|
700
|
+
/** Clears every selection. Equivalent to `setValue(null)`. */
|
|
701
|
+
clearAll() {
|
|
702
|
+
this.clearSelection();
|
|
703
|
+
}
|
|
390
704
|
enable() {
|
|
391
705
|
this.isDisabled = false;
|
|
392
706
|
this.root.classList.remove("forge-select--disabled");
|
|
@@ -432,7 +746,22 @@ var ForgeSelect = class {
|
|
|
432
746
|
}
|
|
433
747
|
}
|
|
434
748
|
}
|
|
749
|
+
shouldShowSearch() {
|
|
750
|
+
return this.opts.searchable && (this.opts.ajax != null || collectValues(this.data).size >= this.opts.minResultsForSearch);
|
|
751
|
+
}
|
|
752
|
+
updateSearchVisibility() {
|
|
753
|
+
if (!this.searchInput) return;
|
|
754
|
+
this.searchInput.hidden = !this.shouldShowSearch();
|
|
755
|
+
if (this.searchInput.hidden) {
|
|
756
|
+
this.searchInput.value = "";
|
|
757
|
+
this.query = "";
|
|
758
|
+
}
|
|
759
|
+
}
|
|
435
760
|
buildDom() {
|
|
761
|
+
const portalParent = typeof this.opts.dropdownParent === "string" ? document.querySelector(this.opts.dropdownParent) : this.opts.dropdownParent;
|
|
762
|
+
if (this.opts.dropdownParent && !portalParent) {
|
|
763
|
+
throw new Error(`ForgeSelect: dropdown parent not found: ${String(this.opts.dropdownParent)}`);
|
|
764
|
+
}
|
|
436
765
|
this.root = document.createElement("div");
|
|
437
766
|
this.root.className = "forge-select";
|
|
438
767
|
this.root.dataset.theme = this.opts.theme;
|
|
@@ -444,6 +773,7 @@ var ForgeSelect = class {
|
|
|
444
773
|
this.control.setAttribute("aria-haspopup", "listbox");
|
|
445
774
|
this.control.setAttribute("aria-expanded", "false");
|
|
446
775
|
this.control.setAttribute("aria-controls", `${this.uid}-list`);
|
|
776
|
+
if (this.opts.required) this.control.setAttribute("aria-required", "true");
|
|
447
777
|
this.control.tabIndex = 0;
|
|
448
778
|
this.applyAccessibleName();
|
|
449
779
|
this.valueEl = document.createElement("div");
|
|
@@ -468,6 +798,7 @@ var ForgeSelect = class {
|
|
|
468
798
|
this.searchInput.setAttribute("aria-label", this.strings.search);
|
|
469
799
|
this.searchInput.setAttribute("aria-autocomplete", "list");
|
|
470
800
|
this.searchInput.setAttribute("aria-controls", `${this.uid}-list`);
|
|
801
|
+
this.searchInput.hidden = !this.shouldShowSearch();
|
|
471
802
|
this.dropdown.append(this.searchInput);
|
|
472
803
|
}
|
|
473
804
|
this.list = document.createElement("ul");
|
|
@@ -480,9 +811,18 @@ var ForgeSelect = class {
|
|
|
480
811
|
this.liveRegion.className = "forge-select__sr-only";
|
|
481
812
|
this.liveRegion.setAttribute("role", "status");
|
|
482
813
|
this.liveRegion.setAttribute("aria-live", "polite");
|
|
483
|
-
this.root.append(this.control, this.
|
|
814
|
+
this.root.append(this.control, this.liveRegion);
|
|
815
|
+
if (!portalParent) this.root.append(this.dropdown);
|
|
484
816
|
this.el.style.display = "none";
|
|
485
817
|
this.el.insertAdjacentElement("afterend", this.root);
|
|
818
|
+
if (portalParent) {
|
|
819
|
+
this.portalHost = document.createElement("div");
|
|
820
|
+
this.portalHost.className = "forge-select forge-select--portal-host";
|
|
821
|
+
this.portalHost.dataset.theme = this.opts.theme;
|
|
822
|
+
this.portalHost.style.setProperty("--fs-item-height", `${this.opts.itemHeight}px`);
|
|
823
|
+
this.portalHost.append(this.dropdown);
|
|
824
|
+
portalParent.append(this.portalHost);
|
|
825
|
+
}
|
|
486
826
|
this.bindEvents();
|
|
487
827
|
}
|
|
488
828
|
bindEvents() {
|
|
@@ -497,23 +837,46 @@ var ForgeSelect = class {
|
|
|
497
837
|
else this.open();
|
|
498
838
|
});
|
|
499
839
|
this.control.addEventListener("keydown", (event) => this.handleKeydown(event));
|
|
840
|
+
this.control.addEventListener("mousedown", () => {
|
|
841
|
+
this.pointerDownOnControl = true;
|
|
842
|
+
});
|
|
843
|
+
this.control.addEventListener("focus", () => {
|
|
844
|
+
if (this.opts.openOnFocus && !this.pointerDownOnControl && !this.isOpen && !this.isDisabled) {
|
|
845
|
+
this.open();
|
|
846
|
+
}
|
|
847
|
+
this.pointerDownOnControl = false;
|
|
848
|
+
});
|
|
500
849
|
this.clearBtn.addEventListener("click", (event) => {
|
|
501
850
|
event.stopPropagation();
|
|
502
851
|
this.clearSelection();
|
|
503
852
|
});
|
|
504
853
|
if (this.searchInput) {
|
|
505
854
|
this.searchInput.addEventListener("input", () => {
|
|
506
|
-
this.
|
|
507
|
-
this.highlightedIndex = -1;
|
|
508
|
-
this.list.scrollTop = 0;
|
|
509
|
-
this.emitter.emit("search", this.query);
|
|
510
|
-
if (this.opts.ajax) {
|
|
511
|
-
this.scheduleRemoteLoad(this.query, this.opts.ajax.debounce ?? 250);
|
|
512
|
-
} else {
|
|
513
|
-
this.renderList();
|
|
514
|
-
}
|
|
855
|
+
this.applySearchQuery(this.searchInput.value, true);
|
|
515
856
|
});
|
|
516
857
|
this.searchInput.addEventListener("keydown", (event) => this.handleKeydown(event));
|
|
858
|
+
this.searchInput.addEventListener("paste", (event) => {
|
|
859
|
+
if (!this.opts.multiple || !this.opts.allowCreate) return;
|
|
860
|
+
const text = event.clipboardData?.getData("text") ?? "";
|
|
861
|
+
const labels = text.split(/[,\n]+/).map((s) => s.trim()).filter(Boolean);
|
|
862
|
+
if (labels.length < 2) return;
|
|
863
|
+
event.preventDefault();
|
|
864
|
+
const created = [];
|
|
865
|
+
for (const label of labels) {
|
|
866
|
+
const result = this.createTag(label);
|
|
867
|
+
if (result) created.push(result);
|
|
868
|
+
}
|
|
869
|
+
if (created.length === 0) return;
|
|
870
|
+
this.searchInput.value = "";
|
|
871
|
+
this.query = "";
|
|
872
|
+
this.afterSelectionChange();
|
|
873
|
+
for (const result of created) {
|
|
874
|
+
if (result.created) this.emitter.emit("create", result.option);
|
|
875
|
+
this.emitter.emit("select", result.option);
|
|
876
|
+
}
|
|
877
|
+
if (this.opts.closeOnSelect) this.close();
|
|
878
|
+
else this.renderList();
|
|
879
|
+
});
|
|
517
880
|
}
|
|
518
881
|
this.list.addEventListener("click", (event) => {
|
|
519
882
|
const target = event.target;
|
|
@@ -526,7 +889,12 @@ var ForgeSelect = class {
|
|
|
526
889
|
return;
|
|
527
890
|
}
|
|
528
891
|
const li = target.closest("li[data-nav-index]");
|
|
529
|
-
if (!li)
|
|
892
|
+
if (!li) {
|
|
893
|
+
const optionRow = target.closest("li[data-option-value]");
|
|
894
|
+
const option = optionRow ? this.findOption(optionRow.dataset.optionValue) : void 0;
|
|
895
|
+
if (option && this.hasReachedMaximum() && !this.selected.includes(option.value)) this.announceMaximum(option);
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
530
898
|
const navIndex = Number(li.dataset.navIndex);
|
|
531
899
|
this.activateNavItem(navIndex);
|
|
532
900
|
});
|
|
@@ -535,6 +903,29 @@ var ForgeSelect = class {
|
|
|
535
903
|
this.maybeLoadNextPage();
|
|
536
904
|
});
|
|
537
905
|
}
|
|
906
|
+
applySearchQuery(query, emitSearch) {
|
|
907
|
+
this.query = query;
|
|
908
|
+
if (this.searchInput && this.searchInput.value !== query) this.searchInput.value = query;
|
|
909
|
+
this.highlightedIndex = -1;
|
|
910
|
+
this.list.scrollTop = 0;
|
|
911
|
+
this.rowContentCache.clear();
|
|
912
|
+
if (emitSearch) this.emitter.emit("search", query);
|
|
913
|
+
const trimmed = query.trim();
|
|
914
|
+
const belowMinLength = trimmed !== "" && trimmed.length < this.opts.minSearchLength;
|
|
915
|
+
if (this.opts.ajax && !belowMinLength) {
|
|
916
|
+
this.scheduleRemoteLoad(query, this.opts.ajax.debounce ?? 250);
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
if (belowMinLength) {
|
|
920
|
+
if (this.ajaxTimer) {
|
|
921
|
+
clearTimeout(this.ajaxTimer);
|
|
922
|
+
this.ajaxTimer = null;
|
|
923
|
+
}
|
|
924
|
+
this.ajaxController?.abort();
|
|
925
|
+
this.setLoading(false);
|
|
926
|
+
}
|
|
927
|
+
this.renderList();
|
|
928
|
+
}
|
|
538
929
|
handleKeydown(event) {
|
|
539
930
|
if (this.isDisabled) return;
|
|
540
931
|
switch (event.key) {
|
|
@@ -577,36 +968,61 @@ var ForgeSelect = class {
|
|
|
577
968
|
}
|
|
578
969
|
}
|
|
579
970
|
// ---------------------------------------------------------------- selection
|
|
971
|
+
canSelectOption(option) {
|
|
972
|
+
if (this.opts.maxSelections == null) return true;
|
|
973
|
+
const projected = [...this.selected];
|
|
974
|
+
if (!projected.includes(option.value)) projected.push(option.value);
|
|
975
|
+
for (const value of collectDescendantValues(option, this.isOptionDisabled)) {
|
|
976
|
+
if (!projected.includes(value)) projected.push(value);
|
|
977
|
+
}
|
|
978
|
+
syncTreeAncestors(this.data, projected, this.isOptionDisabled);
|
|
979
|
+
return projected.length <= this.opts.maxSelections;
|
|
980
|
+
}
|
|
981
|
+
hasReachedMaximum() {
|
|
982
|
+
return this.opts.maxSelections != null && this.selected.length >= this.opts.maxSelections;
|
|
983
|
+
}
|
|
984
|
+
announceMaximum(option) {
|
|
985
|
+
const limit = this.opts.maxSelections;
|
|
986
|
+
if (limit == null) return;
|
|
987
|
+
this.liveRegion.textContent = format(this.strings.maximumSelected, { count: String(limit) });
|
|
988
|
+
this.emitter.emit("maximum", { limit, option });
|
|
989
|
+
}
|
|
580
990
|
selectValue(value, notify) {
|
|
581
991
|
if (this.selected.includes(value)) return;
|
|
582
992
|
const option = this.findOption(value) ?? this.selectedOptions.get(value) ?? { value, label: value };
|
|
583
993
|
this.selectedOptions.set(value, option);
|
|
584
994
|
if (this.opts.multiple) {
|
|
585
995
|
this.selected.push(value);
|
|
586
|
-
for (const v of collectDescendantValues(option)) {
|
|
996
|
+
for (const v of collectDescendantValues(option, this.isOptionDisabled)) {
|
|
587
997
|
if (!this.selected.includes(v)) this.selected.push(v);
|
|
588
998
|
}
|
|
589
999
|
this.syncTreeAncestors();
|
|
590
1000
|
} else {
|
|
591
1001
|
this.selected = [value];
|
|
592
1002
|
}
|
|
593
|
-
if (notify)
|
|
1003
|
+
if (notify) {
|
|
1004
|
+
this.afterSelectionChange();
|
|
1005
|
+
this.emitter.emit("select", option);
|
|
1006
|
+
}
|
|
594
1007
|
}
|
|
595
1008
|
deselectValue(value, notify) {
|
|
596
1009
|
const index = this.selected.indexOf(value);
|
|
597
1010
|
if (index === -1) return;
|
|
1011
|
+
const option = this.findOption(value) ?? this.selectedOptions.get(value);
|
|
598
1012
|
this.selected.splice(index, 1);
|
|
599
1013
|
if (this.opts.multiple) {
|
|
600
|
-
const option = this.findOption(value) ?? this.selectedOptions.get(value);
|
|
601
1014
|
if (option) {
|
|
602
|
-
for (const v of collectDescendantValues(option)) {
|
|
1015
|
+
for (const v of collectDescendantValues(option, this.isOptionDisabled)) {
|
|
603
1016
|
const i = this.selected.indexOf(v);
|
|
604
1017
|
if (i !== -1) this.selected.splice(i, 1);
|
|
605
1018
|
}
|
|
606
1019
|
}
|
|
607
1020
|
this.syncTreeAncestors();
|
|
608
1021
|
}
|
|
609
|
-
if (notify)
|
|
1022
|
+
if (notify) {
|
|
1023
|
+
this.afterSelectionChange();
|
|
1024
|
+
this.emitter.emit("unselect", option ?? { value, label: value });
|
|
1025
|
+
}
|
|
610
1026
|
}
|
|
611
1027
|
/**
|
|
612
1028
|
* Keeps every tree parent's own membership in `selected` consistent with
|
|
@@ -615,7 +1031,7 @@ var ForgeSelect = class {
|
|
|
615
1031
|
* No-op for data with no `children` anywhere.
|
|
616
1032
|
*/
|
|
617
1033
|
syncTreeAncestors() {
|
|
618
|
-
syncTreeAncestors(this.data, this.selected);
|
|
1034
|
+
syncTreeAncestors(this.data, this.selected, this.isOptionDisabled);
|
|
619
1035
|
}
|
|
620
1036
|
clearSelection() {
|
|
621
1037
|
if (this.selected.length === 0) return;
|
|
@@ -623,9 +1039,22 @@ var ForgeSelect = class {
|
|
|
623
1039
|
this.emitter.emit("clear");
|
|
624
1040
|
this.afterSelectionChange();
|
|
625
1041
|
}
|
|
1042
|
+
allSelectableValues() {
|
|
1043
|
+
const values = [];
|
|
1044
|
+
const visit = (option) => {
|
|
1045
|
+
if (!this.isOptionDisabled(option)) values.push(option.value);
|
|
1046
|
+
option.children?.forEach(visit);
|
|
1047
|
+
};
|
|
1048
|
+
for (const item of this.data) (isGroup(item) ? item.options : [item]).forEach(visit);
|
|
1049
|
+
return values;
|
|
1050
|
+
}
|
|
626
1051
|
afterSelectionChange(emitChange = true) {
|
|
627
1052
|
this.renderValue();
|
|
628
1053
|
this.syncNativeSelect(emitChange);
|
|
1054
|
+
if (!this.opts.required || this.selected.length > 0) {
|
|
1055
|
+
this.control.classList.remove("forge-select__control--invalid");
|
|
1056
|
+
this.control.removeAttribute("aria-invalid");
|
|
1057
|
+
}
|
|
629
1058
|
if (this.isOpen) this.renderList();
|
|
630
1059
|
if (emitChange) this.emitter.emit("change", this.getValue());
|
|
631
1060
|
}
|
|
@@ -661,17 +1090,58 @@ var ForgeSelect = class {
|
|
|
661
1090
|
findOption(value) {
|
|
662
1091
|
return findOption(this.data, value);
|
|
663
1092
|
}
|
|
1093
|
+
findOptionByLabel(label) {
|
|
1094
|
+
const lower = label.toLowerCase();
|
|
1095
|
+
const search = (options) => {
|
|
1096
|
+
for (const option of options) {
|
|
1097
|
+
if (option.label.toLowerCase() === lower) return option;
|
|
1098
|
+
const found = option.children ? search(option.children) : void 0;
|
|
1099
|
+
if (found) return found;
|
|
1100
|
+
}
|
|
1101
|
+
return void 0;
|
|
1102
|
+
};
|
|
1103
|
+
for (const item of this.data) {
|
|
1104
|
+
const found = search(isGroup(item) ? item.options : [item]);
|
|
1105
|
+
if (found) return found;
|
|
1106
|
+
}
|
|
1107
|
+
return void 0;
|
|
1108
|
+
}
|
|
1109
|
+
/** Selects an existing option matching `label` exactly, or creates and selects a new one. */
|
|
1110
|
+
createTag(label) {
|
|
1111
|
+
const trimmed = label.trim();
|
|
1112
|
+
if (!trimmed) return void 0;
|
|
1113
|
+
const existing = this.findOptionByLabel(trimmed);
|
|
1114
|
+
if (existing) {
|
|
1115
|
+
if (this.selected.includes(existing.value)) return void 0;
|
|
1116
|
+
if (this.opts.multiple && !this.canSelectOption(existing)) {
|
|
1117
|
+
this.announceMaximum(existing);
|
|
1118
|
+
return void 0;
|
|
1119
|
+
}
|
|
1120
|
+
this.selectValue(existing.value, false);
|
|
1121
|
+
return { option: existing, created: false };
|
|
1122
|
+
}
|
|
1123
|
+
const option = { value: trimmed, label: trimmed };
|
|
1124
|
+
if (this.opts.multiple && !this.canSelectOption(option)) {
|
|
1125
|
+
this.announceMaximum(option);
|
|
1126
|
+
return void 0;
|
|
1127
|
+
}
|
|
1128
|
+
this.data.push(option);
|
|
1129
|
+
this.selectValue(option.value, false);
|
|
1130
|
+
return { option, created: true };
|
|
1131
|
+
}
|
|
664
1132
|
createFromQuery() {
|
|
665
1133
|
const label = this.query.trim();
|
|
666
1134
|
if (!label) return;
|
|
667
|
-
const
|
|
668
|
-
|
|
1135
|
+
const result = this.createTag(label);
|
|
1136
|
+
if (!result) return;
|
|
669
1137
|
if (this.searchInput) {
|
|
670
1138
|
this.searchInput.value = "";
|
|
671
1139
|
this.query = "";
|
|
672
1140
|
}
|
|
673
|
-
this.
|
|
674
|
-
if (
|
|
1141
|
+
this.afterSelectionChange();
|
|
1142
|
+
if (result.created) this.emitter.emit("create", result.option);
|
|
1143
|
+
this.emitter.emit("select", result.option);
|
|
1144
|
+
if (!this.opts.multiple || this.opts.closeOnSelect) this.close();
|
|
675
1145
|
}
|
|
676
1146
|
activateNavItem(navIndex) {
|
|
677
1147
|
const item = this.navItems[navIndex];
|
|
@@ -682,8 +1152,17 @@ var ForgeSelect = class {
|
|
|
682
1152
|
}
|
|
683
1153
|
const { value } = item.option;
|
|
684
1154
|
if (this.opts.multiple) {
|
|
685
|
-
|
|
686
|
-
|
|
1155
|
+
let changed = false;
|
|
1156
|
+
if (this.selected.includes(value)) {
|
|
1157
|
+
this.deselectValue(value, true);
|
|
1158
|
+
changed = true;
|
|
1159
|
+
} else if (this.canSelectOption(item.option)) {
|
|
1160
|
+
this.selectValue(value, true);
|
|
1161
|
+
changed = true;
|
|
1162
|
+
} else {
|
|
1163
|
+
this.announceMaximum(item.option);
|
|
1164
|
+
}
|
|
1165
|
+
if (changed && this.opts.closeOnSelect) this.close();
|
|
687
1166
|
} else {
|
|
688
1167
|
this.selectValue(value, true);
|
|
689
1168
|
this.close();
|
|
@@ -799,6 +1278,7 @@ var ForgeSelect = class {
|
|
|
799
1278
|
this.selected = order;
|
|
800
1279
|
this.suppressNextTagClick = true;
|
|
801
1280
|
this.afterSelectionChange();
|
|
1281
|
+
this.emitter.emit("reorder", [...this.selected]);
|
|
802
1282
|
};
|
|
803
1283
|
tag.addEventListener("pointerdown", (event) => {
|
|
804
1284
|
if (this.isDisabled || event.button !== 0) return;
|
|
@@ -822,6 +1302,7 @@ var ForgeSelect = class {
|
|
|
822
1302
|
[next[index], next[targetIndex]] = [next[targetIndex], next[index]];
|
|
823
1303
|
this.selected = next;
|
|
824
1304
|
this.afterSelectionChange();
|
|
1305
|
+
this.emitter.emit("reorder", [...this.selected]);
|
|
825
1306
|
this.focusTagByValue(value);
|
|
826
1307
|
}
|
|
827
1308
|
focusTagByValue(value) {
|
|
@@ -835,12 +1316,19 @@ var ForgeSelect = class {
|
|
|
835
1316
|
buildRows() {
|
|
836
1317
|
this.rows = [];
|
|
837
1318
|
this.navItems = [];
|
|
838
|
-
const
|
|
839
|
-
const
|
|
1319
|
+
const trimmedQuery = this.query.trim();
|
|
1320
|
+
const query = normalizeSearchText(trimmedQuery, this.opts.accentInsensitive);
|
|
1321
|
+
const matches = (option) => query === "" || (this.opts.filterOption ? this.opts.filterOption(option, trimmedQuery) : this.searchIndex.score(option, trimmedQuery, {
|
|
1322
|
+
fields: this.opts.searchFields,
|
|
1323
|
+
tokenSearch: this.opts.tokenSearch,
|
|
1324
|
+
accentInsensitive: this.opts.accentInsensitive,
|
|
1325
|
+
scorer: this.opts.searchScorer
|
|
1326
|
+
}) > 0);
|
|
840
1327
|
const subtreeMatches = (option) => query === "" || matches(option) || (option.children ?? []).some(subtreeMatches);
|
|
841
1328
|
const pushOption = (option, depth, parentValue) => {
|
|
842
1329
|
let navIndex = -1;
|
|
843
|
-
|
|
1330
|
+
const interactionDisabled = this.isOptionDisabled(option) || this.hasReachedMaximum() && !this.selected.includes(option.value);
|
|
1331
|
+
if (!interactionDisabled) {
|
|
844
1332
|
navIndex = this.navItems.length;
|
|
845
1333
|
this.navItems.push({ kind: "option", option, parentValue });
|
|
846
1334
|
}
|
|
@@ -855,6 +1343,10 @@ var ForgeSelect = class {
|
|
|
855
1343
|
}
|
|
856
1344
|
}
|
|
857
1345
|
};
|
|
1346
|
+
if (trimmedQuery !== "" && trimmedQuery.length < this.opts.minSearchLength) {
|
|
1347
|
+
this.rows.push({ kind: "min-length" });
|
|
1348
|
+
return;
|
|
1349
|
+
}
|
|
858
1350
|
if (this.loading) {
|
|
859
1351
|
this.rows.push({ kind: "loading" });
|
|
860
1352
|
return;
|
|
@@ -882,16 +1374,30 @@ var ForgeSelect = class {
|
|
|
882
1374
|
else if (this.loadingMore) this.rows.push({ kind: "loading-more" });
|
|
883
1375
|
}
|
|
884
1376
|
hasExactMatch(lowerQuery) {
|
|
885
|
-
|
|
886
|
-
for (const item of this.data) {
|
|
887
|
-
const options = isGroup(item) ? item.options : [item];
|
|
888
|
-
if (options.some(matchesExactly)) return true;
|
|
889
|
-
}
|
|
890
|
-
return false;
|
|
1377
|
+
return !!this.findOptionByLabel(lowerQuery);
|
|
891
1378
|
}
|
|
892
1379
|
usesVirtualScroll() {
|
|
893
1380
|
return this.opts.virtualScroll !== false && this.rows.length > VIRTUAL_THRESHOLD;
|
|
894
1381
|
}
|
|
1382
|
+
rowKey(row, index) {
|
|
1383
|
+
if (row.kind === "option") return `option:${row.option.value}`;
|
|
1384
|
+
if (row.kind === "group") return `group:${row.label}:${index}`;
|
|
1385
|
+
return `${row.kind}:${index}`;
|
|
1386
|
+
}
|
|
1387
|
+
measuredRowHeight(index) {
|
|
1388
|
+
return this.opts.variableItemHeight ? this.rowHeightCache.get(this.rowKey(this.rows[index], index)) ?? this.opts.itemHeight : this.opts.itemHeight;
|
|
1389
|
+
}
|
|
1390
|
+
rowOffset(index) {
|
|
1391
|
+
if (!this.opts.variableItemHeight) return index * this.opts.itemHeight;
|
|
1392
|
+
let offset = 0;
|
|
1393
|
+
for (let i = 0; i < index; i += 1) offset += this.measuredRowHeight(i);
|
|
1394
|
+
return offset;
|
|
1395
|
+
}
|
|
1396
|
+
rowOffsets() {
|
|
1397
|
+
const offsets = [0];
|
|
1398
|
+
for (let i = 0; i < this.rows.length; i += 1) offsets.push(offsets[i] + this.measuredRowHeight(i));
|
|
1399
|
+
return offsets;
|
|
1400
|
+
}
|
|
895
1401
|
renderList() {
|
|
896
1402
|
this.buildRows();
|
|
897
1403
|
this.renderRows();
|
|
@@ -899,7 +1405,7 @@ var ForgeSelect = class {
|
|
|
899
1405
|
}
|
|
900
1406
|
announceStatus() {
|
|
901
1407
|
const first = this.rows[0];
|
|
902
|
-
const message = first?.kind === "loading" ? this.strings.loading : first?.kind === "error" ? this.strings.errorLoading : first?.kind === "empty" ? this.strings.noResults : "";
|
|
1408
|
+
const message = this.hasReachedMaximum() ? format(this.strings.maximumSelected, { count: String(this.opts.maxSelections) }) : first?.kind === "loading" ? this.strings.loading : first?.kind === "error" ? this.strings.errorLoading : first?.kind === "empty" ? this.strings.noResults : first?.kind === "min-length" ? format(this.strings.minSearchLength, { count: String(this.opts.minSearchLength) }) : "";
|
|
903
1409
|
if (this.liveRegion.textContent !== message) this.liveRegion.textContent = message;
|
|
904
1410
|
}
|
|
905
1411
|
renderRows() {
|
|
@@ -908,26 +1414,40 @@ var ForgeSelect = class {
|
|
|
908
1414
|
const virtual = this.usesVirtualScroll();
|
|
909
1415
|
this.list.textContent = "";
|
|
910
1416
|
const rowHeight = this.opts.itemHeight;
|
|
1417
|
+
const offsets = this.opts.variableItemHeight ? this.rowOffsets() : null;
|
|
911
1418
|
let start = 0;
|
|
912
1419
|
let end = this.rows.length;
|
|
913
1420
|
if (virtual) {
|
|
914
1421
|
const viewport = clientHeight || rowHeight * 8;
|
|
915
|
-
|
|
916
|
-
|
|
1422
|
+
if (this.opts.variableItemHeight) {
|
|
1423
|
+
while (start < this.rows.length && offsets[start + 1] < scrollTop) start += 1;
|
|
1424
|
+
start = Math.max(0, start - VIRTUAL_BUFFER);
|
|
1425
|
+
end = start;
|
|
1426
|
+
const target = scrollTop + viewport + VIRTUAL_BUFFER * rowHeight;
|
|
1427
|
+
while (end < this.rows.length && offsets[end] < target) end += 1;
|
|
1428
|
+
} else {
|
|
1429
|
+
start = Math.max(0, Math.floor(scrollTop / rowHeight) - VIRTUAL_BUFFER);
|
|
1430
|
+
end = Math.min(this.rows.length, start + Math.ceil(viewport / rowHeight) + VIRTUAL_BUFFER * 2);
|
|
1431
|
+
}
|
|
917
1432
|
const topSpacer = document.createElement("li");
|
|
918
1433
|
topSpacer.className = "forge-select__spacer";
|
|
919
1434
|
topSpacer.setAttribute("aria-hidden", "true");
|
|
920
|
-
topSpacer.style.height = `${start
|
|
1435
|
+
topSpacer.style.height = `${offsets?.[start] ?? this.rowOffset(start)}px`;
|
|
921
1436
|
this.list.append(topSpacer);
|
|
922
1437
|
}
|
|
923
1438
|
for (let i = start; i < end; i++) {
|
|
924
|
-
this.
|
|
1439
|
+
const element = this.renderRow(this.rows[i]);
|
|
1440
|
+
this.list.append(element);
|
|
1441
|
+
if (this.opts.variableItemHeight) {
|
|
1442
|
+
const measured = element.getBoundingClientRect().height || element.offsetHeight;
|
|
1443
|
+
if (measured > 0) this.rowHeightCache.set(this.rowKey(this.rows[i], i), measured);
|
|
1444
|
+
}
|
|
925
1445
|
}
|
|
926
1446
|
if (virtual) {
|
|
927
1447
|
const bottomSpacer = document.createElement("li");
|
|
928
1448
|
bottomSpacer.className = "forge-select__spacer";
|
|
929
1449
|
bottomSpacer.setAttribute("aria-hidden", "true");
|
|
930
|
-
bottomSpacer.style.height = `${
|
|
1450
|
+
bottomSpacer.style.height = `${offsets ? offsets[this.rows.length] - offsets[end] : this.rowOffset(this.rows.length) - this.rowOffset(end)}px`;
|
|
931
1451
|
this.list.append(bottomSpacer);
|
|
932
1452
|
if (this.list.scrollTop !== scrollTop) {
|
|
933
1453
|
this.list.scrollTop = scrollTop;
|
|
@@ -950,6 +1470,13 @@ var ForgeSelect = class {
|
|
|
950
1470
|
li.setAttribute("aria-selected", "false");
|
|
951
1471
|
li.textContent = this.strings.noResults;
|
|
952
1472
|
break;
|
|
1473
|
+
case "min-length":
|
|
1474
|
+
li.className = "forge-select__min-length";
|
|
1475
|
+
li.setAttribute("role", "option");
|
|
1476
|
+
li.setAttribute("aria-disabled", "true");
|
|
1477
|
+
li.setAttribute("aria-selected", "false");
|
|
1478
|
+
li.textContent = format(this.strings.minSearchLength, { count: String(this.opts.minSearchLength) });
|
|
1479
|
+
break;
|
|
953
1480
|
case "error":
|
|
954
1481
|
li.className = "forge-select__error";
|
|
955
1482
|
li.setAttribute("role", "option");
|
|
@@ -979,17 +1506,20 @@ var ForgeSelect = class {
|
|
|
979
1506
|
break;
|
|
980
1507
|
case "option": {
|
|
981
1508
|
li.className = "forge-select__option";
|
|
1509
|
+
li.dataset.optionValue = row.option.value;
|
|
1510
|
+
if (row.option.className) li.classList.add(...row.option.className.trim().split(/\s+/).filter(Boolean));
|
|
982
1511
|
li.setAttribute("role", "option");
|
|
983
1512
|
const isSelected = this.selected.includes(row.option.value);
|
|
984
1513
|
li.setAttribute("aria-selected", String(isSelected));
|
|
985
1514
|
if (isSelected) li.classList.add("forge-select__option--selected");
|
|
986
|
-
if (this.opts.multiple && row.hasChildren && computeCheckState(row.option, this.selected) === "some") {
|
|
1515
|
+
if (this.opts.multiple && row.hasChildren && computeCheckState(row.option, this.selected, this.isOptionDisabled) === "some") {
|
|
987
1516
|
li.classList.add("forge-select__option--indeterminate");
|
|
1517
|
+
li.dataset.selectionState = "mixed";
|
|
988
1518
|
}
|
|
989
1519
|
if (row.depth > 0) {
|
|
990
1520
|
li.style.paddingLeft = `calc(12px + ${row.depth} * var(--fs-tree-indent, 18px))`;
|
|
991
1521
|
}
|
|
992
|
-
if (row.option.
|
|
1522
|
+
if (this.isOptionDisabled(row.option) || this.hasReachedMaximum() && !this.selected.includes(row.option.value)) {
|
|
993
1523
|
li.classList.add("forge-select__option--disabled");
|
|
994
1524
|
li.setAttribute("aria-disabled", "true");
|
|
995
1525
|
} else {
|
|
@@ -1020,6 +1550,28 @@ var ForgeSelect = class {
|
|
|
1020
1550
|
* cached content state-free.
|
|
1021
1551
|
*/
|
|
1022
1552
|
optionContent(option) {
|
|
1553
|
+
if (this.opts.highlightSearch && this.query.trim() && !this.opts.templateResult) {
|
|
1554
|
+
const holder = document.createElement("span");
|
|
1555
|
+
holder.className = "forge-select__option-content";
|
|
1556
|
+
renderOptionContent(holder, option, void 0);
|
|
1557
|
+
const label = holder.querySelector(".forge-select__option-label") ?? holder;
|
|
1558
|
+
const ranges = findNormalizedRanges(option.label, this.query, this.opts.accentInsensitive);
|
|
1559
|
+
if (ranges.length) {
|
|
1560
|
+
label.textContent = "";
|
|
1561
|
+
let cursor = 0;
|
|
1562
|
+
for (const [start, end] of ranges) {
|
|
1563
|
+
if (start < cursor) continue;
|
|
1564
|
+
label.append(document.createTextNode(option.label.slice(cursor, start)));
|
|
1565
|
+
const mark = document.createElement("mark");
|
|
1566
|
+
mark.className = "forge-select__match";
|
|
1567
|
+
mark.textContent = option.label.slice(start, end);
|
|
1568
|
+
label.append(mark);
|
|
1569
|
+
cursor = end;
|
|
1570
|
+
}
|
|
1571
|
+
label.append(document.createTextNode(option.label.slice(cursor)));
|
|
1572
|
+
}
|
|
1573
|
+
return holder;
|
|
1574
|
+
}
|
|
1023
1575
|
let cached = this.rowContentCache.get(option.value);
|
|
1024
1576
|
if (!cached) {
|
|
1025
1577
|
const holder = document.createElement("span");
|
|
@@ -1046,8 +1598,8 @@ var ForgeSelect = class {
|
|
|
1046
1598
|
(row) => (row.kind === "option" || row.kind === "create") && row.navIndex === next
|
|
1047
1599
|
);
|
|
1048
1600
|
if (rowIndex >= 0) {
|
|
1049
|
-
const rowHeight = this.
|
|
1050
|
-
const top = rowIndex
|
|
1601
|
+
const rowHeight = this.measuredRowHeight(rowIndex);
|
|
1602
|
+
const top = this.rowOffset(rowIndex);
|
|
1051
1603
|
const viewport = this.list.clientHeight || rowHeight * 8;
|
|
1052
1604
|
let target = this.list.scrollTop;
|
|
1053
1605
|
if (top < target) target = top;
|
|
@@ -1112,7 +1664,7 @@ var ForgeSelect = class {
|
|
|
1112
1664
|
this.ajaxController = null;
|
|
1113
1665
|
this.page = 0;
|
|
1114
1666
|
this.hasMore = true;
|
|
1115
|
-
this.
|
|
1667
|
+
this.setLoading(true);
|
|
1116
1668
|
this.loadingMore = false;
|
|
1117
1669
|
this.loadError = null;
|
|
1118
1670
|
this.renderList();
|
|
@@ -1121,6 +1673,55 @@ var ForgeSelect = class {
|
|
|
1121
1673
|
void this.loadRemote(query, { requestId });
|
|
1122
1674
|
}, delay);
|
|
1123
1675
|
}
|
|
1676
|
+
setLoading(loading) {
|
|
1677
|
+
if (this.loading === loading) return;
|
|
1678
|
+
this.loading = loading;
|
|
1679
|
+
this.emitter.emit("loading", loading);
|
|
1680
|
+
}
|
|
1681
|
+
remoteCacheKey(query, page) {
|
|
1682
|
+
return `${query}\0${page}`;
|
|
1683
|
+
}
|
|
1684
|
+
async requestRemote(query, page, signal) {
|
|
1685
|
+
const ajax = this.opts.ajax;
|
|
1686
|
+
const attempts = Math.max(0, Math.floor(ajax.retry ?? 0)) + 1;
|
|
1687
|
+
let lastError;
|
|
1688
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
1689
|
+
try {
|
|
1690
|
+
if (ajax.request) return await ajax.request(query, page, signal);
|
|
1691
|
+
const response = await fetch(buildUrl(ajax, query, page), { signal });
|
|
1692
|
+
if (response.ok === false) throw new Error(`ForgeSelect: remote request failed with HTTP ${response.status}`);
|
|
1693
|
+
return await response.json();
|
|
1694
|
+
} catch (error) {
|
|
1695
|
+
lastError = error;
|
|
1696
|
+
if (signal.aborted || attempt === attempts - 1) throw error;
|
|
1697
|
+
const delay = Math.max(0, ajax.retryDelay ?? 250) * 2 ** attempt;
|
|
1698
|
+
await new Promise((resolve, reject) => {
|
|
1699
|
+
const timer = setTimeout(resolve, delay);
|
|
1700
|
+
signal.addEventListener(
|
|
1701
|
+
"abort",
|
|
1702
|
+
() => {
|
|
1703
|
+
clearTimeout(timer);
|
|
1704
|
+
reject(new DOMException("Aborted", "AbortError"));
|
|
1705
|
+
},
|
|
1706
|
+
{ once: true }
|
|
1707
|
+
);
|
|
1708
|
+
});
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
throw lastError;
|
|
1712
|
+
}
|
|
1713
|
+
async prefetchRemote(query) {
|
|
1714
|
+
const ajax = this.opts.ajax;
|
|
1715
|
+
if (!ajax || (ajax.cacheTtl ?? 3e4) <= 0) return;
|
|
1716
|
+
const key = this.remoteCacheKey(query, 0);
|
|
1717
|
+
if (this.remoteCache.get(key)) return;
|
|
1718
|
+
const controller = new AbortController();
|
|
1719
|
+
try {
|
|
1720
|
+
const json = await this.requestRemote(query, 0, controller.signal);
|
|
1721
|
+
this.remoteCache.set(key, normalizeRemoteResult(ajax, json), ajax.cacheTtl ?? 3e4);
|
|
1722
|
+
} catch {
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1124
1725
|
/**
|
|
1125
1726
|
* Fires on every list scroll. Only acts when pagination is opted into via
|
|
1126
1727
|
* `ajax.pagination`; reads real scroll geometry rather than row counts so
|
|
@@ -1145,12 +1746,15 @@ var ForgeSelect = class {
|
|
|
1145
1746
|
this.ajaxController = controller;
|
|
1146
1747
|
const page = append ? this.page + 1 : 0;
|
|
1147
1748
|
try {
|
|
1148
|
-
const
|
|
1149
|
-
|
|
1150
|
-
if (
|
|
1151
|
-
|
|
1749
|
+
const key = this.remoteCacheKey(query, page);
|
|
1750
|
+
let result = this.remoteCache.get(key);
|
|
1751
|
+
if (!result) {
|
|
1752
|
+
const json = await this.requestRemote(query, page, controller.signal);
|
|
1753
|
+
result = normalizeRemoteResult(ajax, json);
|
|
1754
|
+
this.remoteCache.set(key, result, ajax.cacheTtl ?? 3e4);
|
|
1755
|
+
}
|
|
1152
1756
|
if (activeRequestId !== this.ajaxRequestId || this.destroyed) return;
|
|
1153
|
-
const { options, hasMore } =
|
|
1757
|
+
const { options, hasMore } = result;
|
|
1154
1758
|
if (append) {
|
|
1155
1759
|
const existing = collectValues(this.data);
|
|
1156
1760
|
this.data = [...this.data, ...options.filter((o) => !existing.has(o.value))];
|
|
@@ -1175,7 +1779,7 @@ var ForgeSelect = class {
|
|
|
1175
1779
|
} finally {
|
|
1176
1780
|
if (activeRequestId === this.ajaxRequestId && !this.destroyed) {
|
|
1177
1781
|
this.ajaxController = null;
|
|
1178
|
-
this.
|
|
1782
|
+
this.setLoading(false);
|
|
1179
1783
|
this.loadingMore = false;
|
|
1180
1784
|
if (this.isOpen) this.renderList();
|
|
1181
1785
|
}
|