forge-select 0.5.0 → 0.7.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
@@ -123,10 +123,10 @@ function parseOption(option) {
123
123
  }
124
124
 
125
125
  // src/option-renderer.ts
126
- function renderOptionContent(container, option, template, variant = "row") {
126
+ function renderOptionContent(container, option, template, variant = "row", sanitizeTemplate) {
127
127
  if (template) {
128
128
  const result = template(option);
129
- if (typeof result === "string") container.innerHTML = result;
129
+ if (typeof result === "string") container.innerHTML = sanitizeTemplate ? sanitizeTemplate(result, option) : result;
130
130
  else container.append(result);
131
131
  return;
132
132
  }
@@ -163,12 +163,12 @@ function renderOptionContent(container, option, template, variant = "row") {
163
163
  }
164
164
 
165
165
  // src/remote.ts
166
- function buildUrl(ajax, query, page) {
166
+ function buildUrl(ajax, query, page, cursor) {
167
167
  if (!ajax.url) throw new Error("ForgeSelect: ajax requires either url or request.");
168
168
  if (typeof ajax.url === "function") return ajax.url(query, page);
169
169
  if (!ajax.params) return ajax.url;
170
170
  const params = new URLSearchParams();
171
- for (const [key, value] of Object.entries(ajax.params(query, page))) params.set(key, String(value));
171
+ for (const [key, value] of Object.entries(ajax.params(query, page, cursor))) params.set(key, String(value));
172
172
  const separator = ajax.url.includes("?") ? "&" : "?";
173
173
  return `${ajax.url}${separator}${params.toString()}`;
174
174
  }
@@ -180,10 +180,16 @@ function normalizeRemoteResult(ajax, response) {
180
180
  "ForgeSelect: ajax.transform must return an array of options, or an object shaped like { options: Option[], hasMore?: boolean }."
181
181
  );
182
182
  }
183
- return { options: result.options, hasMore: ajax.pagination ? Boolean(result.hasMore) : false };
183
+ const nextCursor = result.nextCursor == null ? void 0 : String(result.nextCursor);
184
+ return {
185
+ options: result.options,
186
+ hasMore: ajax.pagination ? result.hasMore ?? nextCursor !== void 0 : false,
187
+ nextCursor
188
+ };
184
189
  }
185
190
 
186
191
  // src/remote-cache.ts
192
+ var REMOTE_CACHE_LIMIT = 50;
187
193
  var RemoteCache = class {
188
194
  constructor() {
189
195
  this.entries = /* @__PURE__ */ new Map();
@@ -198,7 +204,12 @@ var RemoteCache = class {
198
204
  return entry.value;
199
205
  }
200
206
  set(key, value, ttl, now = Date.now()) {
201
- if (ttl > 0) this.entries.set(key, { value, expiresAt: now + ttl });
207
+ if (ttl <= 0) return;
208
+ if (this.entries.size >= REMOTE_CACHE_LIMIT && !this.entries.has(key)) {
209
+ const oldest = this.entries.keys().next().value;
210
+ this.entries.delete(oldest);
211
+ }
212
+ this.entries.set(key, { value, expiresAt: now + ttl });
202
213
  }
203
214
  clear() {
204
215
  this.entries.clear();
@@ -288,21 +299,6 @@ function computeCheckState(option, selected, isDisabled = defaultIsDisabled) {
288
299
  if (states.every((state) => state === "none")) return "none";
289
300
  return "some";
290
301
  }
291
- function findOption(items, value) {
292
- const search = (options) => {
293
- for (const option of options) {
294
- if (option.value === value) return option;
295
- const found = option.children ? search(option.children) : void 0;
296
- if (found) return found;
297
- }
298
- return void 0;
299
- };
300
- for (const item of items) {
301
- const found = search(isGroup(item) ? item.options : [item]);
302
- if (found) return found;
303
- }
304
- return void 0;
305
- }
306
302
  function syncTreeAncestors(items, selected, isDisabled = defaultIsDisabled) {
307
303
  const sync = (option) => {
308
304
  if (!option.children?.length) return;
@@ -332,9 +328,13 @@ var DEFAULT_ITEM_HEIGHT = 36;
332
328
  var VIRTUAL_BUFFER = 5;
333
329
  var VIRTUAL_THRESHOLD = 100;
334
330
  var ROW_CACHE_LIMIT = 2e3;
331
+ var PAGE_SIZE = 10;
332
+ var TYPEAHEAD_RESET_MS = 500;
335
333
  var uidCounter = 0;
336
334
  var ForgeSelect = class {
337
335
  constructor(target, options = {}) {
336
+ this.optionByValue = /* @__PURE__ */ new Map();
337
+ this.optionByLabel = /* @__PURE__ */ new Map();
338
338
  this.selected = [];
339
339
  this.selectedOptions = /* @__PURE__ */ new Map();
340
340
  this.suppressNextTagClick = false;
@@ -349,8 +349,14 @@ var ForgeSelect = class {
349
349
  this.rows = [];
350
350
  this.navItems = [];
351
351
  this.highlightedIndex = -1;
352
+ this.typeaheadBuffer = "";
353
+ this.typeaheadTimer = null;
352
354
  this.rowContentCache = /* @__PURE__ */ new Map();
355
+ this.rowElementCache = /* @__PURE__ */ new Map();
353
356
  this.rowHeightCache = /* @__PURE__ */ new Map();
357
+ this.rowOffsetsCache = null;
358
+ this.scrollRafId = null;
359
+ this.ancestorScrollRafId = null;
354
360
  this.searchIndex = new SearchIndex();
355
361
  this.expandedValues = /* @__PURE__ */ new Set();
356
362
  this.loading = false;
@@ -362,6 +368,8 @@ var ForgeSelect = class {
362
368
  this.ajaxController = null;
363
369
  this.remoteLoaded = false;
364
370
  this.remoteCache = new RemoteCache();
371
+ this.remoteInFlight = /* @__PURE__ */ new Map();
372
+ this.prefetchControllers = /* @__PURE__ */ new Set();
365
373
  this.loadError = null;
366
374
  this.originalDisplay = "";
367
375
  this.originalDisabled = false;
@@ -379,7 +387,12 @@ var ForgeSelect = class {
379
387
  this.positionDropdown();
380
388
  };
381
389
  this.onAncestorScroll = () => {
382
- if (this.portalHost) this.positionDropdown();
390
+ if (!this.portalHost) return;
391
+ if (this.ancestorScrollRafId != null) return;
392
+ this.ancestorScrollRafId = requestAnimationFrame(() => {
393
+ this.ancestorScrollRafId = null;
394
+ this.positionDropdown();
395
+ });
383
396
  };
384
397
  this.onNativeInvalid = (event) => {
385
398
  event.preventDefault();
@@ -425,6 +438,13 @@ var ForgeSelect = class {
425
438
  ajax: options.ajax,
426
439
  templateResult: options.templateResult,
427
440
  templateSelection: options.templateSelection,
441
+ sanitizeTemplate: options.sanitizeTemplate,
442
+ beforeSelect: options.beforeSelect,
443
+ beforeUnselect: options.beforeUnselect,
444
+ beforeCreate: options.beforeCreate,
445
+ createOption: options.createOption,
446
+ missingSelectionPolicy: options.missingSelectionPolicy ?? "preserve",
447
+ duplicateValuePolicy: options.duplicateValuePolicy ?? "warn",
428
448
  filterOption: options.filterOption,
429
449
  searchFields: options.searchFields ?? ["label", "description"],
430
450
  tokenSearch: options.tokenSearch ?? true,
@@ -446,6 +466,7 @@ var ForgeSelect = class {
446
466
  this.plugins = this.opts.plugins;
447
467
  if (nativeSelect) nativeSelect.required = this.opts.required;
448
468
  this.data = this.opts.data ?? (nativeSelect ? parseNativeOptions(nativeSelect) : []);
469
+ this.rebuildOptionIndexes();
449
470
  if (nativeSelect && !this.opts.data) {
450
471
  const nativeOptions = Array.from(nativeSelect.options);
451
472
  const hasIntentionalSelection = nativeSelect.multiple || nativeSelect.selectedIndex > 0 || nativeOptions.some((option) => option.defaultSelected);
@@ -483,6 +504,8 @@ var ForgeSelect = class {
483
504
  this.renderList();
484
505
  this.positionDropdown();
485
506
  window.addEventListener("resize", this.onWindowResize);
507
+ window.visualViewport?.addEventListener("resize", this.onWindowResize);
508
+ window.visualViewport?.addEventListener("scroll", this.onWindowResize);
486
509
  document.addEventListener("scroll", this.onAncestorScroll, true);
487
510
  if (this.searchInput && !this.searchInput.hidden) this.searchInput.focus();
488
511
  this.emitter.emit("open");
@@ -497,7 +520,22 @@ var ForgeSelect = class {
497
520
  this.control.setAttribute("aria-expanded", "false");
498
521
  document.removeEventListener("mousedown", this.onDocumentMouseDown);
499
522
  window.removeEventListener("resize", this.onWindowResize);
523
+ window.visualViewport?.removeEventListener("resize", this.onWindowResize);
524
+ window.visualViewport?.removeEventListener("scroll", this.onWindowResize);
500
525
  document.removeEventListener("scroll", this.onAncestorScroll, true);
526
+ if (this.ancestorScrollRafId != null) {
527
+ cancelAnimationFrame(this.ancestorScrollRafId);
528
+ this.ancestorScrollRafId = null;
529
+ }
530
+ if (this.scrollRafId != null) {
531
+ cancelAnimationFrame(this.scrollRafId);
532
+ this.scrollRafId = null;
533
+ }
534
+ if (this.typeaheadTimer) {
535
+ clearTimeout(this.typeaheadTimer);
536
+ this.typeaheadTimer = null;
537
+ }
538
+ this.typeaheadBuffer = "";
501
539
  this.highlightedIndex = -1;
502
540
  if (this.searchInput) {
503
541
  this.searchInput.value = "";
@@ -515,11 +553,16 @@ var ForgeSelect = class {
515
553
  */
516
554
  positionDropdown() {
517
555
  const controlRect = this.control.getBoundingClientRect();
518
- const placement = computeDropdownPlacement(controlRect, this.dropdown.offsetHeight, window.innerHeight);
556
+ const viewport = window.visualViewport;
557
+ const placement = computeDropdownPlacement(
558
+ controlRect,
559
+ this.dropdown.offsetHeight,
560
+ viewport?.height ?? window.innerHeight
561
+ );
519
562
  this.root.classList.toggle("forge-select--drop-up", placement.dropUp);
520
563
  if (this.portalHost) {
521
- this.portalHost.style.top = `${placement.top}px`;
522
- this.portalHost.style.left = `${controlRect.left}px`;
564
+ this.portalHost.style.top = `${placement.top + (viewport?.offsetTop ?? 0)}px`;
565
+ this.portalHost.style.left = `${controlRect.left + (viewport?.offsetLeft ?? 0)}px`;
523
566
  this.portalHost.style.width = `${controlRect.width}px`;
524
567
  }
525
568
  }
@@ -530,10 +573,16 @@ var ForgeSelect = class {
530
573
  this.destroyed = true;
531
574
  if (this.ajaxTimer) clearTimeout(this.ajaxTimer);
532
575
  this.ajaxController?.abort();
576
+ for (const controller of this.prefetchControllers) controller.abort();
577
+ this.prefetchControllers.clear();
578
+ this.remoteInFlight.clear();
579
+ if (this.scrollRafId != null) cancelAnimationFrame(this.scrollRafId);
580
+ if (this.typeaheadTimer) clearTimeout(this.typeaheadTimer);
533
581
  this.nativeSelect?.removeEventListener("change", this.onNativeChange);
534
582
  this.nativeSelect?.removeEventListener("invalid", this.onNativeInvalid);
535
583
  this.nativeForm?.removeEventListener("reset", this.onFormReset);
536
584
  this.rowContentCache.clear();
585
+ this.rowElementCache.clear();
537
586
  this.rowHeightCache.clear();
538
587
  this.searchIndex.clear();
539
588
  this.portalHost?.remove();
@@ -582,10 +631,23 @@ var ForgeSelect = class {
582
631
  }
583
632
  if (options.templateResult !== void 0) this.opts.templateResult = options.templateResult;
584
633
  if (options.templateSelection !== void 0) this.opts.templateSelection = options.templateSelection;
634
+ if (options.sanitizeTemplate !== void 0) this.opts.sanitizeTemplate = options.sanitizeTemplate;
635
+ if (options.beforeSelect !== void 0) this.opts.beforeSelect = options.beforeSelect;
636
+ if (options.beforeUnselect !== void 0) this.opts.beforeUnselect = options.beforeUnselect;
637
+ if (options.beforeCreate !== void 0) this.opts.beforeCreate = options.beforeCreate;
638
+ if (options.createOption !== void 0) this.opts.createOption = options.createOption;
639
+ if (options.missingSelectionPolicy !== void 0) this.opts.missingSelectionPolicy = options.missingSelectionPolicy;
640
+ if (options.duplicateValuePolicy !== void 0) {
641
+ this.opts.duplicateValuePolicy = options.duplicateValuePolicy;
642
+ this.rebuildOptionIndexes();
643
+ }
585
644
  if (options.filterOption !== void 0) this.opts.filterOption = options.filterOption;
586
645
  if (options.searchFields !== void 0) this.opts.searchFields = options.searchFields;
587
646
  if (options.tokenSearch !== void 0) this.opts.tokenSearch = options.tokenSearch;
588
- if (options.accentInsensitive !== void 0) this.opts.accentInsensitive = options.accentInsensitive;
647
+ if (options.accentInsensitive !== void 0) {
648
+ this.opts.accentInsensitive = options.accentInsensitive;
649
+ this.rebuildOptionIndexes();
650
+ }
589
651
  if (options.searchScorer !== void 0) this.opts.searchScorer = options.searchScorer;
590
652
  if (options.highlightSearch !== void 0) this.opts.highlightSearch = options.highlightSearch;
591
653
  if (options.minSearchLength !== void 0)
@@ -614,6 +676,8 @@ var ForgeSelect = class {
614
676
  this.root.classList.toggle("forge-select--sortable", this.opts.sortable && this.opts.multiple);
615
677
  this.updateSearchVisibility();
616
678
  this.rowContentCache.clear();
679
+ this.rowElementCache.clear();
680
+ this.rowHeightCache.clear();
617
681
  this.searchIndex.clear();
618
682
  this.renderValue();
619
683
  if (this.isOpen) this.renderList();
@@ -645,6 +709,7 @@ var ForgeSelect = class {
645
709
  }
646
710
  clearRemoteCache() {
647
711
  this.remoteCache.clear();
712
+ this.remoteInFlight.clear();
648
713
  }
649
714
  setValue(value, options = {}) {
650
715
  const values = value == null ? [] : Array.isArray(value) ? value : [value];
@@ -674,10 +739,31 @@ var ForgeSelect = class {
674
739
  this.remoteLoaded = true;
675
740
  this.page = 0;
676
741
  this.hasMore = false;
742
+ this.nextCursor = void 0;
743
+ const previousData = this.data;
677
744
  this.data = data;
745
+ try {
746
+ this.rebuildOptionIndexes();
747
+ } catch (error) {
748
+ this.data = previousData;
749
+ this.rebuildOptionIndexes();
750
+ throw error;
751
+ }
752
+ const missing = this.selected.filter((value) => !this.optionByValue.has(value));
753
+ if (missing.length > 0 && this.opts.missingSelectionPolicy === "error") {
754
+ this.data = previousData;
755
+ this.rebuildOptionIndexes();
756
+ throw new Error(`ForgeSelect: setData() is missing selected value(s): ${missing.join(", ")}`);
757
+ }
678
758
  this.opts.data = data;
759
+ if (missing.length > 0 && this.opts.missingSelectionPolicy === "prune") {
760
+ this.selected = this.selected.filter((value) => this.optionByValue.has(value));
761
+ this.afterSelectionChange();
762
+ }
679
763
  this.updateSearchVisibility();
680
764
  this.rowContentCache.clear();
765
+ this.rowElementCache.clear();
766
+ this.rowHeightCache.clear();
681
767
  this.searchIndex.clear();
682
768
  this.highlightedIndex = -1;
683
769
  if (this.isOpen) this.renderList();
@@ -848,10 +934,24 @@ var ForgeSelect = class {
848
934
  });
849
935
  this.clearBtn.addEventListener("click", (event) => {
850
936
  event.stopPropagation();
937
+ if (this.selected.some((value) => {
938
+ const option = this.findOption(value) ?? this.selectedOptions.get(value) ?? { value, label: value };
939
+ return this.opts.beforeUnselect?.(option) === false;
940
+ }))
941
+ return;
851
942
  this.clearSelection();
852
943
  });
853
944
  if (this.searchInput) {
945
+ let composing = false;
946
+ this.searchInput.addEventListener("compositionstart", () => {
947
+ composing = true;
948
+ });
949
+ this.searchInput.addEventListener("compositionend", () => {
950
+ composing = false;
951
+ this.applySearchQuery(this.searchInput.value, true);
952
+ });
854
953
  this.searchInput.addEventListener("input", () => {
954
+ if (composing) return;
855
955
  this.applySearchQuery(this.searchInput.value, true);
856
956
  });
857
957
  this.searchInput.addEventListener("keydown", (event) => this.handleKeydown(event));
@@ -861,21 +961,22 @@ var ForgeSelect = class {
861
961
  const labels = text.split(/[,\n]+/).map((s) => s.trim()).filter(Boolean);
862
962
  if (labels.length < 2) return;
863
963
  event.preventDefault();
864
- const created = [];
865
- for (const label of labels) {
866
- const result = this.createTag(label);
867
- if (result) created.push(result);
868
- }
869
- if (created.length === 0) return;
870
- this.searchInput.value = "";
871
- this.query = "";
872
- this.afterSelectionChange();
873
- for (const result of created) {
874
- if (result.created) this.emitter.emit("create", result.option);
875
- this.emitter.emit("select", result.option);
876
- }
877
- if (this.opts.closeOnSelect) this.close();
878
- else this.renderList();
964
+ void Promise.all(labels.map((label) => this.createTag(label))).then((results) => {
965
+ const created = results.filter((result) => result !== void 0);
966
+ if (created.length === 0 || this.destroyed) return;
967
+ this.searchInput.value = "";
968
+ this.query = "";
969
+ this.afterSelectionChange();
970
+ for (const result of created) {
971
+ if (result.created) this.emitter.emit("create", result.option);
972
+ this.emitter.emit("select", result.option);
973
+ }
974
+ if (this.opts.closeOnSelect) this.close();
975
+ else this.renderList();
976
+ }).catch((cause) => {
977
+ const error = cause instanceof Error ? cause : new Error(String(cause));
978
+ this.emitter.emit("error", error);
979
+ });
879
980
  });
880
981
  }
881
982
  this.list.addEventListener("click", (event) => {
@@ -899,8 +1000,12 @@ var ForgeSelect = class {
899
1000
  this.activateNavItem(navIndex);
900
1001
  });
901
1002
  this.list.addEventListener("scroll", () => {
902
- if (this.usesVirtualScroll()) this.renderRows();
903
- this.maybeLoadNextPage();
1003
+ if (this.scrollRafId != null) return;
1004
+ this.scrollRafId = requestAnimationFrame(() => {
1005
+ this.scrollRafId = null;
1006
+ if (this.usesVirtualScroll()) this.renderRows();
1007
+ this.maybeLoadNextPage();
1008
+ });
904
1009
  });
905
1010
  }
906
1011
  applySearchQuery(query, emitSearch) {
@@ -908,7 +1013,6 @@ var ForgeSelect = class {
908
1013
  if (this.searchInput && this.searchInput.value !== query) this.searchInput.value = query;
909
1014
  this.highlightedIndex = -1;
910
1015
  this.list.scrollTop = 0;
911
- this.rowContentCache.clear();
912
1016
  if (emitSearch) this.emitter.emit("search", query);
913
1017
  const trimmed = query.trim();
914
1018
  const belowMinLength = trimmed !== "" && trimmed.length < this.opts.minSearchLength;
@@ -927,7 +1031,7 @@ var ForgeSelect = class {
927
1031
  this.renderList();
928
1032
  }
929
1033
  handleKeydown(event) {
930
- if (this.isDisabled) return;
1034
+ if (this.isDisabled || event.isComposing || event.keyCode === 229) return;
931
1035
  switch (event.key) {
932
1036
  case "Enter":
933
1037
  event.preventDefault();
@@ -962,9 +1066,63 @@ var ForgeSelect = class {
962
1066
  case "ArrowLeft":
963
1067
  if (this.isOpen && this.navigateTree("left")) event.preventDefault();
964
1068
  break;
1069
+ case "Home":
1070
+ if (this.isOpen) {
1071
+ event.preventDefault();
1072
+ this.focusNavIndex(0);
1073
+ }
1074
+ break;
1075
+ case "End":
1076
+ if (this.isOpen) {
1077
+ event.preventDefault();
1078
+ this.focusNavIndex(this.navItems.length - 1);
1079
+ }
1080
+ break;
1081
+ case "PageDown":
1082
+ if (this.isOpen) {
1083
+ event.preventDefault();
1084
+ this.focusNavIndex(
1085
+ Math.min(this.navItems.length - 1, (this.highlightedIndex === -1 ? 0 : this.highlightedIndex) + PAGE_SIZE)
1086
+ );
1087
+ }
1088
+ break;
1089
+ case "PageUp":
1090
+ if (this.isOpen) {
1091
+ event.preventDefault();
1092
+ this.focusNavIndex(Math.max(0, (this.highlightedIndex === -1 ? 0 : this.highlightedIndex) - PAGE_SIZE));
1093
+ }
1094
+ break;
965
1095
  case "Tab":
966
1096
  this.close();
967
1097
  break;
1098
+ default:
1099
+ if (this.isOpen && event.target === this.control && event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
1100
+ this.handleTypeahead(event.key);
1101
+ }
1102
+ break;
1103
+ }
1104
+ }
1105
+ /**
1106
+ * Jumps the highlight to the next nav item (wrapping) whose label starts
1107
+ * with the accumulated buffer, matching native <select> typeahead: rapid
1108
+ * distinct keystrokes narrow the prefix, a pause resets it.
1109
+ */
1110
+ handleTypeahead(char) {
1111
+ if (this.typeaheadTimer) clearTimeout(this.typeaheadTimer);
1112
+ this.typeaheadBuffer += normalizeSearchText(char, this.opts.accentInsensitive);
1113
+ this.typeaheadTimer = setTimeout(() => {
1114
+ this.typeaheadBuffer = "";
1115
+ this.typeaheadTimer = null;
1116
+ }, TYPEAHEAD_RESET_MS);
1117
+ const prefix = [...this.typeaheadBuffer].every((value) => value === this.typeaheadBuffer[0]) ? this.typeaheadBuffer[0] : this.typeaheadBuffer;
1118
+ const count = this.navItems.length;
1119
+ for (let step = 1; step <= count; step += 1) {
1120
+ const index = (this.highlightedIndex + step + count) % count;
1121
+ const item = this.navItems[index];
1122
+ if (item.kind === "option" && normalizeSearchText(item.option.label, this.opts.accentInsensitive).startsWith(prefix)) {
1123
+ this.focusNavIndex(index);
1124
+ return;
1125
+ }
968
1126
  }
969
1127
  }
970
1128
  // ---------------------------------------------------------------- selection
@@ -1055,7 +1213,10 @@ var ForgeSelect = class {
1055
1213
  this.control.classList.remove("forge-select__control--invalid");
1056
1214
  this.control.removeAttribute("aria-invalid");
1057
1215
  }
1058
- if (this.isOpen) this.renderList();
1216
+ if (this.isOpen) {
1217
+ if (this.opts.maxSelections != null) this.renderList();
1218
+ else this.renderRows();
1219
+ }
1059
1220
  if (emitChange) this.emitter.emit("change", this.getValue());
1060
1221
  }
1061
1222
  syncNativeSelect(dispatchChange = true) {
@@ -1088,23 +1249,27 @@ var ForgeSelect = class {
1088
1249
  }
1089
1250
  }
1090
1251
  findOption(value) {
1091
- return findOption(this.data, value);
1252
+ return this.optionByValue.get(value);
1092
1253
  }
1093
1254
  findOptionByLabel(label) {
1094
- const lower = label.toLowerCase();
1095
- const search = (options) => {
1096
- for (const option of options) {
1097
- if (option.label.toLowerCase() === lower) return option;
1098
- const found = option.children ? search(option.children) : void 0;
1099
- if (found) return found;
1100
- }
1101
- return void 0;
1255
+ return this.optionByLabel.get(normalizeSearchText(label, this.opts.accentInsensitive));
1256
+ }
1257
+ rebuildOptionIndexes() {
1258
+ this.optionByValue.clear();
1259
+ this.optionByLabel.clear();
1260
+ const duplicates = /* @__PURE__ */ new Set();
1261
+ const visit = (option) => {
1262
+ if (this.optionByValue.has(option.value)) duplicates.add(option.value);
1263
+ else this.optionByValue.set(option.value, option);
1264
+ const label = normalizeSearchText(option.label, this.opts.accentInsensitive);
1265
+ if (!this.optionByLabel.has(label)) this.optionByLabel.set(label, option);
1266
+ option.children?.forEach(visit);
1102
1267
  };
1103
- for (const item of this.data) {
1104
- const found = search(isGroup(item) ? item.options : [item]);
1105
- if (found) return found;
1106
- }
1107
- return void 0;
1268
+ for (const item of this.data) (isGroup(item) ? item.options : [item]).forEach(visit);
1269
+ if (duplicates.size === 0 || this.opts.duplicateValuePolicy === "ignore") return;
1270
+ const message = `ForgeSelect: duplicate option value(s): ${[...duplicates].join(", ")}`;
1271
+ if (this.opts.duplicateValuePolicy === "error") throw new Error(message);
1272
+ console.warn(message);
1108
1273
  }
1109
1274
  /** Selects an existing option matching `label` exactly, or creates and selects a new one. */
1110
1275
  createTag(label) {
@@ -1120,12 +1285,26 @@ var ForgeSelect = class {
1120
1285
  this.selectValue(existing.value, false);
1121
1286
  return { option: existing, created: false };
1122
1287
  }
1123
- const option = { value: trimmed, label: trimmed };
1288
+ if (this.opts.beforeCreate?.(trimmed) === false) return void 0;
1289
+ const created = this.opts.createOption?.(trimmed) ?? { value: trimmed, label: trimmed };
1290
+ if (created instanceof Promise) {
1291
+ return created.then((option) => option ? this.addCreatedOption(option) : void 0);
1292
+ }
1293
+ return created ? this.addCreatedOption(created) : void 0;
1294
+ }
1295
+ addCreatedOption(option) {
1124
1296
  if (this.opts.multiple && !this.canSelectOption(option)) {
1125
1297
  this.announceMaximum(option);
1126
1298
  return void 0;
1127
1299
  }
1300
+ const duplicate = this.findOption(option.value);
1301
+ if (duplicate) {
1302
+ if (this.selected.includes(duplicate.value)) return void 0;
1303
+ this.selectValue(duplicate.value, false);
1304
+ return { option: duplicate, created: false };
1305
+ }
1128
1306
  this.data.push(option);
1307
+ this.rebuildOptionIndexes();
1129
1308
  this.selectValue(option.value, false);
1130
1309
  return { option, created: true };
1131
1310
  }
@@ -1133,8 +1312,18 @@ var ForgeSelect = class {
1133
1312
  const label = this.query.trim();
1134
1313
  if (!label) return;
1135
1314
  const result = this.createTag(label);
1136
- if (!result) return;
1137
- if (this.searchInput) {
1315
+ if (result instanceof Promise) {
1316
+ void result.then((created) => this.finishCreateFromQuery(created, label)).catch((cause) => {
1317
+ const error = cause instanceof Error ? cause : new Error(String(cause));
1318
+ this.emitter.emit("error", error);
1319
+ });
1320
+ return;
1321
+ }
1322
+ this.finishCreateFromQuery(result, label);
1323
+ }
1324
+ finishCreateFromQuery(result, sourceLabel) {
1325
+ if (!result || this.destroyed) return;
1326
+ if (this.searchInput && this.query.trim() === sourceLabel) {
1138
1327
  this.searchInput.value = "";
1139
1328
  this.query = "";
1140
1329
  }
@@ -1142,6 +1331,7 @@ var ForgeSelect = class {
1142
1331
  if (result.created) this.emitter.emit("create", result.option);
1143
1332
  this.emitter.emit("select", result.option);
1144
1333
  if (!this.opts.multiple || this.opts.closeOnSelect) this.close();
1334
+ else if (this.isOpen) this.renderList();
1145
1335
  }
1146
1336
  activateNavItem(navIndex) {
1147
1337
  const item = this.navItems[navIndex];
@@ -1154,9 +1344,11 @@ var ForgeSelect = class {
1154
1344
  if (this.opts.multiple) {
1155
1345
  let changed = false;
1156
1346
  if (this.selected.includes(value)) {
1347
+ if (this.opts.beforeUnselect?.(item.option) === false) return;
1157
1348
  this.deselectValue(value, true);
1158
1349
  changed = true;
1159
1350
  } else if (this.canSelectOption(item.option)) {
1351
+ if (this.opts.beforeSelect?.(item.option) === false) return;
1160
1352
  this.selectValue(value, true);
1161
1353
  changed = true;
1162
1354
  } else {
@@ -1164,6 +1356,7 @@ var ForgeSelect = class {
1164
1356
  }
1165
1357
  if (changed && this.opts.closeOnSelect) this.close();
1166
1358
  } else {
1359
+ if (this.opts.beforeSelect?.(item.option) === false) return;
1167
1360
  this.selectValue(value, true);
1168
1361
  this.close();
1169
1362
  this.control.focus();
@@ -1188,7 +1381,7 @@ var ForgeSelect = class {
1188
1381
  tag.className = "forge-select__tag";
1189
1382
  const label = document.createElement("span");
1190
1383
  label.className = "forge-select__tag-label";
1191
- renderOptionContent(label, option, this.opts.templateSelection, "inline");
1384
+ renderOptionContent(label, option, this.opts.templateSelection, "inline", this.opts.sanitizeTemplate);
1192
1385
  const remove = document.createElement("button");
1193
1386
  remove.type = "button";
1194
1387
  remove.className = "forge-select__tag-remove";
@@ -1196,7 +1389,7 @@ var ForgeSelect = class {
1196
1389
  remove.textContent = "\xD7";
1197
1390
  remove.addEventListener("click", (event) => {
1198
1391
  event.stopPropagation();
1199
- if (!this.isDisabled) this.deselectValue(value, true);
1392
+ if (!this.isDisabled && this.opts.beforeUnselect?.(option) !== false) this.deselectValue(value, true);
1200
1393
  });
1201
1394
  tag.append(label, remove);
1202
1395
  if (this.opts.sortable) {
@@ -1216,7 +1409,7 @@ var ForgeSelect = class {
1216
1409
  };
1217
1410
  const span = document.createElement("span");
1218
1411
  span.className = "forge-select__single-value";
1219
- renderOptionContent(span, option, this.opts.templateSelection, "inline");
1412
+ renderOptionContent(span, option, this.opts.templateSelection, "inline", this.opts.sanitizeTemplate);
1220
1413
  this.valueEl.append(span);
1221
1414
  }
1222
1415
  }
@@ -1316,6 +1509,7 @@ var ForgeSelect = class {
1316
1509
  buildRows() {
1317
1510
  this.rows = [];
1318
1511
  this.navItems = [];
1512
+ this.rowOffsetsCache = null;
1319
1513
  const trimmedQuery = this.query.trim();
1320
1514
  const query = normalizeSearchText(trimmedQuery, this.opts.accentInsensitive);
1321
1515
  const matches = (option) => query === "" || (this.opts.filterOption ? this.opts.filterOption(option, trimmedQuery) : this.searchIndex.score(option, trimmedQuery, {
@@ -1324,7 +1518,14 @@ var ForgeSelect = class {
1324
1518
  accentInsensitive: this.opts.accentInsensitive,
1325
1519
  scorer: this.opts.searchScorer
1326
1520
  }) > 0);
1327
- const subtreeMatches = (option) => query === "" || matches(option) || (option.children ?? []).some(subtreeMatches);
1521
+ const subtreeMatchCache = /* @__PURE__ */ new Map();
1522
+ const subtreeMatches = (option) => {
1523
+ const cached = subtreeMatchCache.get(option);
1524
+ if (cached !== void 0) return cached;
1525
+ const result = query === "" || matches(option) || (option.children ?? []).some(subtreeMatches);
1526
+ subtreeMatchCache.set(option, result);
1527
+ return result;
1528
+ };
1328
1529
  const pushOption = (option, depth, parentValue) => {
1329
1530
  let navIndex = -1;
1330
1531
  const interactionDisabled = this.isOptionDisabled(option) || this.hasReachedMaximum() && !this.selected.includes(option.value);
@@ -1380,7 +1581,7 @@ var ForgeSelect = class {
1380
1581
  return this.opts.virtualScroll !== false && this.rows.length > VIRTUAL_THRESHOLD;
1381
1582
  }
1382
1583
  rowKey(row, index) {
1383
- if (row.kind === "option") return `option:${row.option.value}`;
1584
+ if (row.kind === "option") return `option:${row.option.value}:${index}`;
1384
1585
  if (row.kind === "group") return `group:${row.label}:${index}`;
1385
1586
  return `${row.kind}:${index}`;
1386
1587
  }
@@ -1394,8 +1595,10 @@ var ForgeSelect = class {
1394
1595
  return offset;
1395
1596
  }
1396
1597
  rowOffsets() {
1598
+ if (this.rowOffsetsCache) return this.rowOffsetsCache;
1397
1599
  const offsets = [0];
1398
1600
  for (let i = 0; i < this.rows.length; i += 1) offsets.push(offsets[i] + this.measuredRowHeight(i));
1601
+ this.rowOffsetsCache = offsets;
1399
1602
  return offsets;
1400
1603
  }
1401
1604
  renderList() {
@@ -1420,7 +1623,14 @@ var ForgeSelect = class {
1420
1623
  if (virtual) {
1421
1624
  const viewport = clientHeight || rowHeight * 8;
1422
1625
  if (this.opts.variableItemHeight) {
1423
- while (start < this.rows.length && offsets[start + 1] < scrollTop) start += 1;
1626
+ let low = 0;
1627
+ let high = this.rows.length;
1628
+ while (low < high) {
1629
+ const middle = low + high >>> 1;
1630
+ if (offsets[middle + 1] < scrollTop) low = middle + 1;
1631
+ else high = middle;
1632
+ }
1633
+ start = low;
1424
1634
  start = Math.max(0, start - VIRTUAL_BUFFER);
1425
1635
  end = start;
1426
1636
  const target = scrollTop + viewport + VIRTUAL_BUFFER * rowHeight;
@@ -1435,12 +1645,27 @@ var ForgeSelect = class {
1435
1645
  topSpacer.style.height = `${offsets?.[start] ?? this.rowOffset(start)}px`;
1436
1646
  this.list.append(topSpacer);
1437
1647
  }
1648
+ const appended = [];
1438
1649
  for (let i = start; i < end; i++) {
1439
- const element = this.renderRow(this.rows[i]);
1650
+ const key = this.rowKey(this.rows[i], i);
1651
+ const element = this.renderRow(this.rows[i], this.rowElementCache.get(key));
1652
+ this.rowElementCache.set(key, element);
1653
+ if (this.rowElementCache.size > ROW_CACHE_LIMIT) {
1654
+ const oldest = this.rowElementCache.keys().next().value;
1655
+ this.rowElementCache.delete(oldest);
1656
+ }
1440
1657
  this.list.append(element);
1441
- if (this.opts.variableItemHeight) {
1658
+ appended.push(element);
1659
+ }
1660
+ if (this.opts.variableItemHeight) {
1661
+ for (let i = start; i < end; i++) {
1662
+ const element = appended[i - start];
1442
1663
  const measured = element.getBoundingClientRect().height || element.offsetHeight;
1443
- if (measured > 0) this.rowHeightCache.set(this.rowKey(this.rows[i], i), measured);
1664
+ if (measured > 0) {
1665
+ const key = this.rowKey(this.rows[i], i);
1666
+ if (this.rowHeightCache.get(key) !== measured) this.rowOffsetsCache = null;
1667
+ this.rowHeightCache.set(key, measured);
1668
+ }
1444
1669
  }
1445
1670
  }
1446
1671
  if (virtual) {
@@ -1455,8 +1680,22 @@ var ForgeSelect = class {
1455
1680
  }
1456
1681
  this.updateActiveDescendant();
1457
1682
  }
1458
- renderRow(row) {
1459
- const li = document.createElement("li");
1683
+ renderRow(row, recycled) {
1684
+ const li = recycled ?? document.createElement("li");
1685
+ li.replaceChildren();
1686
+ li.className = "";
1687
+ for (const attribute of [
1688
+ "role",
1689
+ "id",
1690
+ "aria-hidden",
1691
+ "aria-selected",
1692
+ "aria-disabled",
1693
+ "aria-expanded",
1694
+ "aria-level",
1695
+ "data-nav-index",
1696
+ "data-option-value"
1697
+ ])
1698
+ li.removeAttribute(attribute);
1460
1699
  switch (row.kind) {
1461
1700
  case "group":
1462
1701
  li.className = "forge-select__group-label";
@@ -1576,7 +1815,7 @@ var ForgeSelect = class {
1576
1815
  if (!cached) {
1577
1816
  const holder = document.createElement("span");
1578
1817
  holder.className = "forge-select__option-content";
1579
- renderOptionContent(holder, option, this.opts.templateResult);
1818
+ renderOptionContent(holder, option, this.opts.templateResult, "row", this.opts.sanitizeTemplate);
1580
1819
  if (this.rowContentCache.size >= ROW_CACHE_LIMIT) {
1581
1820
  const oldest = this.rowContentCache.keys().next().value;
1582
1821
  this.rowContentCache.delete(oldest);
@@ -1592,6 +1831,7 @@ var ForgeSelect = class {
1592
1831
  this.focusNavIndex(next);
1593
1832
  }
1594
1833
  focusNavIndex(next) {
1834
+ if (this.navItems.length === 0) return;
1595
1835
  this.highlightedIndex = next;
1596
1836
  if (this.usesVirtualScroll()) {
1597
1837
  const rowIndex = this.rows.findIndex(
@@ -1664,6 +1904,7 @@ var ForgeSelect = class {
1664
1904
  this.ajaxController = null;
1665
1905
  this.page = 0;
1666
1906
  this.hasMore = true;
1907
+ this.nextCursor = void 0;
1667
1908
  this.setLoading(true);
1668
1909
  this.loadingMore = false;
1669
1910
  this.loadError = null;
@@ -1678,17 +1919,27 @@ var ForgeSelect = class {
1678
1919
  this.loading = loading;
1679
1920
  this.emitter.emit("loading", loading);
1680
1921
  }
1681
- remoteCacheKey(query, page) {
1682
- return `${query}\0${page}`;
1922
+ remoteCacheKey(query, page, cursor) {
1923
+ return `${query}\0${cursor ?? page}`;
1683
1924
  }
1684
- async requestRemote(query, page, signal) {
1925
+ fetchRemoteResult(query, page, signal, cursor) {
1926
+ const key = this.remoteCacheKey(query, page, cursor);
1927
+ const pending = this.remoteInFlight.get(key);
1928
+ if (pending) return pending;
1929
+ const ajax = this.opts.ajax;
1930
+ const request = this.requestRemote(query, page, signal, cursor).then((json) => normalizeRemoteResult(ajax, json)).finally(() => this.remoteInFlight.delete(key));
1931
+ this.remoteInFlight.set(key, request);
1932
+ return request;
1933
+ }
1934
+ async requestRemote(query, page, signal, cursor) {
1685
1935
  const ajax = this.opts.ajax;
1686
1936
  const attempts = Math.max(0, Math.floor(ajax.retry ?? 0)) + 1;
1687
1937
  let lastError;
1688
1938
  for (let attempt = 0; attempt < attempts; attempt += 1) {
1689
1939
  try {
1690
- if (ajax.request) return await ajax.request(query, page, signal);
1691
- const response = await fetch(buildUrl(ajax, query, page), { signal });
1940
+ if (ajax.request)
1941
+ return cursor === void 0 ? await ajax.request(query, page, signal) : await ajax.request(query, page, signal, cursor);
1942
+ const response = await fetch(buildUrl(ajax, query, page, cursor), { signal });
1692
1943
  if (response.ok === false) throw new Error(`ForgeSelect: remote request failed with HTTP ${response.status}`);
1693
1944
  return await response.json();
1694
1945
  } catch (error) {
@@ -1716,10 +1967,13 @@ var ForgeSelect = class {
1716
1967
  const key = this.remoteCacheKey(query, 0);
1717
1968
  if (this.remoteCache.get(key)) return;
1718
1969
  const controller = new AbortController();
1970
+ this.prefetchControllers.add(controller);
1719
1971
  try {
1720
- const json = await this.requestRemote(query, 0, controller.signal);
1721
- this.remoteCache.set(key, normalizeRemoteResult(ajax, json), ajax.cacheTtl ?? 3e4);
1972
+ const result = await this.fetchRemoteResult(query, 0, controller.signal);
1973
+ this.remoteCache.set(key, result, ajax.cacheTtl ?? 3e4);
1722
1974
  } catch {
1975
+ } finally {
1976
+ this.prefetchControllers.delete(controller);
1723
1977
  }
1724
1978
  }
1725
1979
  /**
@@ -1745,12 +1999,12 @@ var ForgeSelect = class {
1745
1999
  const controller = new AbortController();
1746
2000
  this.ajaxController = controller;
1747
2001
  const page = append ? this.page + 1 : 0;
2002
+ const cursor = append ? this.nextCursor : void 0;
1748
2003
  try {
1749
- const key = this.remoteCacheKey(query, page);
2004
+ const key = this.remoteCacheKey(query, page, cursor);
1750
2005
  let result = this.remoteCache.get(key);
1751
2006
  if (!result) {
1752
- const json = await this.requestRemote(query, page, controller.signal);
1753
- result = normalizeRemoteResult(ajax, json);
2007
+ result = await this.fetchRemoteResult(query, page, controller.signal, cursor);
1754
2008
  this.remoteCache.set(key, result, ajax.cacheTtl ?? 3e4);
1755
2009
  }
1756
2010
  if (activeRequestId !== this.ajaxRequestId || this.destroyed) return;
@@ -1761,17 +2015,21 @@ var ForgeSelect = class {
1761
2015
  } else {
1762
2016
  this.data = options;
1763
2017
  this.rowContentCache.clear();
2018
+ this.rowHeightCache.clear();
1764
2019
  }
1765
2020
  this.page = page;
1766
2021
  this.hasMore = hasMore;
2022
+ this.nextCursor = result.nextCursor;
1767
2023
  this.remoteLoaded = true;
1768
2024
  this.loadError = null;
2025
+ this.rebuildOptionIndexes();
1769
2026
  } catch (cause) {
1770
2027
  if (activeRequestId !== this.ajaxRequestId || this.destroyed || controller.signal.aborted) return;
1771
2028
  const error = cause instanceof Error ? cause : new Error(String(cause));
1772
2029
  if (!append) {
1773
2030
  this.data = [];
1774
2031
  this.rowContentCache.clear();
2032
+ this.rowHeightCache.clear();
1775
2033
  }
1776
2034
  this.hasMore = false;
1777
2035
  this.loadError = error;