forge-select 0.4.0 → 0.6.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/dist/index.cjs CHANGED
@@ -183,6 +183,95 @@ 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 REMOTE_CACHE_LIMIT = 50;
188
+ var RemoteCache = class {
189
+ constructor() {
190
+ this.entries = /* @__PURE__ */ new Map();
191
+ }
192
+ get(key, now = Date.now()) {
193
+ const entry = this.entries.get(key);
194
+ if (!entry) return void 0;
195
+ if (entry.expiresAt <= now) {
196
+ this.entries.delete(key);
197
+ return void 0;
198
+ }
199
+ return entry.value;
200
+ }
201
+ set(key, value, ttl, now = Date.now()) {
202
+ if (ttl <= 0) return;
203
+ if (this.entries.size >= REMOTE_CACHE_LIMIT && !this.entries.has(key)) {
204
+ const oldest = this.entries.keys().next().value;
205
+ this.entries.delete(oldest);
206
+ }
207
+ this.entries.set(key, { value, expiresAt: now + ttl });
208
+ }
209
+ clear() {
210
+ this.entries.clear();
211
+ }
212
+ };
213
+
214
+ // src/search.ts
215
+ function normalizeSearchText(value, accentInsensitive = true) {
216
+ const lower = value.toLocaleLowerCase();
217
+ return accentInsensitive ? lower.normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/đ/g, "d") : lower;
218
+ }
219
+ function getSearchField(option, field) {
220
+ if (field === "label") return option.label;
221
+ if (field === "description") return option.description ?? "";
222
+ const path = field.slice(5).split(".");
223
+ let value = option.meta;
224
+ for (const key of path) {
225
+ if (!value || typeof value !== "object") return "";
226
+ value = value[key];
227
+ }
228
+ return value == null ? "" : String(value);
229
+ }
230
+ var SearchIndex = class {
231
+ constructor() {
232
+ this.cache = /* @__PURE__ */ new WeakMap();
233
+ }
234
+ clear() {
235
+ this.cache = /* @__PURE__ */ new WeakMap();
236
+ }
237
+ score(option, query, config) {
238
+ const normalizedQuery = normalizeSearchText(query.trim(), config.accentInsensitive);
239
+ if (!normalizedQuery) return 1;
240
+ if (config.scorer) return config.scorer(option, query.trim(), normalizedQuery);
241
+ const key = `${config.accentInsensitive ? "1" : "0"}:${config.fields.join("\0")}`;
242
+ let variants = this.cache.get(option);
243
+ if (!variants) {
244
+ variants = /* @__PURE__ */ new Map();
245
+ this.cache.set(option, variants);
246
+ }
247
+ let haystacks = variants.get(key);
248
+ if (!haystacks) {
249
+ haystacks = config.fields.map(
250
+ (field) => normalizeSearchText(getSearchField(option, field), config.accentInsensitive)
251
+ );
252
+ variants.set(key, haystacks);
253
+ }
254
+ const tokens = config.tokenSearch ? normalizedQuery.split(/\s+/).filter(Boolean) : [normalizedQuery];
255
+ if (!tokens.every((token) => haystacks.some((field) => field.includes(token)))) return 0;
256
+ const label = haystacks[config.fields.indexOf("label")];
257
+ if (label === normalizedQuery) return 4;
258
+ if (label?.startsWith(normalizedQuery)) return 3;
259
+ if (label?.includes(normalizedQuery)) return 2;
260
+ return 1;
261
+ }
262
+ };
263
+ function findNormalizedRanges(label, query, accentInsensitive = true) {
264
+ const tokens = normalizeSearchText(query.trim(), accentInsensitive).split(/\s+/).filter(Boolean);
265
+ if (!tokens.length) return [];
266
+ const normalized = normalizeSearchText(label, accentInsensitive);
267
+ const ranges = [];
268
+ for (const token of tokens) {
269
+ const index = normalized.indexOf(token);
270
+ if (index >= 0) ranges.push([index, index + token.length]);
271
+ }
272
+ return ranges.sort((a, b) => a[0] - b[0]);
273
+ }
274
+
186
275
  // src/selection.ts
187
276
  function isGroup(item) {
188
277
  return item.options !== void 0;
@@ -249,6 +338,8 @@ var DEFAULT_ITEM_HEIGHT = 36;
249
338
  var VIRTUAL_BUFFER = 5;
250
339
  var VIRTUAL_THRESHOLD = 100;
251
340
  var ROW_CACHE_LIMIT = 2e3;
341
+ var PAGE_SIZE = 10;
342
+ var TYPEAHEAD_RESET_MS = 500;
252
343
  var uidCounter = 0;
253
344
  var ForgeSelect = class {
254
345
  constructor(target, options = {}) {
@@ -266,7 +357,14 @@ var ForgeSelect = class {
266
357
  this.rows = [];
267
358
  this.navItems = [];
268
359
  this.highlightedIndex = -1;
360
+ this.typeaheadBuffer = "";
361
+ this.typeaheadTimer = null;
269
362
  this.rowContentCache = /* @__PURE__ */ new Map();
363
+ this.rowHeightCache = /* @__PURE__ */ new Map();
364
+ this.rowOffsetsCache = null;
365
+ this.scrollRafId = null;
366
+ this.ancestorScrollRafId = null;
367
+ this.searchIndex = new SearchIndex();
270
368
  this.expandedValues = /* @__PURE__ */ new Set();
271
369
  this.loading = false;
272
370
  this.loadingMore = false;
@@ -276,6 +374,7 @@ var ForgeSelect = class {
276
374
  this.ajaxRequestId = 0;
277
375
  this.ajaxController = null;
278
376
  this.remoteLoaded = false;
377
+ this.remoteCache = new RemoteCache();
279
378
  this.loadError = null;
280
379
  this.originalDisplay = "";
281
380
  this.originalDisabled = false;
@@ -293,7 +392,12 @@ var ForgeSelect = class {
293
392
  this.positionDropdown();
294
393
  };
295
394
  this.onAncestorScroll = () => {
296
- if (this.portalHost) this.positionDropdown();
395
+ if (!this.portalHost) return;
396
+ if (this.ancestorScrollRafId != null) return;
397
+ this.ancestorScrollRafId = requestAnimationFrame(() => {
398
+ this.ancestorScrollRafId = null;
399
+ this.positionDropdown();
400
+ });
297
401
  };
298
402
  this.onNativeInvalid = (event) => {
299
403
  event.preventDefault();
@@ -301,6 +405,7 @@ var ForgeSelect = class {
301
405
  this.control.setAttribute("aria-invalid", "true");
302
406
  if (!this.isOpen) this.open();
303
407
  this.control.focus();
408
+ this.emitter.emit("invalid", this.nativeSelect?.validationMessage ?? "");
304
409
  };
305
410
  this.onNativeChange = () => {
306
411
  if (!this.nativeSelect || this.destroyed || this.syncingNative) return;
@@ -339,11 +444,17 @@ var ForgeSelect = class {
339
444
  templateResult: options.templateResult,
340
445
  templateSelection: options.templateSelection,
341
446
  filterOption: options.filterOption,
447
+ searchFields: options.searchFields ?? ["label", "description"],
448
+ tokenSearch: options.tokenSearch ?? true,
449
+ accentInsensitive: options.accentInsensitive ?? true,
450
+ searchScorer: options.searchScorer,
451
+ highlightSearch: options.highlightSearch ?? false,
342
452
  minSearchLength: Math.max(0, Math.floor(options.minSearchLength ?? 0)),
343
453
  minResultsForSearch: Math.max(0, Math.floor(options.minResultsForSearch ?? 0)),
344
454
  isOptionDisabled: options.isOptionDisabled,
345
455
  virtualScroll: options.virtualScroll,
346
- itemHeight: options.itemHeight ?? DEFAULT_ITEM_HEIGHT,
456
+ itemHeight: typeof options.itemHeight === "number" ? Math.max(1, options.itemHeight) : DEFAULT_ITEM_HEIGHT,
457
+ variableItemHeight: options.itemHeight === "auto",
347
458
  language: options.language ?? "en",
348
459
  plugins: options.plugins ?? [],
349
460
  openOnFocus: options.openOnFocus ?? false,
@@ -367,6 +478,7 @@ var ForgeSelect = class {
367
478
  nativeSelect?.addEventListener("invalid", this.onNativeInvalid);
368
479
  this.nativeForm?.addEventListener("reset", this.onFormReset);
369
480
  for (const plugin of this.plugins) plugin.onInit?.(this);
481
+ for (const query of this.opts.ajax?.prefetch ?? []) void this.prefetchRemote(query);
370
482
  }
371
483
  applyNativeValues(values) {
372
484
  this.selected = [];
@@ -383,7 +495,7 @@ var ForgeSelect = class {
383
495
  this.root.classList.add("forge-select--open");
384
496
  this.control.setAttribute("aria-expanded", "true");
385
497
  document.addEventListener("mousedown", this.onDocumentMouseDown);
386
- if (this.opts.ajax && !this.remoteLoaded) {
498
+ if (this.opts.ajax && (this.opts.ajax.loadOnOpen ?? true) && !this.remoteLoaded) {
387
499
  this.scheduleRemoteLoad(this.query, 0);
388
500
  }
389
501
  this.renderList();
@@ -404,6 +516,19 @@ var ForgeSelect = class {
404
516
  document.removeEventListener("mousedown", this.onDocumentMouseDown);
405
517
  window.removeEventListener("resize", this.onWindowResize);
406
518
  document.removeEventListener("scroll", this.onAncestorScroll, true);
519
+ if (this.ancestorScrollRafId != null) {
520
+ cancelAnimationFrame(this.ancestorScrollRafId);
521
+ this.ancestorScrollRafId = null;
522
+ }
523
+ if (this.scrollRafId != null) {
524
+ cancelAnimationFrame(this.scrollRafId);
525
+ this.scrollRafId = null;
526
+ }
527
+ if (this.typeaheadTimer) {
528
+ clearTimeout(this.typeaheadTimer);
529
+ this.typeaheadTimer = null;
530
+ }
531
+ this.typeaheadBuffer = "";
407
532
  this.highlightedIndex = -1;
408
533
  if (this.searchInput) {
409
534
  this.searchInput.value = "";
@@ -436,10 +561,14 @@ var ForgeSelect = class {
436
561
  this.destroyed = true;
437
562
  if (this.ajaxTimer) clearTimeout(this.ajaxTimer);
438
563
  this.ajaxController?.abort();
564
+ if (this.scrollRafId != null) cancelAnimationFrame(this.scrollRafId);
565
+ if (this.typeaheadTimer) clearTimeout(this.typeaheadTimer);
439
566
  this.nativeSelect?.removeEventListener("change", this.onNativeChange);
440
567
  this.nativeSelect?.removeEventListener("invalid", this.onNativeInvalid);
441
568
  this.nativeForm?.removeEventListener("reset", this.onFormReset);
442
569
  this.rowContentCache.clear();
570
+ this.rowHeightCache.clear();
571
+ this.searchIndex.clear();
443
572
  this.portalHost?.remove();
444
573
  this.root.remove();
445
574
  this.el.style.display = this.originalDisplay;
@@ -450,6 +579,107 @@ var ForgeSelect = class {
450
579
  if (this.opts.multiple) return [...this.selected];
451
580
  return this.selected[0] ?? null;
452
581
  }
582
+ getSearchQuery() {
583
+ return this.query;
584
+ }
585
+ setSearchQuery(query, options = {}) {
586
+ this.applySearchQuery(query, options.emitSearch ?? true);
587
+ }
588
+ isDropdownOpen() {
589
+ return this.isOpen;
590
+ }
591
+ updateOptions(options) {
592
+ if (options.data) this.setData(options.data);
593
+ if ("ajax" in options && options.ajax !== this.opts.ajax) {
594
+ this.opts.ajax = options.ajax;
595
+ this.remoteLoaded = false;
596
+ this.clearRemoteCache();
597
+ }
598
+ if (options.placeholder !== void 0) this.opts.placeholder = options.placeholder;
599
+ if (options.clearable !== void 0) this.opts.clearable = options.clearable;
600
+ if (options.allowCreate !== void 0) this.opts.allowCreate = options.allowCreate;
601
+ if (options.sortable !== void 0) this.opts.sortable = options.sortable;
602
+ if (options.closeOnSelect !== void 0) this.opts.closeOnSelect = options.closeOnSelect;
603
+ if ("maxSelections" in options)
604
+ this.opts.maxSelections = options.maxSelections == null || !Number.isFinite(options.maxSelections) ? void 0 : Math.max(0, Math.floor(options.maxSelections));
605
+ if (options.theme !== void 0) {
606
+ this.opts.theme = options.theme;
607
+ this.root.dataset.theme = options.theme;
608
+ if (this.portalHost) this.portalHost.dataset.theme = options.theme;
609
+ }
610
+ if (options.required !== void 0) {
611
+ this.opts.required = options.required;
612
+ if (options.required) this.control.setAttribute("aria-required", "true");
613
+ else this.control.removeAttribute("aria-required");
614
+ if (this.nativeSelect) this.nativeSelect.required = options.required;
615
+ }
616
+ if (options.templateResult !== void 0) this.opts.templateResult = options.templateResult;
617
+ if (options.templateSelection !== void 0) this.opts.templateSelection = options.templateSelection;
618
+ if (options.filterOption !== void 0) this.opts.filterOption = options.filterOption;
619
+ if (options.searchFields !== void 0) this.opts.searchFields = options.searchFields;
620
+ if (options.tokenSearch !== void 0) this.opts.tokenSearch = options.tokenSearch;
621
+ if (options.accentInsensitive !== void 0) this.opts.accentInsensitive = options.accentInsensitive;
622
+ if (options.searchScorer !== void 0) this.opts.searchScorer = options.searchScorer;
623
+ if (options.highlightSearch !== void 0) this.opts.highlightSearch = options.highlightSearch;
624
+ if (options.minSearchLength !== void 0)
625
+ this.opts.minSearchLength = Math.max(0, Math.floor(options.minSearchLength));
626
+ if (options.minResultsForSearch !== void 0)
627
+ this.opts.minResultsForSearch = Math.max(0, Math.floor(options.minResultsForSearch));
628
+ if (options.isOptionDisabled !== void 0) this.opts.isOptionDisabled = options.isOptionDisabled;
629
+ if (options.virtualScroll !== void 0) this.opts.virtualScroll = options.virtualScroll;
630
+ if (options.itemHeight !== void 0) {
631
+ this.opts.variableItemHeight = options.itemHeight === "auto";
632
+ if (typeof options.itemHeight === "number") this.opts.itemHeight = Math.max(1, options.itemHeight);
633
+ this.root.style.setProperty("--fs-item-height", `${this.opts.itemHeight}px`);
634
+ this.portalHost?.style.setProperty("--fs-item-height", `${this.opts.itemHeight}px`);
635
+ }
636
+ if (options.language !== void 0) {
637
+ this.opts.language = options.language;
638
+ this.strings = getStrings(options.language);
639
+ this.clearBtn.setAttribute("aria-label", this.strings.clearSelection);
640
+ this.searchInput?.setAttribute("aria-label", this.strings.search);
641
+ }
642
+ if (options.openOnFocus !== void 0) this.opts.openOnFocus = options.openOnFocus;
643
+ if (options.disabled !== void 0) {
644
+ if (options.disabled) this.disable();
645
+ else this.enable();
646
+ }
647
+ this.root.classList.toggle("forge-select--sortable", this.opts.sortable && this.opts.multiple);
648
+ this.updateSearchVisibility();
649
+ this.rowContentCache.clear();
650
+ this.rowHeightCache.clear();
651
+ this.searchIndex.clear();
652
+ this.renderValue();
653
+ if (this.isOpen) this.renderList();
654
+ }
655
+ validate() {
656
+ const valid = (!this.opts.required || this.selected.length > 0) && (this.control.dataset.validationMessage ?? "") === "";
657
+ this.control.classList.toggle("forge-select__control--invalid", !valid);
658
+ this.control.setAttribute("aria-invalid", String(!valid));
659
+ return valid;
660
+ }
661
+ setCustomValidity(message) {
662
+ this.nativeSelect?.setCustomValidity(message);
663
+ this.control.dataset.validationMessage = message;
664
+ }
665
+ reportValidity() {
666
+ const valid = this.validate() && (this.nativeSelect?.checkValidity() ?? true);
667
+ if (!valid) {
668
+ const message = this.nativeSelect?.validationMessage ?? this.control.dataset.validationMessage ?? "";
669
+ if (this.nativeSelect) return this.nativeSelect.reportValidity();
670
+ this.emitter.emit("invalid", message);
671
+ }
672
+ return valid;
673
+ }
674
+ reload() {
675
+ if (!this.opts.ajax) return;
676
+ this.clearRemoteCache();
677
+ this.remoteLoaded = false;
678
+ this.scheduleRemoteLoad(this.query, 0);
679
+ }
680
+ clearRemoteCache() {
681
+ this.remoteCache.clear();
682
+ }
453
683
  setValue(value, options = {}) {
454
684
  const values = value == null ? [] : Array.isArray(value) ? value : [value];
455
685
  const next = this.opts.multiple ? values : values.slice(0, 1);
@@ -472,7 +702,7 @@ var ForgeSelect = class {
472
702
  this.ajaxController?.abort();
473
703
  this.ajaxController = null;
474
704
  this.ajaxRequestId += 1;
475
- this.loading = false;
705
+ this.setLoading(false);
476
706
  this.loadingMore = false;
477
707
  this.loadError = null;
478
708
  this.remoteLoaded = true;
@@ -482,6 +712,8 @@ var ForgeSelect = class {
482
712
  this.opts.data = data;
483
713
  this.updateSearchVisibility();
484
714
  this.rowContentCache.clear();
715
+ this.rowHeightCache.clear();
716
+ this.searchIndex.clear();
485
717
  this.highlightedIndex = -1;
486
718
  if (this.isOpen) this.renderList();
487
719
  }
@@ -655,25 +887,7 @@ var ForgeSelect = class {
655
887
  });
656
888
  if (this.searchInput) {
657
889
  this.searchInput.addEventListener("input", () => {
658
- this.query = this.searchInput.value;
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
- }
890
+ this.applySearchQuery(this.searchInput.value, true);
677
891
  });
678
892
  this.searchInput.addEventListener("keydown", (event) => this.handleKeydown(event));
679
893
  this.searchInput.addEventListener("paste", (event) => {
@@ -720,10 +934,36 @@ var ForgeSelect = class {
720
934
  this.activateNavItem(navIndex);
721
935
  });
722
936
  this.list.addEventListener("scroll", () => {
723
- if (this.usesVirtualScroll()) this.renderRows();
724
- this.maybeLoadNextPage();
937
+ if (this.scrollRafId != null) return;
938
+ this.scrollRafId = requestAnimationFrame(() => {
939
+ this.scrollRafId = null;
940
+ if (this.usesVirtualScroll()) this.renderRows();
941
+ this.maybeLoadNextPage();
942
+ });
725
943
  });
726
944
  }
945
+ applySearchQuery(query, emitSearch) {
946
+ this.query = query;
947
+ if (this.searchInput && this.searchInput.value !== query) this.searchInput.value = query;
948
+ this.highlightedIndex = -1;
949
+ this.list.scrollTop = 0;
950
+ if (emitSearch) this.emitter.emit("search", query);
951
+ const trimmed = query.trim();
952
+ const belowMinLength = trimmed !== "" && trimmed.length < this.opts.minSearchLength;
953
+ if (this.opts.ajax && !belowMinLength) {
954
+ this.scheduleRemoteLoad(query, this.opts.ajax.debounce ?? 250);
955
+ return;
956
+ }
957
+ if (belowMinLength) {
958
+ if (this.ajaxTimer) {
959
+ clearTimeout(this.ajaxTimer);
960
+ this.ajaxTimer = null;
961
+ }
962
+ this.ajaxController?.abort();
963
+ this.setLoading(false);
964
+ }
965
+ this.renderList();
966
+ }
727
967
  handleKeydown(event) {
728
968
  if (this.isDisabled) return;
729
969
  switch (event.key) {
@@ -760,9 +1000,63 @@ var ForgeSelect = class {
760
1000
  case "ArrowLeft":
761
1001
  if (this.isOpen && this.navigateTree("left")) event.preventDefault();
762
1002
  break;
1003
+ case "Home":
1004
+ if (this.isOpen) {
1005
+ event.preventDefault();
1006
+ this.focusNavIndex(0);
1007
+ }
1008
+ break;
1009
+ case "End":
1010
+ if (this.isOpen) {
1011
+ event.preventDefault();
1012
+ this.focusNavIndex(this.navItems.length - 1);
1013
+ }
1014
+ break;
1015
+ case "PageDown":
1016
+ if (this.isOpen) {
1017
+ event.preventDefault();
1018
+ this.focusNavIndex(
1019
+ Math.min(this.navItems.length - 1, (this.highlightedIndex === -1 ? 0 : this.highlightedIndex) + PAGE_SIZE)
1020
+ );
1021
+ }
1022
+ break;
1023
+ case "PageUp":
1024
+ if (this.isOpen) {
1025
+ event.preventDefault();
1026
+ this.focusNavIndex(Math.max(0, (this.highlightedIndex === -1 ? 0 : this.highlightedIndex) - PAGE_SIZE));
1027
+ }
1028
+ break;
763
1029
  case "Tab":
764
1030
  this.close();
765
1031
  break;
1032
+ default:
1033
+ if (this.isOpen && event.target === this.control && event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
1034
+ this.handleTypeahead(event.key);
1035
+ }
1036
+ break;
1037
+ }
1038
+ }
1039
+ /**
1040
+ * Jumps the highlight to the next nav item (wrapping) whose label starts
1041
+ * with the accumulated buffer, matching native <select> typeahead: rapid
1042
+ * distinct keystrokes narrow the prefix, a pause resets it.
1043
+ */
1044
+ handleTypeahead(char) {
1045
+ if (this.typeaheadTimer) clearTimeout(this.typeaheadTimer);
1046
+ this.typeaheadBuffer += normalizeSearchText(char, this.opts.accentInsensitive);
1047
+ this.typeaheadTimer = setTimeout(() => {
1048
+ this.typeaheadBuffer = "";
1049
+ this.typeaheadTimer = null;
1050
+ }, TYPEAHEAD_RESET_MS);
1051
+ const prefix = [...this.typeaheadBuffer].every((value) => value === this.typeaheadBuffer[0]) ? this.typeaheadBuffer[0] : this.typeaheadBuffer;
1052
+ const count = this.navItems.length;
1053
+ for (let step = 1; step <= count; step += 1) {
1054
+ const index = (this.highlightedIndex + step + count) % count;
1055
+ const item = this.navItems[index];
1056
+ if (item.kind === "option" && normalizeSearchText(item.option.label, this.opts.accentInsensitive).startsWith(prefix)) {
1057
+ this.focusNavIndex(index);
1058
+ return;
1059
+ }
766
1060
  }
767
1061
  }
768
1062
  // ---------------------------------------------------------------- selection
@@ -853,7 +1147,10 @@ var ForgeSelect = class {
853
1147
  this.control.classList.remove("forge-select__control--invalid");
854
1148
  this.control.removeAttribute("aria-invalid");
855
1149
  }
856
- if (this.isOpen) this.renderList();
1150
+ if (this.isOpen) {
1151
+ if (this.opts.maxSelections != null) this.renderList();
1152
+ else this.renderRows();
1153
+ }
857
1154
  if (emitChange) this.emitter.emit("change", this.getValue());
858
1155
  }
859
1156
  syncNativeSelect(dispatchChange = true) {
@@ -940,6 +1237,7 @@ var ForgeSelect = class {
940
1237
  if (result.created) this.emitter.emit("create", result.option);
941
1238
  this.emitter.emit("select", result.option);
942
1239
  if (!this.opts.multiple || this.opts.closeOnSelect) this.close();
1240
+ else if (this.isOpen) this.renderList();
943
1241
  }
944
1242
  activateNavItem(navIndex) {
945
1243
  const item = this.navItems[navIndex];
@@ -1114,9 +1412,15 @@ var ForgeSelect = class {
1114
1412
  buildRows() {
1115
1413
  this.rows = [];
1116
1414
  this.navItems = [];
1415
+ this.rowOffsetsCache = null;
1117
1416
  const trimmedQuery = this.query.trim();
1118
- const query = trimmedQuery.toLowerCase();
1119
- const matches = (option) => query === "" || (this.opts.filterOption ? this.opts.filterOption(option, trimmedQuery) : option.label.toLowerCase().includes(query) || (option.description?.toLowerCase().includes(query) ?? false));
1417
+ const query = normalizeSearchText(trimmedQuery, this.opts.accentInsensitive);
1418
+ const matches = (option) => query === "" || (this.opts.filterOption ? this.opts.filterOption(option, trimmedQuery) : this.searchIndex.score(option, trimmedQuery, {
1419
+ fields: this.opts.searchFields,
1420
+ tokenSearch: this.opts.tokenSearch,
1421
+ accentInsensitive: this.opts.accentInsensitive,
1422
+ scorer: this.opts.searchScorer
1423
+ }) > 0);
1120
1424
  const subtreeMatches = (option) => query === "" || matches(option) || (option.children ?? []).some(subtreeMatches);
1121
1425
  const pushOption = (option, depth, parentValue) => {
1122
1426
  let navIndex = -1;
@@ -1172,6 +1476,27 @@ var ForgeSelect = class {
1172
1476
  usesVirtualScroll() {
1173
1477
  return this.opts.virtualScroll !== false && this.rows.length > VIRTUAL_THRESHOLD;
1174
1478
  }
1479
+ rowKey(row, index) {
1480
+ if (row.kind === "option") return `option:${row.option.value}`;
1481
+ if (row.kind === "group") return `group:${row.label}:${index}`;
1482
+ return `${row.kind}:${index}`;
1483
+ }
1484
+ measuredRowHeight(index) {
1485
+ return this.opts.variableItemHeight ? this.rowHeightCache.get(this.rowKey(this.rows[index], index)) ?? this.opts.itemHeight : this.opts.itemHeight;
1486
+ }
1487
+ rowOffset(index) {
1488
+ if (!this.opts.variableItemHeight) return index * this.opts.itemHeight;
1489
+ let offset = 0;
1490
+ for (let i = 0; i < index; i += 1) offset += this.measuredRowHeight(i);
1491
+ return offset;
1492
+ }
1493
+ rowOffsets() {
1494
+ if (this.rowOffsetsCache) return this.rowOffsetsCache;
1495
+ const offsets = [0];
1496
+ for (let i = 0; i < this.rows.length; i += 1) offsets.push(offsets[i] + this.measuredRowHeight(i));
1497
+ this.rowOffsetsCache = offsets;
1498
+ return offsets;
1499
+ }
1175
1500
  renderList() {
1176
1501
  this.buildRows();
1177
1502
  this.renderRows();
@@ -1188,26 +1513,49 @@ var ForgeSelect = class {
1188
1513
  const virtual = this.usesVirtualScroll();
1189
1514
  this.list.textContent = "";
1190
1515
  const rowHeight = this.opts.itemHeight;
1516
+ const offsets = this.opts.variableItemHeight ? this.rowOffsets() : null;
1191
1517
  let start = 0;
1192
1518
  let end = this.rows.length;
1193
1519
  if (virtual) {
1194
1520
  const viewport = clientHeight || rowHeight * 8;
1195
- start = Math.max(0, Math.floor(scrollTop / rowHeight) - VIRTUAL_BUFFER);
1196
- end = Math.min(this.rows.length, start + Math.ceil(viewport / rowHeight) + VIRTUAL_BUFFER * 2);
1521
+ if (this.opts.variableItemHeight) {
1522
+ while (start < this.rows.length && offsets[start + 1] < scrollTop) start += 1;
1523
+ start = Math.max(0, start - VIRTUAL_BUFFER);
1524
+ end = start;
1525
+ const target = scrollTop + viewport + VIRTUAL_BUFFER * rowHeight;
1526
+ while (end < this.rows.length && offsets[end] < target) end += 1;
1527
+ } else {
1528
+ start = Math.max(0, Math.floor(scrollTop / rowHeight) - VIRTUAL_BUFFER);
1529
+ end = Math.min(this.rows.length, start + Math.ceil(viewport / rowHeight) + VIRTUAL_BUFFER * 2);
1530
+ }
1197
1531
  const topSpacer = document.createElement("li");
1198
1532
  topSpacer.className = "forge-select__spacer";
1199
1533
  topSpacer.setAttribute("aria-hidden", "true");
1200
- topSpacer.style.height = `${start * rowHeight}px`;
1534
+ topSpacer.style.height = `${offsets?.[start] ?? this.rowOffset(start)}px`;
1201
1535
  this.list.append(topSpacer);
1202
1536
  }
1537
+ const appended = [];
1203
1538
  for (let i = start; i < end; i++) {
1204
- this.list.append(this.renderRow(this.rows[i]));
1539
+ const element = this.renderRow(this.rows[i]);
1540
+ this.list.append(element);
1541
+ appended.push(element);
1542
+ }
1543
+ if (this.opts.variableItemHeight) {
1544
+ for (let i = start; i < end; i++) {
1545
+ const element = appended[i - start];
1546
+ const measured = element.getBoundingClientRect().height || element.offsetHeight;
1547
+ if (measured > 0) {
1548
+ const key = this.rowKey(this.rows[i], i);
1549
+ if (this.rowHeightCache.get(key) !== measured) this.rowOffsetsCache = null;
1550
+ this.rowHeightCache.set(key, measured);
1551
+ }
1552
+ }
1205
1553
  }
1206
1554
  if (virtual) {
1207
1555
  const bottomSpacer = document.createElement("li");
1208
1556
  bottomSpacer.className = "forge-select__spacer";
1209
1557
  bottomSpacer.setAttribute("aria-hidden", "true");
1210
- bottomSpacer.style.height = `${(this.rows.length - end) * rowHeight}px`;
1558
+ bottomSpacer.style.height = `${offsets ? offsets[this.rows.length] - offsets[end] : this.rowOffset(this.rows.length) - this.rowOffset(end)}px`;
1211
1559
  this.list.append(bottomSpacer);
1212
1560
  if (this.list.scrollTop !== scrollTop) {
1213
1561
  this.list.scrollTop = scrollTop;
@@ -1274,6 +1622,7 @@ var ForgeSelect = class {
1274
1622
  if (isSelected) li.classList.add("forge-select__option--selected");
1275
1623
  if (this.opts.multiple && row.hasChildren && computeCheckState(row.option, this.selected, this.isOptionDisabled) === "some") {
1276
1624
  li.classList.add("forge-select__option--indeterminate");
1625
+ li.dataset.selectionState = "mixed";
1277
1626
  }
1278
1627
  if (row.depth > 0) {
1279
1628
  li.style.paddingLeft = `calc(12px + ${row.depth} * var(--fs-tree-indent, 18px))`;
@@ -1309,6 +1658,28 @@ var ForgeSelect = class {
1309
1658
  * cached content state-free.
1310
1659
  */
1311
1660
  optionContent(option) {
1661
+ if (this.opts.highlightSearch && this.query.trim() && !this.opts.templateResult) {
1662
+ const holder = document.createElement("span");
1663
+ holder.className = "forge-select__option-content";
1664
+ renderOptionContent(holder, option, void 0);
1665
+ const label = holder.querySelector(".forge-select__option-label") ?? holder;
1666
+ const ranges = findNormalizedRanges(option.label, this.query, this.opts.accentInsensitive);
1667
+ if (ranges.length) {
1668
+ label.textContent = "";
1669
+ let cursor = 0;
1670
+ for (const [start, end] of ranges) {
1671
+ if (start < cursor) continue;
1672
+ label.append(document.createTextNode(option.label.slice(cursor, start)));
1673
+ const mark = document.createElement("mark");
1674
+ mark.className = "forge-select__match";
1675
+ mark.textContent = option.label.slice(start, end);
1676
+ label.append(mark);
1677
+ cursor = end;
1678
+ }
1679
+ label.append(document.createTextNode(option.label.slice(cursor)));
1680
+ }
1681
+ return holder;
1682
+ }
1312
1683
  let cached = this.rowContentCache.get(option.value);
1313
1684
  if (!cached) {
1314
1685
  const holder = document.createElement("span");
@@ -1329,14 +1700,15 @@ var ForgeSelect = class {
1329
1700
  this.focusNavIndex(next);
1330
1701
  }
1331
1702
  focusNavIndex(next) {
1703
+ if (this.navItems.length === 0) return;
1332
1704
  this.highlightedIndex = next;
1333
1705
  if (this.usesVirtualScroll()) {
1334
1706
  const rowIndex = this.rows.findIndex(
1335
1707
  (row) => (row.kind === "option" || row.kind === "create") && row.navIndex === next
1336
1708
  );
1337
1709
  if (rowIndex >= 0) {
1338
- const rowHeight = this.opts.itemHeight;
1339
- const top = rowIndex * rowHeight;
1710
+ const rowHeight = this.measuredRowHeight(rowIndex);
1711
+ const top = this.rowOffset(rowIndex);
1340
1712
  const viewport = this.list.clientHeight || rowHeight * 8;
1341
1713
  let target = this.list.scrollTop;
1342
1714
  if (top < target) target = top;
@@ -1401,7 +1773,7 @@ var ForgeSelect = class {
1401
1773
  this.ajaxController = null;
1402
1774
  this.page = 0;
1403
1775
  this.hasMore = true;
1404
- this.loading = true;
1776
+ this.setLoading(true);
1405
1777
  this.loadingMore = false;
1406
1778
  this.loadError = null;
1407
1779
  this.renderList();
@@ -1410,6 +1782,55 @@ var ForgeSelect = class {
1410
1782
  void this.loadRemote(query, { requestId });
1411
1783
  }, delay);
1412
1784
  }
1785
+ setLoading(loading) {
1786
+ if (this.loading === loading) return;
1787
+ this.loading = loading;
1788
+ this.emitter.emit("loading", loading);
1789
+ }
1790
+ remoteCacheKey(query, page) {
1791
+ return `${query}\0${page}`;
1792
+ }
1793
+ async requestRemote(query, page, signal) {
1794
+ const ajax = this.opts.ajax;
1795
+ const attempts = Math.max(0, Math.floor(ajax.retry ?? 0)) + 1;
1796
+ let lastError;
1797
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
1798
+ try {
1799
+ if (ajax.request) return await ajax.request(query, page, signal);
1800
+ const response = await fetch(buildUrl(ajax, query, page), { signal });
1801
+ if (response.ok === false) throw new Error(`ForgeSelect: remote request failed with HTTP ${response.status}`);
1802
+ return await response.json();
1803
+ } catch (error) {
1804
+ lastError = error;
1805
+ if (signal.aborted || attempt === attempts - 1) throw error;
1806
+ const delay = Math.max(0, ajax.retryDelay ?? 250) * 2 ** attempt;
1807
+ await new Promise((resolve, reject) => {
1808
+ const timer = setTimeout(resolve, delay);
1809
+ signal.addEventListener(
1810
+ "abort",
1811
+ () => {
1812
+ clearTimeout(timer);
1813
+ reject(new DOMException("Aborted", "AbortError"));
1814
+ },
1815
+ { once: true }
1816
+ );
1817
+ });
1818
+ }
1819
+ }
1820
+ throw lastError;
1821
+ }
1822
+ async prefetchRemote(query) {
1823
+ const ajax = this.opts.ajax;
1824
+ if (!ajax || (ajax.cacheTtl ?? 3e4) <= 0) return;
1825
+ const key = this.remoteCacheKey(query, 0);
1826
+ if (this.remoteCache.get(key)) return;
1827
+ const controller = new AbortController();
1828
+ try {
1829
+ const json = await this.requestRemote(query, 0, controller.signal);
1830
+ this.remoteCache.set(key, normalizeRemoteResult(ajax, json), ajax.cacheTtl ?? 3e4);
1831
+ } catch {
1832
+ }
1833
+ }
1413
1834
  /**
1414
1835
  * Fires on every list scroll. Only acts when pagination is opted into via
1415
1836
  * `ajax.pagination`; reads real scroll geometry rather than row counts so
@@ -1434,23 +1855,22 @@ var ForgeSelect = class {
1434
1855
  this.ajaxController = controller;
1435
1856
  const page = append ? this.page + 1 : 0;
1436
1857
  try {
1437
- let json;
1438
- if (ajax.request) {
1439
- json = await ajax.request(query, page, controller.signal);
1440
- } else {
1441
- const url = buildUrl(ajax, query, page);
1442
- const response = await fetch(url, { signal: controller.signal });
1443
- if (response.ok === false) throw new Error(`ForgeSelect: remote request failed with HTTP ${response.status}`);
1444
- json = await response.json();
1858
+ const key = this.remoteCacheKey(query, page);
1859
+ let result = this.remoteCache.get(key);
1860
+ if (!result) {
1861
+ const json = await this.requestRemote(query, page, controller.signal);
1862
+ result = normalizeRemoteResult(ajax, json);
1863
+ this.remoteCache.set(key, result, ajax.cacheTtl ?? 3e4);
1445
1864
  }
1446
1865
  if (activeRequestId !== this.ajaxRequestId || this.destroyed) return;
1447
- const { options, hasMore } = normalizeRemoteResult(ajax, json);
1866
+ const { options, hasMore } = result;
1448
1867
  if (append) {
1449
1868
  const existing = collectValues(this.data);
1450
1869
  this.data = [...this.data, ...options.filter((o) => !existing.has(o.value))];
1451
1870
  } else {
1452
1871
  this.data = options;
1453
1872
  this.rowContentCache.clear();
1873
+ this.rowHeightCache.clear();
1454
1874
  }
1455
1875
  this.page = page;
1456
1876
  this.hasMore = hasMore;
@@ -1462,6 +1882,7 @@ var ForgeSelect = class {
1462
1882
  if (!append) {
1463
1883
  this.data = [];
1464
1884
  this.rowContentCache.clear();
1885
+ this.rowHeightCache.clear();
1465
1886
  }
1466
1887
  this.hasMore = false;
1467
1888
  this.loadError = error;
@@ -1469,7 +1890,7 @@ var ForgeSelect = class {
1469
1890
  } finally {
1470
1891
  if (activeRequestId === this.ajaxRequestId && !this.destroyed) {
1471
1892
  this.ajaxController = null;
1472
- this.loading = false;
1893
+ this.setLoading(false);
1473
1894
  this.loadingMore = false;
1474
1895
  if (this.isOpen) this.renderList();
1475
1896
  }