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.js CHANGED
@@ -156,6 +156,95 @@ function normalizeRemoteResult(ajax, response) {
156
156
  return { options: result.options, hasMore: ajax.pagination ? Boolean(result.hasMore) : false };
157
157
  }
158
158
 
159
+ // src/remote-cache.ts
160
+ var REMOTE_CACHE_LIMIT = 50;
161
+ var RemoteCache = class {
162
+ constructor() {
163
+ this.entries = /* @__PURE__ */ new Map();
164
+ }
165
+ get(key, now = Date.now()) {
166
+ const entry = this.entries.get(key);
167
+ if (!entry) return void 0;
168
+ if (entry.expiresAt <= now) {
169
+ this.entries.delete(key);
170
+ return void 0;
171
+ }
172
+ return entry.value;
173
+ }
174
+ set(key, value, ttl, now = Date.now()) {
175
+ if (ttl <= 0) return;
176
+ if (this.entries.size >= REMOTE_CACHE_LIMIT && !this.entries.has(key)) {
177
+ const oldest = this.entries.keys().next().value;
178
+ this.entries.delete(oldest);
179
+ }
180
+ this.entries.set(key, { value, expiresAt: now + ttl });
181
+ }
182
+ clear() {
183
+ this.entries.clear();
184
+ }
185
+ };
186
+
187
+ // src/search.ts
188
+ function normalizeSearchText(value, accentInsensitive = true) {
189
+ const lower = value.toLocaleLowerCase();
190
+ return accentInsensitive ? lower.normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/đ/g, "d") : lower;
191
+ }
192
+ function getSearchField(option, field) {
193
+ if (field === "label") return option.label;
194
+ if (field === "description") return option.description ?? "";
195
+ const path = field.slice(5).split(".");
196
+ let value = option.meta;
197
+ for (const key of path) {
198
+ if (!value || typeof value !== "object") return "";
199
+ value = value[key];
200
+ }
201
+ return value == null ? "" : String(value);
202
+ }
203
+ var SearchIndex = class {
204
+ constructor() {
205
+ this.cache = /* @__PURE__ */ new WeakMap();
206
+ }
207
+ clear() {
208
+ this.cache = /* @__PURE__ */ new WeakMap();
209
+ }
210
+ score(option, query, config) {
211
+ const normalizedQuery = normalizeSearchText(query.trim(), config.accentInsensitive);
212
+ if (!normalizedQuery) return 1;
213
+ if (config.scorer) return config.scorer(option, query.trim(), normalizedQuery);
214
+ const key = `${config.accentInsensitive ? "1" : "0"}:${config.fields.join("\0")}`;
215
+ let variants = this.cache.get(option);
216
+ if (!variants) {
217
+ variants = /* @__PURE__ */ new Map();
218
+ this.cache.set(option, variants);
219
+ }
220
+ let haystacks = variants.get(key);
221
+ if (!haystacks) {
222
+ haystacks = config.fields.map(
223
+ (field) => normalizeSearchText(getSearchField(option, field), config.accentInsensitive)
224
+ );
225
+ variants.set(key, haystacks);
226
+ }
227
+ const tokens = config.tokenSearch ? normalizedQuery.split(/\s+/).filter(Boolean) : [normalizedQuery];
228
+ if (!tokens.every((token) => haystacks.some((field) => field.includes(token)))) return 0;
229
+ const label = haystacks[config.fields.indexOf("label")];
230
+ if (label === normalizedQuery) return 4;
231
+ if (label?.startsWith(normalizedQuery)) return 3;
232
+ if (label?.includes(normalizedQuery)) return 2;
233
+ return 1;
234
+ }
235
+ };
236
+ function findNormalizedRanges(label, query, accentInsensitive = true) {
237
+ const tokens = normalizeSearchText(query.trim(), accentInsensitive).split(/\s+/).filter(Boolean);
238
+ if (!tokens.length) return [];
239
+ const normalized = normalizeSearchText(label, accentInsensitive);
240
+ const ranges = [];
241
+ for (const token of tokens) {
242
+ const index = normalized.indexOf(token);
243
+ if (index >= 0) ranges.push([index, index + token.length]);
244
+ }
245
+ return ranges.sort((a, b) => a[0] - b[0]);
246
+ }
247
+
159
248
  // src/selection.ts
160
249
  function isGroup(item) {
161
250
  return item.options !== void 0;
@@ -222,6 +311,8 @@ var DEFAULT_ITEM_HEIGHT = 36;
222
311
  var VIRTUAL_BUFFER = 5;
223
312
  var VIRTUAL_THRESHOLD = 100;
224
313
  var ROW_CACHE_LIMIT = 2e3;
314
+ var PAGE_SIZE = 10;
315
+ var TYPEAHEAD_RESET_MS = 500;
225
316
  var uidCounter = 0;
226
317
  var ForgeSelect = class {
227
318
  constructor(target, options = {}) {
@@ -239,7 +330,14 @@ var ForgeSelect = class {
239
330
  this.rows = [];
240
331
  this.navItems = [];
241
332
  this.highlightedIndex = -1;
333
+ this.typeaheadBuffer = "";
334
+ this.typeaheadTimer = null;
242
335
  this.rowContentCache = /* @__PURE__ */ new Map();
336
+ this.rowHeightCache = /* @__PURE__ */ new Map();
337
+ this.rowOffsetsCache = null;
338
+ this.scrollRafId = null;
339
+ this.ancestorScrollRafId = null;
340
+ this.searchIndex = new SearchIndex();
243
341
  this.expandedValues = /* @__PURE__ */ new Set();
244
342
  this.loading = false;
245
343
  this.loadingMore = false;
@@ -249,6 +347,7 @@ var ForgeSelect = class {
249
347
  this.ajaxRequestId = 0;
250
348
  this.ajaxController = null;
251
349
  this.remoteLoaded = false;
350
+ this.remoteCache = new RemoteCache();
252
351
  this.loadError = null;
253
352
  this.originalDisplay = "";
254
353
  this.originalDisabled = false;
@@ -266,7 +365,12 @@ var ForgeSelect = class {
266
365
  this.positionDropdown();
267
366
  };
268
367
  this.onAncestorScroll = () => {
269
- if (this.portalHost) this.positionDropdown();
368
+ if (!this.portalHost) return;
369
+ if (this.ancestorScrollRafId != null) return;
370
+ this.ancestorScrollRafId = requestAnimationFrame(() => {
371
+ this.ancestorScrollRafId = null;
372
+ this.positionDropdown();
373
+ });
270
374
  };
271
375
  this.onNativeInvalid = (event) => {
272
376
  event.preventDefault();
@@ -274,6 +378,7 @@ var ForgeSelect = class {
274
378
  this.control.setAttribute("aria-invalid", "true");
275
379
  if (!this.isOpen) this.open();
276
380
  this.control.focus();
381
+ this.emitter.emit("invalid", this.nativeSelect?.validationMessage ?? "");
277
382
  };
278
383
  this.onNativeChange = () => {
279
384
  if (!this.nativeSelect || this.destroyed || this.syncingNative) return;
@@ -312,11 +417,17 @@ var ForgeSelect = class {
312
417
  templateResult: options.templateResult,
313
418
  templateSelection: options.templateSelection,
314
419
  filterOption: options.filterOption,
420
+ searchFields: options.searchFields ?? ["label", "description"],
421
+ tokenSearch: options.tokenSearch ?? true,
422
+ accentInsensitive: options.accentInsensitive ?? true,
423
+ searchScorer: options.searchScorer,
424
+ highlightSearch: options.highlightSearch ?? false,
315
425
  minSearchLength: Math.max(0, Math.floor(options.minSearchLength ?? 0)),
316
426
  minResultsForSearch: Math.max(0, Math.floor(options.minResultsForSearch ?? 0)),
317
427
  isOptionDisabled: options.isOptionDisabled,
318
428
  virtualScroll: options.virtualScroll,
319
- itemHeight: options.itemHeight ?? DEFAULT_ITEM_HEIGHT,
429
+ itemHeight: typeof options.itemHeight === "number" ? Math.max(1, options.itemHeight) : DEFAULT_ITEM_HEIGHT,
430
+ variableItemHeight: options.itemHeight === "auto",
320
431
  language: options.language ?? "en",
321
432
  plugins: options.plugins ?? [],
322
433
  openOnFocus: options.openOnFocus ?? false,
@@ -340,6 +451,7 @@ var ForgeSelect = class {
340
451
  nativeSelect?.addEventListener("invalid", this.onNativeInvalid);
341
452
  this.nativeForm?.addEventListener("reset", this.onFormReset);
342
453
  for (const plugin of this.plugins) plugin.onInit?.(this);
454
+ for (const query of this.opts.ajax?.prefetch ?? []) void this.prefetchRemote(query);
343
455
  }
344
456
  applyNativeValues(values) {
345
457
  this.selected = [];
@@ -356,7 +468,7 @@ var ForgeSelect = class {
356
468
  this.root.classList.add("forge-select--open");
357
469
  this.control.setAttribute("aria-expanded", "true");
358
470
  document.addEventListener("mousedown", this.onDocumentMouseDown);
359
- if (this.opts.ajax && !this.remoteLoaded) {
471
+ if (this.opts.ajax && (this.opts.ajax.loadOnOpen ?? true) && !this.remoteLoaded) {
360
472
  this.scheduleRemoteLoad(this.query, 0);
361
473
  }
362
474
  this.renderList();
@@ -377,6 +489,19 @@ var ForgeSelect = class {
377
489
  document.removeEventListener("mousedown", this.onDocumentMouseDown);
378
490
  window.removeEventListener("resize", this.onWindowResize);
379
491
  document.removeEventListener("scroll", this.onAncestorScroll, true);
492
+ if (this.ancestorScrollRafId != null) {
493
+ cancelAnimationFrame(this.ancestorScrollRafId);
494
+ this.ancestorScrollRafId = null;
495
+ }
496
+ if (this.scrollRafId != null) {
497
+ cancelAnimationFrame(this.scrollRafId);
498
+ this.scrollRafId = null;
499
+ }
500
+ if (this.typeaheadTimer) {
501
+ clearTimeout(this.typeaheadTimer);
502
+ this.typeaheadTimer = null;
503
+ }
504
+ this.typeaheadBuffer = "";
380
505
  this.highlightedIndex = -1;
381
506
  if (this.searchInput) {
382
507
  this.searchInput.value = "";
@@ -409,10 +534,14 @@ var ForgeSelect = class {
409
534
  this.destroyed = true;
410
535
  if (this.ajaxTimer) clearTimeout(this.ajaxTimer);
411
536
  this.ajaxController?.abort();
537
+ if (this.scrollRafId != null) cancelAnimationFrame(this.scrollRafId);
538
+ if (this.typeaheadTimer) clearTimeout(this.typeaheadTimer);
412
539
  this.nativeSelect?.removeEventListener("change", this.onNativeChange);
413
540
  this.nativeSelect?.removeEventListener("invalid", this.onNativeInvalid);
414
541
  this.nativeForm?.removeEventListener("reset", this.onFormReset);
415
542
  this.rowContentCache.clear();
543
+ this.rowHeightCache.clear();
544
+ this.searchIndex.clear();
416
545
  this.portalHost?.remove();
417
546
  this.root.remove();
418
547
  this.el.style.display = this.originalDisplay;
@@ -423,6 +552,107 @@ var ForgeSelect = class {
423
552
  if (this.opts.multiple) return [...this.selected];
424
553
  return this.selected[0] ?? null;
425
554
  }
555
+ getSearchQuery() {
556
+ return this.query;
557
+ }
558
+ setSearchQuery(query, options = {}) {
559
+ this.applySearchQuery(query, options.emitSearch ?? true);
560
+ }
561
+ isDropdownOpen() {
562
+ return this.isOpen;
563
+ }
564
+ updateOptions(options) {
565
+ if (options.data) this.setData(options.data);
566
+ if ("ajax" in options && options.ajax !== this.opts.ajax) {
567
+ this.opts.ajax = options.ajax;
568
+ this.remoteLoaded = false;
569
+ this.clearRemoteCache();
570
+ }
571
+ if (options.placeholder !== void 0) this.opts.placeholder = options.placeholder;
572
+ if (options.clearable !== void 0) this.opts.clearable = options.clearable;
573
+ if (options.allowCreate !== void 0) this.opts.allowCreate = options.allowCreate;
574
+ if (options.sortable !== void 0) this.opts.sortable = options.sortable;
575
+ if (options.closeOnSelect !== void 0) this.opts.closeOnSelect = options.closeOnSelect;
576
+ if ("maxSelections" in options)
577
+ this.opts.maxSelections = options.maxSelections == null || !Number.isFinite(options.maxSelections) ? void 0 : Math.max(0, Math.floor(options.maxSelections));
578
+ if (options.theme !== void 0) {
579
+ this.opts.theme = options.theme;
580
+ this.root.dataset.theme = options.theme;
581
+ if (this.portalHost) this.portalHost.dataset.theme = options.theme;
582
+ }
583
+ if (options.required !== void 0) {
584
+ this.opts.required = options.required;
585
+ if (options.required) this.control.setAttribute("aria-required", "true");
586
+ else this.control.removeAttribute("aria-required");
587
+ if (this.nativeSelect) this.nativeSelect.required = options.required;
588
+ }
589
+ if (options.templateResult !== void 0) this.opts.templateResult = options.templateResult;
590
+ if (options.templateSelection !== void 0) this.opts.templateSelection = options.templateSelection;
591
+ if (options.filterOption !== void 0) this.opts.filterOption = options.filterOption;
592
+ if (options.searchFields !== void 0) this.opts.searchFields = options.searchFields;
593
+ if (options.tokenSearch !== void 0) this.opts.tokenSearch = options.tokenSearch;
594
+ if (options.accentInsensitive !== void 0) this.opts.accentInsensitive = options.accentInsensitive;
595
+ if (options.searchScorer !== void 0) this.opts.searchScorer = options.searchScorer;
596
+ if (options.highlightSearch !== void 0) this.opts.highlightSearch = options.highlightSearch;
597
+ if (options.minSearchLength !== void 0)
598
+ this.opts.minSearchLength = Math.max(0, Math.floor(options.minSearchLength));
599
+ if (options.minResultsForSearch !== void 0)
600
+ this.opts.minResultsForSearch = Math.max(0, Math.floor(options.minResultsForSearch));
601
+ if (options.isOptionDisabled !== void 0) this.opts.isOptionDisabled = options.isOptionDisabled;
602
+ if (options.virtualScroll !== void 0) this.opts.virtualScroll = options.virtualScroll;
603
+ if (options.itemHeight !== void 0) {
604
+ this.opts.variableItemHeight = options.itemHeight === "auto";
605
+ if (typeof options.itemHeight === "number") this.opts.itemHeight = Math.max(1, options.itemHeight);
606
+ this.root.style.setProperty("--fs-item-height", `${this.opts.itemHeight}px`);
607
+ this.portalHost?.style.setProperty("--fs-item-height", `${this.opts.itemHeight}px`);
608
+ }
609
+ if (options.language !== void 0) {
610
+ this.opts.language = options.language;
611
+ this.strings = getStrings(options.language);
612
+ this.clearBtn.setAttribute("aria-label", this.strings.clearSelection);
613
+ this.searchInput?.setAttribute("aria-label", this.strings.search);
614
+ }
615
+ if (options.openOnFocus !== void 0) this.opts.openOnFocus = options.openOnFocus;
616
+ if (options.disabled !== void 0) {
617
+ if (options.disabled) this.disable();
618
+ else this.enable();
619
+ }
620
+ this.root.classList.toggle("forge-select--sortable", this.opts.sortable && this.opts.multiple);
621
+ this.updateSearchVisibility();
622
+ this.rowContentCache.clear();
623
+ this.rowHeightCache.clear();
624
+ this.searchIndex.clear();
625
+ this.renderValue();
626
+ if (this.isOpen) this.renderList();
627
+ }
628
+ validate() {
629
+ const valid = (!this.opts.required || this.selected.length > 0) && (this.control.dataset.validationMessage ?? "") === "";
630
+ this.control.classList.toggle("forge-select__control--invalid", !valid);
631
+ this.control.setAttribute("aria-invalid", String(!valid));
632
+ return valid;
633
+ }
634
+ setCustomValidity(message) {
635
+ this.nativeSelect?.setCustomValidity(message);
636
+ this.control.dataset.validationMessage = message;
637
+ }
638
+ reportValidity() {
639
+ const valid = this.validate() && (this.nativeSelect?.checkValidity() ?? true);
640
+ if (!valid) {
641
+ const message = this.nativeSelect?.validationMessage ?? this.control.dataset.validationMessage ?? "";
642
+ if (this.nativeSelect) return this.nativeSelect.reportValidity();
643
+ this.emitter.emit("invalid", message);
644
+ }
645
+ return valid;
646
+ }
647
+ reload() {
648
+ if (!this.opts.ajax) return;
649
+ this.clearRemoteCache();
650
+ this.remoteLoaded = false;
651
+ this.scheduleRemoteLoad(this.query, 0);
652
+ }
653
+ clearRemoteCache() {
654
+ this.remoteCache.clear();
655
+ }
426
656
  setValue(value, options = {}) {
427
657
  const values = value == null ? [] : Array.isArray(value) ? value : [value];
428
658
  const next = this.opts.multiple ? values : values.slice(0, 1);
@@ -445,7 +675,7 @@ var ForgeSelect = class {
445
675
  this.ajaxController?.abort();
446
676
  this.ajaxController = null;
447
677
  this.ajaxRequestId += 1;
448
- this.loading = false;
678
+ this.setLoading(false);
449
679
  this.loadingMore = false;
450
680
  this.loadError = null;
451
681
  this.remoteLoaded = true;
@@ -455,6 +685,8 @@ var ForgeSelect = class {
455
685
  this.opts.data = data;
456
686
  this.updateSearchVisibility();
457
687
  this.rowContentCache.clear();
688
+ this.rowHeightCache.clear();
689
+ this.searchIndex.clear();
458
690
  this.highlightedIndex = -1;
459
691
  if (this.isOpen) this.renderList();
460
692
  }
@@ -628,25 +860,7 @@ var ForgeSelect = class {
628
860
  });
629
861
  if (this.searchInput) {
630
862
  this.searchInput.addEventListener("input", () => {
631
- this.query = this.searchInput.value;
632
- this.highlightedIndex = -1;
633
- this.list.scrollTop = 0;
634
- this.emitter.emit("search", this.query);
635
- const trimmed = this.query.trim();
636
- const belowMinLength = trimmed !== "" && trimmed.length < this.opts.minSearchLength;
637
- if (this.opts.ajax && !belowMinLength) {
638
- this.scheduleRemoteLoad(this.query, this.opts.ajax.debounce ?? 250);
639
- } else {
640
- if (belowMinLength) {
641
- if (this.ajaxTimer) {
642
- clearTimeout(this.ajaxTimer);
643
- this.ajaxTimer = null;
644
- }
645
- this.ajaxController?.abort();
646
- this.loading = false;
647
- }
648
- this.renderList();
649
- }
863
+ this.applySearchQuery(this.searchInput.value, true);
650
864
  });
651
865
  this.searchInput.addEventListener("keydown", (event) => this.handleKeydown(event));
652
866
  this.searchInput.addEventListener("paste", (event) => {
@@ -693,10 +907,36 @@ var ForgeSelect = class {
693
907
  this.activateNavItem(navIndex);
694
908
  });
695
909
  this.list.addEventListener("scroll", () => {
696
- if (this.usesVirtualScroll()) this.renderRows();
697
- this.maybeLoadNextPage();
910
+ if (this.scrollRafId != null) return;
911
+ this.scrollRafId = requestAnimationFrame(() => {
912
+ this.scrollRafId = null;
913
+ if (this.usesVirtualScroll()) this.renderRows();
914
+ this.maybeLoadNextPage();
915
+ });
698
916
  });
699
917
  }
918
+ applySearchQuery(query, emitSearch) {
919
+ this.query = query;
920
+ if (this.searchInput && this.searchInput.value !== query) this.searchInput.value = query;
921
+ this.highlightedIndex = -1;
922
+ this.list.scrollTop = 0;
923
+ if (emitSearch) this.emitter.emit("search", query);
924
+ const trimmed = query.trim();
925
+ const belowMinLength = trimmed !== "" && trimmed.length < this.opts.minSearchLength;
926
+ if (this.opts.ajax && !belowMinLength) {
927
+ this.scheduleRemoteLoad(query, this.opts.ajax.debounce ?? 250);
928
+ return;
929
+ }
930
+ if (belowMinLength) {
931
+ if (this.ajaxTimer) {
932
+ clearTimeout(this.ajaxTimer);
933
+ this.ajaxTimer = null;
934
+ }
935
+ this.ajaxController?.abort();
936
+ this.setLoading(false);
937
+ }
938
+ this.renderList();
939
+ }
700
940
  handleKeydown(event) {
701
941
  if (this.isDisabled) return;
702
942
  switch (event.key) {
@@ -733,9 +973,63 @@ var ForgeSelect = class {
733
973
  case "ArrowLeft":
734
974
  if (this.isOpen && this.navigateTree("left")) event.preventDefault();
735
975
  break;
976
+ case "Home":
977
+ if (this.isOpen) {
978
+ event.preventDefault();
979
+ this.focusNavIndex(0);
980
+ }
981
+ break;
982
+ case "End":
983
+ if (this.isOpen) {
984
+ event.preventDefault();
985
+ this.focusNavIndex(this.navItems.length - 1);
986
+ }
987
+ break;
988
+ case "PageDown":
989
+ if (this.isOpen) {
990
+ event.preventDefault();
991
+ this.focusNavIndex(
992
+ Math.min(this.navItems.length - 1, (this.highlightedIndex === -1 ? 0 : this.highlightedIndex) + PAGE_SIZE)
993
+ );
994
+ }
995
+ break;
996
+ case "PageUp":
997
+ if (this.isOpen) {
998
+ event.preventDefault();
999
+ this.focusNavIndex(Math.max(0, (this.highlightedIndex === -1 ? 0 : this.highlightedIndex) - PAGE_SIZE));
1000
+ }
1001
+ break;
736
1002
  case "Tab":
737
1003
  this.close();
738
1004
  break;
1005
+ default:
1006
+ if (this.isOpen && event.target === this.control && event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
1007
+ this.handleTypeahead(event.key);
1008
+ }
1009
+ break;
1010
+ }
1011
+ }
1012
+ /**
1013
+ * Jumps the highlight to the next nav item (wrapping) whose label starts
1014
+ * with the accumulated buffer, matching native <select> typeahead: rapid
1015
+ * distinct keystrokes narrow the prefix, a pause resets it.
1016
+ */
1017
+ handleTypeahead(char) {
1018
+ if (this.typeaheadTimer) clearTimeout(this.typeaheadTimer);
1019
+ this.typeaheadBuffer += normalizeSearchText(char, this.opts.accentInsensitive);
1020
+ this.typeaheadTimer = setTimeout(() => {
1021
+ this.typeaheadBuffer = "";
1022
+ this.typeaheadTimer = null;
1023
+ }, TYPEAHEAD_RESET_MS);
1024
+ const prefix = [...this.typeaheadBuffer].every((value) => value === this.typeaheadBuffer[0]) ? this.typeaheadBuffer[0] : this.typeaheadBuffer;
1025
+ const count = this.navItems.length;
1026
+ for (let step = 1; step <= count; step += 1) {
1027
+ const index = (this.highlightedIndex + step + count) % count;
1028
+ const item = this.navItems[index];
1029
+ if (item.kind === "option" && normalizeSearchText(item.option.label, this.opts.accentInsensitive).startsWith(prefix)) {
1030
+ this.focusNavIndex(index);
1031
+ return;
1032
+ }
739
1033
  }
740
1034
  }
741
1035
  // ---------------------------------------------------------------- selection
@@ -826,7 +1120,10 @@ var ForgeSelect = class {
826
1120
  this.control.classList.remove("forge-select__control--invalid");
827
1121
  this.control.removeAttribute("aria-invalid");
828
1122
  }
829
- if (this.isOpen) this.renderList();
1123
+ if (this.isOpen) {
1124
+ if (this.opts.maxSelections != null) this.renderList();
1125
+ else this.renderRows();
1126
+ }
830
1127
  if (emitChange) this.emitter.emit("change", this.getValue());
831
1128
  }
832
1129
  syncNativeSelect(dispatchChange = true) {
@@ -913,6 +1210,7 @@ var ForgeSelect = class {
913
1210
  if (result.created) this.emitter.emit("create", result.option);
914
1211
  this.emitter.emit("select", result.option);
915
1212
  if (!this.opts.multiple || this.opts.closeOnSelect) this.close();
1213
+ else if (this.isOpen) this.renderList();
916
1214
  }
917
1215
  activateNavItem(navIndex) {
918
1216
  const item = this.navItems[navIndex];
@@ -1087,9 +1385,15 @@ var ForgeSelect = class {
1087
1385
  buildRows() {
1088
1386
  this.rows = [];
1089
1387
  this.navItems = [];
1388
+ this.rowOffsetsCache = null;
1090
1389
  const trimmedQuery = this.query.trim();
1091
- const query = trimmedQuery.toLowerCase();
1092
- const matches = (option) => query === "" || (this.opts.filterOption ? this.opts.filterOption(option, trimmedQuery) : option.label.toLowerCase().includes(query) || (option.description?.toLowerCase().includes(query) ?? false));
1390
+ const query = normalizeSearchText(trimmedQuery, this.opts.accentInsensitive);
1391
+ const matches = (option) => query === "" || (this.opts.filterOption ? this.opts.filterOption(option, trimmedQuery) : this.searchIndex.score(option, trimmedQuery, {
1392
+ fields: this.opts.searchFields,
1393
+ tokenSearch: this.opts.tokenSearch,
1394
+ accentInsensitive: this.opts.accentInsensitive,
1395
+ scorer: this.opts.searchScorer
1396
+ }) > 0);
1093
1397
  const subtreeMatches = (option) => query === "" || matches(option) || (option.children ?? []).some(subtreeMatches);
1094
1398
  const pushOption = (option, depth, parentValue) => {
1095
1399
  let navIndex = -1;
@@ -1145,6 +1449,27 @@ var ForgeSelect = class {
1145
1449
  usesVirtualScroll() {
1146
1450
  return this.opts.virtualScroll !== false && this.rows.length > VIRTUAL_THRESHOLD;
1147
1451
  }
1452
+ rowKey(row, index) {
1453
+ if (row.kind === "option") return `option:${row.option.value}`;
1454
+ if (row.kind === "group") return `group:${row.label}:${index}`;
1455
+ return `${row.kind}:${index}`;
1456
+ }
1457
+ measuredRowHeight(index) {
1458
+ return this.opts.variableItemHeight ? this.rowHeightCache.get(this.rowKey(this.rows[index], index)) ?? this.opts.itemHeight : this.opts.itemHeight;
1459
+ }
1460
+ rowOffset(index) {
1461
+ if (!this.opts.variableItemHeight) return index * this.opts.itemHeight;
1462
+ let offset = 0;
1463
+ for (let i = 0; i < index; i += 1) offset += this.measuredRowHeight(i);
1464
+ return offset;
1465
+ }
1466
+ rowOffsets() {
1467
+ if (this.rowOffsetsCache) return this.rowOffsetsCache;
1468
+ const offsets = [0];
1469
+ for (let i = 0; i < this.rows.length; i += 1) offsets.push(offsets[i] + this.measuredRowHeight(i));
1470
+ this.rowOffsetsCache = offsets;
1471
+ return offsets;
1472
+ }
1148
1473
  renderList() {
1149
1474
  this.buildRows();
1150
1475
  this.renderRows();
@@ -1161,26 +1486,49 @@ var ForgeSelect = class {
1161
1486
  const virtual = this.usesVirtualScroll();
1162
1487
  this.list.textContent = "";
1163
1488
  const rowHeight = this.opts.itemHeight;
1489
+ const offsets = this.opts.variableItemHeight ? this.rowOffsets() : null;
1164
1490
  let start = 0;
1165
1491
  let end = this.rows.length;
1166
1492
  if (virtual) {
1167
1493
  const viewport = clientHeight || rowHeight * 8;
1168
- start = Math.max(0, Math.floor(scrollTop / rowHeight) - VIRTUAL_BUFFER);
1169
- end = Math.min(this.rows.length, start + Math.ceil(viewport / rowHeight) + VIRTUAL_BUFFER * 2);
1494
+ if (this.opts.variableItemHeight) {
1495
+ while (start < this.rows.length && offsets[start + 1] < scrollTop) start += 1;
1496
+ start = Math.max(0, start - VIRTUAL_BUFFER);
1497
+ end = start;
1498
+ const target = scrollTop + viewport + VIRTUAL_BUFFER * rowHeight;
1499
+ while (end < this.rows.length && offsets[end] < target) end += 1;
1500
+ } else {
1501
+ start = Math.max(0, Math.floor(scrollTop / rowHeight) - VIRTUAL_BUFFER);
1502
+ end = Math.min(this.rows.length, start + Math.ceil(viewport / rowHeight) + VIRTUAL_BUFFER * 2);
1503
+ }
1170
1504
  const topSpacer = document.createElement("li");
1171
1505
  topSpacer.className = "forge-select__spacer";
1172
1506
  topSpacer.setAttribute("aria-hidden", "true");
1173
- topSpacer.style.height = `${start * rowHeight}px`;
1507
+ topSpacer.style.height = `${offsets?.[start] ?? this.rowOffset(start)}px`;
1174
1508
  this.list.append(topSpacer);
1175
1509
  }
1510
+ const appended = [];
1176
1511
  for (let i = start; i < end; i++) {
1177
- this.list.append(this.renderRow(this.rows[i]));
1512
+ const element = this.renderRow(this.rows[i]);
1513
+ this.list.append(element);
1514
+ appended.push(element);
1515
+ }
1516
+ if (this.opts.variableItemHeight) {
1517
+ for (let i = start; i < end; i++) {
1518
+ const element = appended[i - start];
1519
+ const measured = element.getBoundingClientRect().height || element.offsetHeight;
1520
+ if (measured > 0) {
1521
+ const key = this.rowKey(this.rows[i], i);
1522
+ if (this.rowHeightCache.get(key) !== measured) this.rowOffsetsCache = null;
1523
+ this.rowHeightCache.set(key, measured);
1524
+ }
1525
+ }
1178
1526
  }
1179
1527
  if (virtual) {
1180
1528
  const bottomSpacer = document.createElement("li");
1181
1529
  bottomSpacer.className = "forge-select__spacer";
1182
1530
  bottomSpacer.setAttribute("aria-hidden", "true");
1183
- bottomSpacer.style.height = `${(this.rows.length - end) * rowHeight}px`;
1531
+ bottomSpacer.style.height = `${offsets ? offsets[this.rows.length] - offsets[end] : this.rowOffset(this.rows.length) - this.rowOffset(end)}px`;
1184
1532
  this.list.append(bottomSpacer);
1185
1533
  if (this.list.scrollTop !== scrollTop) {
1186
1534
  this.list.scrollTop = scrollTop;
@@ -1247,6 +1595,7 @@ var ForgeSelect = class {
1247
1595
  if (isSelected) li.classList.add("forge-select__option--selected");
1248
1596
  if (this.opts.multiple && row.hasChildren && computeCheckState(row.option, this.selected, this.isOptionDisabled) === "some") {
1249
1597
  li.classList.add("forge-select__option--indeterminate");
1598
+ li.dataset.selectionState = "mixed";
1250
1599
  }
1251
1600
  if (row.depth > 0) {
1252
1601
  li.style.paddingLeft = `calc(12px + ${row.depth} * var(--fs-tree-indent, 18px))`;
@@ -1282,6 +1631,28 @@ var ForgeSelect = class {
1282
1631
  * cached content state-free.
1283
1632
  */
1284
1633
  optionContent(option) {
1634
+ if (this.opts.highlightSearch && this.query.trim() && !this.opts.templateResult) {
1635
+ const holder = document.createElement("span");
1636
+ holder.className = "forge-select__option-content";
1637
+ renderOptionContent(holder, option, void 0);
1638
+ const label = holder.querySelector(".forge-select__option-label") ?? holder;
1639
+ const ranges = findNormalizedRanges(option.label, this.query, this.opts.accentInsensitive);
1640
+ if (ranges.length) {
1641
+ label.textContent = "";
1642
+ let cursor = 0;
1643
+ for (const [start, end] of ranges) {
1644
+ if (start < cursor) continue;
1645
+ label.append(document.createTextNode(option.label.slice(cursor, start)));
1646
+ const mark = document.createElement("mark");
1647
+ mark.className = "forge-select__match";
1648
+ mark.textContent = option.label.slice(start, end);
1649
+ label.append(mark);
1650
+ cursor = end;
1651
+ }
1652
+ label.append(document.createTextNode(option.label.slice(cursor)));
1653
+ }
1654
+ return holder;
1655
+ }
1285
1656
  let cached = this.rowContentCache.get(option.value);
1286
1657
  if (!cached) {
1287
1658
  const holder = document.createElement("span");
@@ -1302,14 +1673,15 @@ var ForgeSelect = class {
1302
1673
  this.focusNavIndex(next);
1303
1674
  }
1304
1675
  focusNavIndex(next) {
1676
+ if (this.navItems.length === 0) return;
1305
1677
  this.highlightedIndex = next;
1306
1678
  if (this.usesVirtualScroll()) {
1307
1679
  const rowIndex = this.rows.findIndex(
1308
1680
  (row) => (row.kind === "option" || row.kind === "create") && row.navIndex === next
1309
1681
  );
1310
1682
  if (rowIndex >= 0) {
1311
- const rowHeight = this.opts.itemHeight;
1312
- const top = rowIndex * rowHeight;
1683
+ const rowHeight = this.measuredRowHeight(rowIndex);
1684
+ const top = this.rowOffset(rowIndex);
1313
1685
  const viewport = this.list.clientHeight || rowHeight * 8;
1314
1686
  let target = this.list.scrollTop;
1315
1687
  if (top < target) target = top;
@@ -1374,7 +1746,7 @@ var ForgeSelect = class {
1374
1746
  this.ajaxController = null;
1375
1747
  this.page = 0;
1376
1748
  this.hasMore = true;
1377
- this.loading = true;
1749
+ this.setLoading(true);
1378
1750
  this.loadingMore = false;
1379
1751
  this.loadError = null;
1380
1752
  this.renderList();
@@ -1383,6 +1755,55 @@ var ForgeSelect = class {
1383
1755
  void this.loadRemote(query, { requestId });
1384
1756
  }, delay);
1385
1757
  }
1758
+ setLoading(loading) {
1759
+ if (this.loading === loading) return;
1760
+ this.loading = loading;
1761
+ this.emitter.emit("loading", loading);
1762
+ }
1763
+ remoteCacheKey(query, page) {
1764
+ return `${query}\0${page}`;
1765
+ }
1766
+ async requestRemote(query, page, signal) {
1767
+ const ajax = this.opts.ajax;
1768
+ const attempts = Math.max(0, Math.floor(ajax.retry ?? 0)) + 1;
1769
+ let lastError;
1770
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
1771
+ try {
1772
+ if (ajax.request) return await ajax.request(query, page, signal);
1773
+ const response = await fetch(buildUrl(ajax, query, page), { signal });
1774
+ if (response.ok === false) throw new Error(`ForgeSelect: remote request failed with HTTP ${response.status}`);
1775
+ return await response.json();
1776
+ } catch (error) {
1777
+ lastError = error;
1778
+ if (signal.aborted || attempt === attempts - 1) throw error;
1779
+ const delay = Math.max(0, ajax.retryDelay ?? 250) * 2 ** attempt;
1780
+ await new Promise((resolve, reject) => {
1781
+ const timer = setTimeout(resolve, delay);
1782
+ signal.addEventListener(
1783
+ "abort",
1784
+ () => {
1785
+ clearTimeout(timer);
1786
+ reject(new DOMException("Aborted", "AbortError"));
1787
+ },
1788
+ { once: true }
1789
+ );
1790
+ });
1791
+ }
1792
+ }
1793
+ throw lastError;
1794
+ }
1795
+ async prefetchRemote(query) {
1796
+ const ajax = this.opts.ajax;
1797
+ if (!ajax || (ajax.cacheTtl ?? 3e4) <= 0) return;
1798
+ const key = this.remoteCacheKey(query, 0);
1799
+ if (this.remoteCache.get(key)) return;
1800
+ const controller = new AbortController();
1801
+ try {
1802
+ const json = await this.requestRemote(query, 0, controller.signal);
1803
+ this.remoteCache.set(key, normalizeRemoteResult(ajax, json), ajax.cacheTtl ?? 3e4);
1804
+ } catch {
1805
+ }
1806
+ }
1386
1807
  /**
1387
1808
  * Fires on every list scroll. Only acts when pagination is opted into via
1388
1809
  * `ajax.pagination`; reads real scroll geometry rather than row counts so
@@ -1407,23 +1828,22 @@ var ForgeSelect = class {
1407
1828
  this.ajaxController = controller;
1408
1829
  const page = append ? this.page + 1 : 0;
1409
1830
  try {
1410
- let json;
1411
- if (ajax.request) {
1412
- json = await ajax.request(query, page, controller.signal);
1413
- } else {
1414
- const url = buildUrl(ajax, query, page);
1415
- const response = await fetch(url, { signal: controller.signal });
1416
- if (response.ok === false) throw new Error(`ForgeSelect: remote request failed with HTTP ${response.status}`);
1417
- json = await response.json();
1831
+ const key = this.remoteCacheKey(query, page);
1832
+ let result = this.remoteCache.get(key);
1833
+ if (!result) {
1834
+ const json = await this.requestRemote(query, page, controller.signal);
1835
+ result = normalizeRemoteResult(ajax, json);
1836
+ this.remoteCache.set(key, result, ajax.cacheTtl ?? 3e4);
1418
1837
  }
1419
1838
  if (activeRequestId !== this.ajaxRequestId || this.destroyed) return;
1420
- const { options, hasMore } = normalizeRemoteResult(ajax, json);
1839
+ const { options, hasMore } = result;
1421
1840
  if (append) {
1422
1841
  const existing = collectValues(this.data);
1423
1842
  this.data = [...this.data, ...options.filter((o) => !existing.has(o.value))];
1424
1843
  } else {
1425
1844
  this.data = options;
1426
1845
  this.rowContentCache.clear();
1846
+ this.rowHeightCache.clear();
1427
1847
  }
1428
1848
  this.page = page;
1429
1849
  this.hasMore = hasMore;
@@ -1435,6 +1855,7 @@ var ForgeSelect = class {
1435
1855
  if (!append) {
1436
1856
  this.data = [];
1437
1857
  this.rowContentCache.clear();
1858
+ this.rowHeightCache.clear();
1438
1859
  }
1439
1860
  this.hasMore = false;
1440
1861
  this.loadError = error;
@@ -1442,7 +1863,7 @@ var ForgeSelect = class {
1442
1863
  } finally {
1443
1864
  if (activeRequestId === this.ajaxRequestId && !this.destroyed) {
1444
1865
  this.ajaxController = null;
1445
- this.loading = false;
1866
+ this.setLoading(false);
1446
1867
  this.loadingMore = false;
1447
1868
  if (this.isOpen) this.renderList();
1448
1869
  }