forge-select 0.4.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 +1 -1
- package/dist/index.cjs +352 -42
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +54 -3
- package/dist/index.d.ts +54 -3
- package/dist/index.global.js +1 -1
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +352 -42
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/styles/forge-select.css +12 -0
package/README.md
CHANGED
|
@@ -159,7 +159,7 @@ Write and run Forge Select code in the browser at **<https://forgeselect.konexfo
|
|
|
159
159
|
| `minResultsForSearch` | `number` | `0` | Hide local search below an option-count threshold |
|
|
160
160
|
| `isOptionDisabled` | `(option) => boolean` | `undefined` | Dynamically disable an option per render |
|
|
161
161
|
| `virtualScroll` | `boolean` | _(auto)_ | Virtualize the list once it exceeds ~100 rows |
|
|
162
|
-
| `itemHeight` | `number`
|
|
162
|
+
| `itemHeight` | `number \| "auto"` | `36` | Fixed or measured variable-height virtual rows |
|
|
163
163
|
| `language` | `string \| Record<string, string>` | `"en"` | Locale code or a custom string table for i18n |
|
|
164
164
|
| `plugins` | `Array<ForgeSelectPlugin>` | `[]` | Plugins to register on this instance |
|
|
165
165
|
| `openOnFocus` | `boolean` | `false` | Open the dropdown on keyboard focus |
|
package/dist/index.cjs
CHANGED
|
@@ -183,6 +183,89 @@ function normalizeRemoteResult(ajax, response) {
|
|
|
183
183
|
return { options: result.options, hasMore: ajax.pagination ? Boolean(result.hasMore) : false };
|
|
184
184
|
}
|
|
185
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
|
+
|
|
186
269
|
// src/selection.ts
|
|
187
270
|
function isGroup(item) {
|
|
188
271
|
return item.options !== void 0;
|
|
@@ -267,6 +350,8 @@ var ForgeSelect = class {
|
|
|
267
350
|
this.navItems = [];
|
|
268
351
|
this.highlightedIndex = -1;
|
|
269
352
|
this.rowContentCache = /* @__PURE__ */ new Map();
|
|
353
|
+
this.rowHeightCache = /* @__PURE__ */ new Map();
|
|
354
|
+
this.searchIndex = new SearchIndex();
|
|
270
355
|
this.expandedValues = /* @__PURE__ */ new Set();
|
|
271
356
|
this.loading = false;
|
|
272
357
|
this.loadingMore = false;
|
|
@@ -276,6 +361,7 @@ var ForgeSelect = class {
|
|
|
276
361
|
this.ajaxRequestId = 0;
|
|
277
362
|
this.ajaxController = null;
|
|
278
363
|
this.remoteLoaded = false;
|
|
364
|
+
this.remoteCache = new RemoteCache();
|
|
279
365
|
this.loadError = null;
|
|
280
366
|
this.originalDisplay = "";
|
|
281
367
|
this.originalDisabled = false;
|
|
@@ -301,6 +387,7 @@ var ForgeSelect = class {
|
|
|
301
387
|
this.control.setAttribute("aria-invalid", "true");
|
|
302
388
|
if (!this.isOpen) this.open();
|
|
303
389
|
this.control.focus();
|
|
390
|
+
this.emitter.emit("invalid", this.nativeSelect?.validationMessage ?? "");
|
|
304
391
|
};
|
|
305
392
|
this.onNativeChange = () => {
|
|
306
393
|
if (!this.nativeSelect || this.destroyed || this.syncingNative) return;
|
|
@@ -339,11 +426,17 @@ var ForgeSelect = class {
|
|
|
339
426
|
templateResult: options.templateResult,
|
|
340
427
|
templateSelection: options.templateSelection,
|
|
341
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,
|
|
342
434
|
minSearchLength: Math.max(0, Math.floor(options.minSearchLength ?? 0)),
|
|
343
435
|
minResultsForSearch: Math.max(0, Math.floor(options.minResultsForSearch ?? 0)),
|
|
344
436
|
isOptionDisabled: options.isOptionDisabled,
|
|
345
437
|
virtualScroll: options.virtualScroll,
|
|
346
|
-
itemHeight: options.itemHeight
|
|
438
|
+
itemHeight: typeof options.itemHeight === "number" ? Math.max(1, options.itemHeight) : DEFAULT_ITEM_HEIGHT,
|
|
439
|
+
variableItemHeight: options.itemHeight === "auto",
|
|
347
440
|
language: options.language ?? "en",
|
|
348
441
|
plugins: options.plugins ?? [],
|
|
349
442
|
openOnFocus: options.openOnFocus ?? false,
|
|
@@ -367,6 +460,7 @@ var ForgeSelect = class {
|
|
|
367
460
|
nativeSelect?.addEventListener("invalid", this.onNativeInvalid);
|
|
368
461
|
this.nativeForm?.addEventListener("reset", this.onFormReset);
|
|
369
462
|
for (const plugin of this.plugins) plugin.onInit?.(this);
|
|
463
|
+
for (const query of this.opts.ajax?.prefetch ?? []) void this.prefetchRemote(query);
|
|
370
464
|
}
|
|
371
465
|
applyNativeValues(values) {
|
|
372
466
|
this.selected = [];
|
|
@@ -383,7 +477,7 @@ var ForgeSelect = class {
|
|
|
383
477
|
this.root.classList.add("forge-select--open");
|
|
384
478
|
this.control.setAttribute("aria-expanded", "true");
|
|
385
479
|
document.addEventListener("mousedown", this.onDocumentMouseDown);
|
|
386
|
-
if (this.opts.ajax && !this.remoteLoaded) {
|
|
480
|
+
if (this.opts.ajax && (this.opts.ajax.loadOnOpen ?? true) && !this.remoteLoaded) {
|
|
387
481
|
this.scheduleRemoteLoad(this.query, 0);
|
|
388
482
|
}
|
|
389
483
|
this.renderList();
|
|
@@ -440,6 +534,8 @@ var ForgeSelect = class {
|
|
|
440
534
|
this.nativeSelect?.removeEventListener("invalid", this.onNativeInvalid);
|
|
441
535
|
this.nativeForm?.removeEventListener("reset", this.onFormReset);
|
|
442
536
|
this.rowContentCache.clear();
|
|
537
|
+
this.rowHeightCache.clear();
|
|
538
|
+
this.searchIndex.clear();
|
|
443
539
|
this.portalHost?.remove();
|
|
444
540
|
this.root.remove();
|
|
445
541
|
this.el.style.display = this.originalDisplay;
|
|
@@ -450,6 +546,106 @@ var ForgeSelect = class {
|
|
|
450
546
|
if (this.opts.multiple) return [...this.selected];
|
|
451
547
|
return this.selected[0] ?? null;
|
|
452
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
|
+
}
|
|
453
649
|
setValue(value, options = {}) {
|
|
454
650
|
const values = value == null ? [] : Array.isArray(value) ? value : [value];
|
|
455
651
|
const next = this.opts.multiple ? values : values.slice(0, 1);
|
|
@@ -472,7 +668,7 @@ var ForgeSelect = class {
|
|
|
472
668
|
this.ajaxController?.abort();
|
|
473
669
|
this.ajaxController = null;
|
|
474
670
|
this.ajaxRequestId += 1;
|
|
475
|
-
this.
|
|
671
|
+
this.setLoading(false);
|
|
476
672
|
this.loadingMore = false;
|
|
477
673
|
this.loadError = null;
|
|
478
674
|
this.remoteLoaded = true;
|
|
@@ -482,6 +678,7 @@ var ForgeSelect = class {
|
|
|
482
678
|
this.opts.data = data;
|
|
483
679
|
this.updateSearchVisibility();
|
|
484
680
|
this.rowContentCache.clear();
|
|
681
|
+
this.searchIndex.clear();
|
|
485
682
|
this.highlightedIndex = -1;
|
|
486
683
|
if (this.isOpen) this.renderList();
|
|
487
684
|
}
|
|
@@ -655,25 +852,7 @@ var ForgeSelect = class {
|
|
|
655
852
|
});
|
|
656
853
|
if (this.searchInput) {
|
|
657
854
|
this.searchInput.addEventListener("input", () => {
|
|
658
|
-
this.
|
|
659
|
-
this.highlightedIndex = -1;
|
|
660
|
-
this.list.scrollTop = 0;
|
|
661
|
-
this.emitter.emit("search", this.query);
|
|
662
|
-
const trimmed = this.query.trim();
|
|
663
|
-
const belowMinLength = trimmed !== "" && trimmed.length < this.opts.minSearchLength;
|
|
664
|
-
if (this.opts.ajax && !belowMinLength) {
|
|
665
|
-
this.scheduleRemoteLoad(this.query, this.opts.ajax.debounce ?? 250);
|
|
666
|
-
} else {
|
|
667
|
-
if (belowMinLength) {
|
|
668
|
-
if (this.ajaxTimer) {
|
|
669
|
-
clearTimeout(this.ajaxTimer);
|
|
670
|
-
this.ajaxTimer = null;
|
|
671
|
-
}
|
|
672
|
-
this.ajaxController?.abort();
|
|
673
|
-
this.loading = false;
|
|
674
|
-
}
|
|
675
|
-
this.renderList();
|
|
676
|
-
}
|
|
855
|
+
this.applySearchQuery(this.searchInput.value, true);
|
|
677
856
|
});
|
|
678
857
|
this.searchInput.addEventListener("keydown", (event) => this.handleKeydown(event));
|
|
679
858
|
this.searchInput.addEventListener("paste", (event) => {
|
|
@@ -724,6 +903,29 @@ var ForgeSelect = class {
|
|
|
724
903
|
this.maybeLoadNextPage();
|
|
725
904
|
});
|
|
726
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
|
+
}
|
|
727
929
|
handleKeydown(event) {
|
|
728
930
|
if (this.isDisabled) return;
|
|
729
931
|
switch (event.key) {
|
|
@@ -1115,8 +1317,13 @@ var ForgeSelect = class {
|
|
|
1115
1317
|
this.rows = [];
|
|
1116
1318
|
this.navItems = [];
|
|
1117
1319
|
const trimmedQuery = this.query.trim();
|
|
1118
|
-
const query = trimmedQuery.
|
|
1119
|
-
const matches = (option) => query === "" || (this.opts.filterOption ? this.opts.filterOption(option, trimmedQuery) :
|
|
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);
|
|
1120
1327
|
const subtreeMatches = (option) => query === "" || matches(option) || (option.children ?? []).some(subtreeMatches);
|
|
1121
1328
|
const pushOption = (option, depth, parentValue) => {
|
|
1122
1329
|
let navIndex = -1;
|
|
@@ -1172,6 +1379,25 @@ var ForgeSelect = class {
|
|
|
1172
1379
|
usesVirtualScroll() {
|
|
1173
1380
|
return this.opts.virtualScroll !== false && this.rows.length > VIRTUAL_THRESHOLD;
|
|
1174
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
|
+
}
|
|
1175
1401
|
renderList() {
|
|
1176
1402
|
this.buildRows();
|
|
1177
1403
|
this.renderRows();
|
|
@@ -1188,26 +1414,40 @@ var ForgeSelect = class {
|
|
|
1188
1414
|
const virtual = this.usesVirtualScroll();
|
|
1189
1415
|
this.list.textContent = "";
|
|
1190
1416
|
const rowHeight = this.opts.itemHeight;
|
|
1417
|
+
const offsets = this.opts.variableItemHeight ? this.rowOffsets() : null;
|
|
1191
1418
|
let start = 0;
|
|
1192
1419
|
let end = this.rows.length;
|
|
1193
1420
|
if (virtual) {
|
|
1194
1421
|
const viewport = clientHeight || rowHeight * 8;
|
|
1195
|
-
|
|
1196
|
-
|
|
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
|
+
}
|
|
1197
1432
|
const topSpacer = document.createElement("li");
|
|
1198
1433
|
topSpacer.className = "forge-select__spacer";
|
|
1199
1434
|
topSpacer.setAttribute("aria-hidden", "true");
|
|
1200
|
-
topSpacer.style.height = `${start
|
|
1435
|
+
topSpacer.style.height = `${offsets?.[start] ?? this.rowOffset(start)}px`;
|
|
1201
1436
|
this.list.append(topSpacer);
|
|
1202
1437
|
}
|
|
1203
1438
|
for (let i = start; i < end; i++) {
|
|
1204
|
-
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
|
+
}
|
|
1205
1445
|
}
|
|
1206
1446
|
if (virtual) {
|
|
1207
1447
|
const bottomSpacer = document.createElement("li");
|
|
1208
1448
|
bottomSpacer.className = "forge-select__spacer";
|
|
1209
1449
|
bottomSpacer.setAttribute("aria-hidden", "true");
|
|
1210
|
-
bottomSpacer.style.height = `${
|
|
1450
|
+
bottomSpacer.style.height = `${offsets ? offsets[this.rows.length] - offsets[end] : this.rowOffset(this.rows.length) - this.rowOffset(end)}px`;
|
|
1211
1451
|
this.list.append(bottomSpacer);
|
|
1212
1452
|
if (this.list.scrollTop !== scrollTop) {
|
|
1213
1453
|
this.list.scrollTop = scrollTop;
|
|
@@ -1274,6 +1514,7 @@ var ForgeSelect = class {
|
|
|
1274
1514
|
if (isSelected) li.classList.add("forge-select__option--selected");
|
|
1275
1515
|
if (this.opts.multiple && row.hasChildren && computeCheckState(row.option, this.selected, this.isOptionDisabled) === "some") {
|
|
1276
1516
|
li.classList.add("forge-select__option--indeterminate");
|
|
1517
|
+
li.dataset.selectionState = "mixed";
|
|
1277
1518
|
}
|
|
1278
1519
|
if (row.depth > 0) {
|
|
1279
1520
|
li.style.paddingLeft = `calc(12px + ${row.depth} * var(--fs-tree-indent, 18px))`;
|
|
@@ -1309,6 +1550,28 @@ var ForgeSelect = class {
|
|
|
1309
1550
|
* cached content state-free.
|
|
1310
1551
|
*/
|
|
1311
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
|
+
}
|
|
1312
1575
|
let cached = this.rowContentCache.get(option.value);
|
|
1313
1576
|
if (!cached) {
|
|
1314
1577
|
const holder = document.createElement("span");
|
|
@@ -1335,8 +1598,8 @@ var ForgeSelect = class {
|
|
|
1335
1598
|
(row) => (row.kind === "option" || row.kind === "create") && row.navIndex === next
|
|
1336
1599
|
);
|
|
1337
1600
|
if (rowIndex >= 0) {
|
|
1338
|
-
const rowHeight = this.
|
|
1339
|
-
const top = rowIndex
|
|
1601
|
+
const rowHeight = this.measuredRowHeight(rowIndex);
|
|
1602
|
+
const top = this.rowOffset(rowIndex);
|
|
1340
1603
|
const viewport = this.list.clientHeight || rowHeight * 8;
|
|
1341
1604
|
let target = this.list.scrollTop;
|
|
1342
1605
|
if (top < target) target = top;
|
|
@@ -1401,7 +1664,7 @@ var ForgeSelect = class {
|
|
|
1401
1664
|
this.ajaxController = null;
|
|
1402
1665
|
this.page = 0;
|
|
1403
1666
|
this.hasMore = true;
|
|
1404
|
-
this.
|
|
1667
|
+
this.setLoading(true);
|
|
1405
1668
|
this.loadingMore = false;
|
|
1406
1669
|
this.loadError = null;
|
|
1407
1670
|
this.renderList();
|
|
@@ -1410,6 +1673,55 @@ var ForgeSelect = class {
|
|
|
1410
1673
|
void this.loadRemote(query, { requestId });
|
|
1411
1674
|
}, delay);
|
|
1412
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
|
+
}
|
|
1413
1725
|
/**
|
|
1414
1726
|
* Fires on every list scroll. Only acts when pagination is opted into via
|
|
1415
1727
|
* `ajax.pagination`; reads real scroll geometry rather than row counts so
|
|
@@ -1434,17 +1746,15 @@ var ForgeSelect = class {
|
|
|
1434
1746
|
this.ajaxController = controller;
|
|
1435
1747
|
const page = append ? this.page + 1 : 0;
|
|
1436
1748
|
try {
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
if (response.ok === false) throw new Error(`ForgeSelect: remote request failed with HTTP ${response.status}`);
|
|
1444
|
-
json = await response.json();
|
|
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);
|
|
1445
1755
|
}
|
|
1446
1756
|
if (activeRequestId !== this.ajaxRequestId || this.destroyed) return;
|
|
1447
|
-
const { options, hasMore } =
|
|
1757
|
+
const { options, hasMore } = result;
|
|
1448
1758
|
if (append) {
|
|
1449
1759
|
const existing = collectValues(this.data);
|
|
1450
1760
|
this.data = [...this.data, ...options.filter((o) => !existing.has(o.value))];
|
|
@@ -1469,7 +1779,7 @@ var ForgeSelect = class {
|
|
|
1469
1779
|
} finally {
|
|
1470
1780
|
if (activeRequestId === this.ajaxRequestId && !this.destroyed) {
|
|
1471
1781
|
this.ajaxController = null;
|
|
1472
|
-
this.
|
|
1782
|
+
this.setLoading(false);
|
|
1473
1783
|
this.loadingMore = false;
|
|
1474
1784
|
if (this.isOpen) this.renderList();
|
|
1475
1785
|
}
|