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.js CHANGED
@@ -96,10 +96,10 @@ function parseOption(option) {
96
96
  }
97
97
 
98
98
  // src/option-renderer.ts
99
- function renderOptionContent(container, option, template, variant = "row") {
99
+ function renderOptionContent(container, option, template, variant = "row", sanitizeTemplate) {
100
100
  if (template) {
101
101
  const result = template(option);
102
- if (typeof result === "string") container.innerHTML = result;
102
+ if (typeof result === "string") container.innerHTML = sanitizeTemplate ? sanitizeTemplate(result, option) : result;
103
103
  else container.append(result);
104
104
  return;
105
105
  }
@@ -136,12 +136,12 @@ function renderOptionContent(container, option, template, variant = "row") {
136
136
  }
137
137
 
138
138
  // src/remote.ts
139
- function buildUrl(ajax, query, page) {
139
+ function buildUrl(ajax, query, page, cursor) {
140
140
  if (!ajax.url) throw new Error("ForgeSelect: ajax requires either url or request.");
141
141
  if (typeof ajax.url === "function") return ajax.url(query, page);
142
142
  if (!ajax.params) return ajax.url;
143
143
  const params = new URLSearchParams();
144
- for (const [key, value] of Object.entries(ajax.params(query, page))) params.set(key, String(value));
144
+ for (const [key, value] of Object.entries(ajax.params(query, page, cursor))) params.set(key, String(value));
145
145
  const separator = ajax.url.includes("?") ? "&" : "?";
146
146
  return `${ajax.url}${separator}${params.toString()}`;
147
147
  }
@@ -153,10 +153,16 @@ function normalizeRemoteResult(ajax, response) {
153
153
  "ForgeSelect: ajax.transform must return an array of options, or an object shaped like { options: Option[], hasMore?: boolean }."
154
154
  );
155
155
  }
156
- return { options: result.options, hasMore: ajax.pagination ? Boolean(result.hasMore) : false };
156
+ const nextCursor = result.nextCursor == null ? void 0 : String(result.nextCursor);
157
+ return {
158
+ options: result.options,
159
+ hasMore: ajax.pagination ? result.hasMore ?? nextCursor !== void 0 : false,
160
+ nextCursor
161
+ };
157
162
  }
158
163
 
159
164
  // src/remote-cache.ts
165
+ var REMOTE_CACHE_LIMIT = 50;
160
166
  var RemoteCache = class {
161
167
  constructor() {
162
168
  this.entries = /* @__PURE__ */ new Map();
@@ -171,7 +177,12 @@ var RemoteCache = class {
171
177
  return entry.value;
172
178
  }
173
179
  set(key, value, ttl, now = Date.now()) {
174
- if (ttl > 0) this.entries.set(key, { value, expiresAt: now + ttl });
180
+ if (ttl <= 0) return;
181
+ if (this.entries.size >= REMOTE_CACHE_LIMIT && !this.entries.has(key)) {
182
+ const oldest = this.entries.keys().next().value;
183
+ this.entries.delete(oldest);
184
+ }
185
+ this.entries.set(key, { value, expiresAt: now + ttl });
175
186
  }
176
187
  clear() {
177
188
  this.entries.clear();
@@ -261,21 +272,6 @@ function computeCheckState(option, selected, isDisabled = defaultIsDisabled) {
261
272
  if (states.every((state) => state === "none")) return "none";
262
273
  return "some";
263
274
  }
264
- function findOption(items, value) {
265
- const search = (options) => {
266
- for (const option of options) {
267
- if (option.value === value) return option;
268
- const found = option.children ? search(option.children) : void 0;
269
- if (found) return found;
270
- }
271
- return void 0;
272
- };
273
- for (const item of items) {
274
- const found = search(isGroup(item) ? item.options : [item]);
275
- if (found) return found;
276
- }
277
- return void 0;
278
- }
279
275
  function syncTreeAncestors(items, selected, isDisabled = defaultIsDisabled) {
280
276
  const sync = (option) => {
281
277
  if (!option.children?.length) return;
@@ -305,9 +301,13 @@ var DEFAULT_ITEM_HEIGHT = 36;
305
301
  var VIRTUAL_BUFFER = 5;
306
302
  var VIRTUAL_THRESHOLD = 100;
307
303
  var ROW_CACHE_LIMIT = 2e3;
304
+ var PAGE_SIZE = 10;
305
+ var TYPEAHEAD_RESET_MS = 500;
308
306
  var uidCounter = 0;
309
307
  var ForgeSelect = class {
310
308
  constructor(target, options = {}) {
309
+ this.optionByValue = /* @__PURE__ */ new Map();
310
+ this.optionByLabel = /* @__PURE__ */ new Map();
311
311
  this.selected = [];
312
312
  this.selectedOptions = /* @__PURE__ */ new Map();
313
313
  this.suppressNextTagClick = false;
@@ -322,8 +322,14 @@ var ForgeSelect = class {
322
322
  this.rows = [];
323
323
  this.navItems = [];
324
324
  this.highlightedIndex = -1;
325
+ this.typeaheadBuffer = "";
326
+ this.typeaheadTimer = null;
325
327
  this.rowContentCache = /* @__PURE__ */ new Map();
328
+ this.rowElementCache = /* @__PURE__ */ new Map();
326
329
  this.rowHeightCache = /* @__PURE__ */ new Map();
330
+ this.rowOffsetsCache = null;
331
+ this.scrollRafId = null;
332
+ this.ancestorScrollRafId = null;
327
333
  this.searchIndex = new SearchIndex();
328
334
  this.expandedValues = /* @__PURE__ */ new Set();
329
335
  this.loading = false;
@@ -335,6 +341,8 @@ var ForgeSelect = class {
335
341
  this.ajaxController = null;
336
342
  this.remoteLoaded = false;
337
343
  this.remoteCache = new RemoteCache();
344
+ this.remoteInFlight = /* @__PURE__ */ new Map();
345
+ this.prefetchControllers = /* @__PURE__ */ new Set();
338
346
  this.loadError = null;
339
347
  this.originalDisplay = "";
340
348
  this.originalDisabled = false;
@@ -352,7 +360,12 @@ var ForgeSelect = class {
352
360
  this.positionDropdown();
353
361
  };
354
362
  this.onAncestorScroll = () => {
355
- if (this.portalHost) this.positionDropdown();
363
+ if (!this.portalHost) return;
364
+ if (this.ancestorScrollRafId != null) return;
365
+ this.ancestorScrollRafId = requestAnimationFrame(() => {
366
+ this.ancestorScrollRafId = null;
367
+ this.positionDropdown();
368
+ });
356
369
  };
357
370
  this.onNativeInvalid = (event) => {
358
371
  event.preventDefault();
@@ -398,6 +411,13 @@ var ForgeSelect = class {
398
411
  ajax: options.ajax,
399
412
  templateResult: options.templateResult,
400
413
  templateSelection: options.templateSelection,
414
+ sanitizeTemplate: options.sanitizeTemplate,
415
+ beforeSelect: options.beforeSelect,
416
+ beforeUnselect: options.beforeUnselect,
417
+ beforeCreate: options.beforeCreate,
418
+ createOption: options.createOption,
419
+ missingSelectionPolicy: options.missingSelectionPolicy ?? "preserve",
420
+ duplicateValuePolicy: options.duplicateValuePolicy ?? "warn",
401
421
  filterOption: options.filterOption,
402
422
  searchFields: options.searchFields ?? ["label", "description"],
403
423
  tokenSearch: options.tokenSearch ?? true,
@@ -419,6 +439,7 @@ var ForgeSelect = class {
419
439
  this.plugins = this.opts.plugins;
420
440
  if (nativeSelect) nativeSelect.required = this.opts.required;
421
441
  this.data = this.opts.data ?? (nativeSelect ? parseNativeOptions(nativeSelect) : []);
442
+ this.rebuildOptionIndexes();
422
443
  if (nativeSelect && !this.opts.data) {
423
444
  const nativeOptions = Array.from(nativeSelect.options);
424
445
  const hasIntentionalSelection = nativeSelect.multiple || nativeSelect.selectedIndex > 0 || nativeOptions.some((option) => option.defaultSelected);
@@ -456,6 +477,8 @@ var ForgeSelect = class {
456
477
  this.renderList();
457
478
  this.positionDropdown();
458
479
  window.addEventListener("resize", this.onWindowResize);
480
+ window.visualViewport?.addEventListener("resize", this.onWindowResize);
481
+ window.visualViewport?.addEventListener("scroll", this.onWindowResize);
459
482
  document.addEventListener("scroll", this.onAncestorScroll, true);
460
483
  if (this.searchInput && !this.searchInput.hidden) this.searchInput.focus();
461
484
  this.emitter.emit("open");
@@ -470,7 +493,22 @@ var ForgeSelect = class {
470
493
  this.control.setAttribute("aria-expanded", "false");
471
494
  document.removeEventListener("mousedown", this.onDocumentMouseDown);
472
495
  window.removeEventListener("resize", this.onWindowResize);
496
+ window.visualViewport?.removeEventListener("resize", this.onWindowResize);
497
+ window.visualViewport?.removeEventListener("scroll", this.onWindowResize);
473
498
  document.removeEventListener("scroll", this.onAncestorScroll, true);
499
+ if (this.ancestorScrollRafId != null) {
500
+ cancelAnimationFrame(this.ancestorScrollRafId);
501
+ this.ancestorScrollRafId = null;
502
+ }
503
+ if (this.scrollRafId != null) {
504
+ cancelAnimationFrame(this.scrollRafId);
505
+ this.scrollRafId = null;
506
+ }
507
+ if (this.typeaheadTimer) {
508
+ clearTimeout(this.typeaheadTimer);
509
+ this.typeaheadTimer = null;
510
+ }
511
+ this.typeaheadBuffer = "";
474
512
  this.highlightedIndex = -1;
475
513
  if (this.searchInput) {
476
514
  this.searchInput.value = "";
@@ -488,11 +526,16 @@ var ForgeSelect = class {
488
526
  */
489
527
  positionDropdown() {
490
528
  const controlRect = this.control.getBoundingClientRect();
491
- const placement = computeDropdownPlacement(controlRect, this.dropdown.offsetHeight, window.innerHeight);
529
+ const viewport = window.visualViewport;
530
+ const placement = computeDropdownPlacement(
531
+ controlRect,
532
+ this.dropdown.offsetHeight,
533
+ viewport?.height ?? window.innerHeight
534
+ );
492
535
  this.root.classList.toggle("forge-select--drop-up", placement.dropUp);
493
536
  if (this.portalHost) {
494
- this.portalHost.style.top = `${placement.top}px`;
495
- this.portalHost.style.left = `${controlRect.left}px`;
537
+ this.portalHost.style.top = `${placement.top + (viewport?.offsetTop ?? 0)}px`;
538
+ this.portalHost.style.left = `${controlRect.left + (viewport?.offsetLeft ?? 0)}px`;
496
539
  this.portalHost.style.width = `${controlRect.width}px`;
497
540
  }
498
541
  }
@@ -503,10 +546,16 @@ var ForgeSelect = class {
503
546
  this.destroyed = true;
504
547
  if (this.ajaxTimer) clearTimeout(this.ajaxTimer);
505
548
  this.ajaxController?.abort();
549
+ for (const controller of this.prefetchControllers) controller.abort();
550
+ this.prefetchControllers.clear();
551
+ this.remoteInFlight.clear();
552
+ if (this.scrollRafId != null) cancelAnimationFrame(this.scrollRafId);
553
+ if (this.typeaheadTimer) clearTimeout(this.typeaheadTimer);
506
554
  this.nativeSelect?.removeEventListener("change", this.onNativeChange);
507
555
  this.nativeSelect?.removeEventListener("invalid", this.onNativeInvalid);
508
556
  this.nativeForm?.removeEventListener("reset", this.onFormReset);
509
557
  this.rowContentCache.clear();
558
+ this.rowElementCache.clear();
510
559
  this.rowHeightCache.clear();
511
560
  this.searchIndex.clear();
512
561
  this.portalHost?.remove();
@@ -555,10 +604,23 @@ var ForgeSelect = class {
555
604
  }
556
605
  if (options.templateResult !== void 0) this.opts.templateResult = options.templateResult;
557
606
  if (options.templateSelection !== void 0) this.opts.templateSelection = options.templateSelection;
607
+ if (options.sanitizeTemplate !== void 0) this.opts.sanitizeTemplate = options.sanitizeTemplate;
608
+ if (options.beforeSelect !== void 0) this.opts.beforeSelect = options.beforeSelect;
609
+ if (options.beforeUnselect !== void 0) this.opts.beforeUnselect = options.beforeUnselect;
610
+ if (options.beforeCreate !== void 0) this.opts.beforeCreate = options.beforeCreate;
611
+ if (options.createOption !== void 0) this.opts.createOption = options.createOption;
612
+ if (options.missingSelectionPolicy !== void 0) this.opts.missingSelectionPolicy = options.missingSelectionPolicy;
613
+ if (options.duplicateValuePolicy !== void 0) {
614
+ this.opts.duplicateValuePolicy = options.duplicateValuePolicy;
615
+ this.rebuildOptionIndexes();
616
+ }
558
617
  if (options.filterOption !== void 0) this.opts.filterOption = options.filterOption;
559
618
  if (options.searchFields !== void 0) this.opts.searchFields = options.searchFields;
560
619
  if (options.tokenSearch !== void 0) this.opts.tokenSearch = options.tokenSearch;
561
- if (options.accentInsensitive !== void 0) this.opts.accentInsensitive = options.accentInsensitive;
620
+ if (options.accentInsensitive !== void 0) {
621
+ this.opts.accentInsensitive = options.accentInsensitive;
622
+ this.rebuildOptionIndexes();
623
+ }
562
624
  if (options.searchScorer !== void 0) this.opts.searchScorer = options.searchScorer;
563
625
  if (options.highlightSearch !== void 0) this.opts.highlightSearch = options.highlightSearch;
564
626
  if (options.minSearchLength !== void 0)
@@ -587,6 +649,8 @@ var ForgeSelect = class {
587
649
  this.root.classList.toggle("forge-select--sortable", this.opts.sortable && this.opts.multiple);
588
650
  this.updateSearchVisibility();
589
651
  this.rowContentCache.clear();
652
+ this.rowElementCache.clear();
653
+ this.rowHeightCache.clear();
590
654
  this.searchIndex.clear();
591
655
  this.renderValue();
592
656
  if (this.isOpen) this.renderList();
@@ -618,6 +682,7 @@ var ForgeSelect = class {
618
682
  }
619
683
  clearRemoteCache() {
620
684
  this.remoteCache.clear();
685
+ this.remoteInFlight.clear();
621
686
  }
622
687
  setValue(value, options = {}) {
623
688
  const values = value == null ? [] : Array.isArray(value) ? value : [value];
@@ -647,10 +712,31 @@ var ForgeSelect = class {
647
712
  this.remoteLoaded = true;
648
713
  this.page = 0;
649
714
  this.hasMore = false;
715
+ this.nextCursor = void 0;
716
+ const previousData = this.data;
650
717
  this.data = data;
718
+ try {
719
+ this.rebuildOptionIndexes();
720
+ } catch (error) {
721
+ this.data = previousData;
722
+ this.rebuildOptionIndexes();
723
+ throw error;
724
+ }
725
+ const missing = this.selected.filter((value) => !this.optionByValue.has(value));
726
+ if (missing.length > 0 && this.opts.missingSelectionPolicy === "error") {
727
+ this.data = previousData;
728
+ this.rebuildOptionIndexes();
729
+ throw new Error(`ForgeSelect: setData() is missing selected value(s): ${missing.join(", ")}`);
730
+ }
651
731
  this.opts.data = data;
732
+ if (missing.length > 0 && this.opts.missingSelectionPolicy === "prune") {
733
+ this.selected = this.selected.filter((value) => this.optionByValue.has(value));
734
+ this.afterSelectionChange();
735
+ }
652
736
  this.updateSearchVisibility();
653
737
  this.rowContentCache.clear();
738
+ this.rowElementCache.clear();
739
+ this.rowHeightCache.clear();
654
740
  this.searchIndex.clear();
655
741
  this.highlightedIndex = -1;
656
742
  if (this.isOpen) this.renderList();
@@ -821,10 +907,24 @@ var ForgeSelect = class {
821
907
  });
822
908
  this.clearBtn.addEventListener("click", (event) => {
823
909
  event.stopPropagation();
910
+ if (this.selected.some((value) => {
911
+ const option = this.findOption(value) ?? this.selectedOptions.get(value) ?? { value, label: value };
912
+ return this.opts.beforeUnselect?.(option) === false;
913
+ }))
914
+ return;
824
915
  this.clearSelection();
825
916
  });
826
917
  if (this.searchInput) {
918
+ let composing = false;
919
+ this.searchInput.addEventListener("compositionstart", () => {
920
+ composing = true;
921
+ });
922
+ this.searchInput.addEventListener("compositionend", () => {
923
+ composing = false;
924
+ this.applySearchQuery(this.searchInput.value, true);
925
+ });
827
926
  this.searchInput.addEventListener("input", () => {
927
+ if (composing) return;
828
928
  this.applySearchQuery(this.searchInput.value, true);
829
929
  });
830
930
  this.searchInput.addEventListener("keydown", (event) => this.handleKeydown(event));
@@ -834,21 +934,22 @@ var ForgeSelect = class {
834
934
  const labels = text.split(/[,\n]+/).map((s) => s.trim()).filter(Boolean);
835
935
  if (labels.length < 2) return;
836
936
  event.preventDefault();
837
- const created = [];
838
- for (const label of labels) {
839
- const result = this.createTag(label);
840
- if (result) created.push(result);
841
- }
842
- if (created.length === 0) return;
843
- this.searchInput.value = "";
844
- this.query = "";
845
- this.afterSelectionChange();
846
- for (const result of created) {
847
- if (result.created) this.emitter.emit("create", result.option);
848
- this.emitter.emit("select", result.option);
849
- }
850
- if (this.opts.closeOnSelect) this.close();
851
- else this.renderList();
937
+ void Promise.all(labels.map((label) => this.createTag(label))).then((results) => {
938
+ const created = results.filter((result) => result !== void 0);
939
+ if (created.length === 0 || this.destroyed) return;
940
+ this.searchInput.value = "";
941
+ this.query = "";
942
+ this.afterSelectionChange();
943
+ for (const result of created) {
944
+ if (result.created) this.emitter.emit("create", result.option);
945
+ this.emitter.emit("select", result.option);
946
+ }
947
+ if (this.opts.closeOnSelect) this.close();
948
+ else this.renderList();
949
+ }).catch((cause) => {
950
+ const error = cause instanceof Error ? cause : new Error(String(cause));
951
+ this.emitter.emit("error", error);
952
+ });
852
953
  });
853
954
  }
854
955
  this.list.addEventListener("click", (event) => {
@@ -872,8 +973,12 @@ var ForgeSelect = class {
872
973
  this.activateNavItem(navIndex);
873
974
  });
874
975
  this.list.addEventListener("scroll", () => {
875
- if (this.usesVirtualScroll()) this.renderRows();
876
- this.maybeLoadNextPage();
976
+ if (this.scrollRafId != null) return;
977
+ this.scrollRafId = requestAnimationFrame(() => {
978
+ this.scrollRafId = null;
979
+ if (this.usesVirtualScroll()) this.renderRows();
980
+ this.maybeLoadNextPage();
981
+ });
877
982
  });
878
983
  }
879
984
  applySearchQuery(query, emitSearch) {
@@ -881,7 +986,6 @@ var ForgeSelect = class {
881
986
  if (this.searchInput && this.searchInput.value !== query) this.searchInput.value = query;
882
987
  this.highlightedIndex = -1;
883
988
  this.list.scrollTop = 0;
884
- this.rowContentCache.clear();
885
989
  if (emitSearch) this.emitter.emit("search", query);
886
990
  const trimmed = query.trim();
887
991
  const belowMinLength = trimmed !== "" && trimmed.length < this.opts.minSearchLength;
@@ -900,7 +1004,7 @@ var ForgeSelect = class {
900
1004
  this.renderList();
901
1005
  }
902
1006
  handleKeydown(event) {
903
- if (this.isDisabled) return;
1007
+ if (this.isDisabled || event.isComposing || event.keyCode === 229) return;
904
1008
  switch (event.key) {
905
1009
  case "Enter":
906
1010
  event.preventDefault();
@@ -935,9 +1039,63 @@ var ForgeSelect = class {
935
1039
  case "ArrowLeft":
936
1040
  if (this.isOpen && this.navigateTree("left")) event.preventDefault();
937
1041
  break;
1042
+ case "Home":
1043
+ if (this.isOpen) {
1044
+ event.preventDefault();
1045
+ this.focusNavIndex(0);
1046
+ }
1047
+ break;
1048
+ case "End":
1049
+ if (this.isOpen) {
1050
+ event.preventDefault();
1051
+ this.focusNavIndex(this.navItems.length - 1);
1052
+ }
1053
+ break;
1054
+ case "PageDown":
1055
+ if (this.isOpen) {
1056
+ event.preventDefault();
1057
+ this.focusNavIndex(
1058
+ Math.min(this.navItems.length - 1, (this.highlightedIndex === -1 ? 0 : this.highlightedIndex) + PAGE_SIZE)
1059
+ );
1060
+ }
1061
+ break;
1062
+ case "PageUp":
1063
+ if (this.isOpen) {
1064
+ event.preventDefault();
1065
+ this.focusNavIndex(Math.max(0, (this.highlightedIndex === -1 ? 0 : this.highlightedIndex) - PAGE_SIZE));
1066
+ }
1067
+ break;
938
1068
  case "Tab":
939
1069
  this.close();
940
1070
  break;
1071
+ default:
1072
+ if (this.isOpen && event.target === this.control && event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
1073
+ this.handleTypeahead(event.key);
1074
+ }
1075
+ break;
1076
+ }
1077
+ }
1078
+ /**
1079
+ * Jumps the highlight to the next nav item (wrapping) whose label starts
1080
+ * with the accumulated buffer, matching native <select> typeahead: rapid
1081
+ * distinct keystrokes narrow the prefix, a pause resets it.
1082
+ */
1083
+ handleTypeahead(char) {
1084
+ if (this.typeaheadTimer) clearTimeout(this.typeaheadTimer);
1085
+ this.typeaheadBuffer += normalizeSearchText(char, this.opts.accentInsensitive);
1086
+ this.typeaheadTimer = setTimeout(() => {
1087
+ this.typeaheadBuffer = "";
1088
+ this.typeaheadTimer = null;
1089
+ }, TYPEAHEAD_RESET_MS);
1090
+ const prefix = [...this.typeaheadBuffer].every((value) => value === this.typeaheadBuffer[0]) ? this.typeaheadBuffer[0] : this.typeaheadBuffer;
1091
+ const count = this.navItems.length;
1092
+ for (let step = 1; step <= count; step += 1) {
1093
+ const index = (this.highlightedIndex + step + count) % count;
1094
+ const item = this.navItems[index];
1095
+ if (item.kind === "option" && normalizeSearchText(item.option.label, this.opts.accentInsensitive).startsWith(prefix)) {
1096
+ this.focusNavIndex(index);
1097
+ return;
1098
+ }
941
1099
  }
942
1100
  }
943
1101
  // ---------------------------------------------------------------- selection
@@ -1028,7 +1186,10 @@ var ForgeSelect = class {
1028
1186
  this.control.classList.remove("forge-select__control--invalid");
1029
1187
  this.control.removeAttribute("aria-invalid");
1030
1188
  }
1031
- if (this.isOpen) this.renderList();
1189
+ if (this.isOpen) {
1190
+ if (this.opts.maxSelections != null) this.renderList();
1191
+ else this.renderRows();
1192
+ }
1032
1193
  if (emitChange) this.emitter.emit("change", this.getValue());
1033
1194
  }
1034
1195
  syncNativeSelect(dispatchChange = true) {
@@ -1061,23 +1222,27 @@ var ForgeSelect = class {
1061
1222
  }
1062
1223
  }
1063
1224
  findOption(value) {
1064
- return findOption(this.data, value);
1225
+ return this.optionByValue.get(value);
1065
1226
  }
1066
1227
  findOptionByLabel(label) {
1067
- const lower = label.toLowerCase();
1068
- const search = (options) => {
1069
- for (const option of options) {
1070
- if (option.label.toLowerCase() === lower) return option;
1071
- const found = option.children ? search(option.children) : void 0;
1072
- if (found) return found;
1073
- }
1074
- return void 0;
1228
+ return this.optionByLabel.get(normalizeSearchText(label, this.opts.accentInsensitive));
1229
+ }
1230
+ rebuildOptionIndexes() {
1231
+ this.optionByValue.clear();
1232
+ this.optionByLabel.clear();
1233
+ const duplicates = /* @__PURE__ */ new Set();
1234
+ const visit = (option) => {
1235
+ if (this.optionByValue.has(option.value)) duplicates.add(option.value);
1236
+ else this.optionByValue.set(option.value, option);
1237
+ const label = normalizeSearchText(option.label, this.opts.accentInsensitive);
1238
+ if (!this.optionByLabel.has(label)) this.optionByLabel.set(label, option);
1239
+ option.children?.forEach(visit);
1075
1240
  };
1076
- for (const item of this.data) {
1077
- const found = search(isGroup(item) ? item.options : [item]);
1078
- if (found) return found;
1079
- }
1080
- return void 0;
1241
+ for (const item of this.data) (isGroup(item) ? item.options : [item]).forEach(visit);
1242
+ if (duplicates.size === 0 || this.opts.duplicateValuePolicy === "ignore") return;
1243
+ const message = `ForgeSelect: duplicate option value(s): ${[...duplicates].join(", ")}`;
1244
+ if (this.opts.duplicateValuePolicy === "error") throw new Error(message);
1245
+ console.warn(message);
1081
1246
  }
1082
1247
  /** Selects an existing option matching `label` exactly, or creates and selects a new one. */
1083
1248
  createTag(label) {
@@ -1093,12 +1258,26 @@ var ForgeSelect = class {
1093
1258
  this.selectValue(existing.value, false);
1094
1259
  return { option: existing, created: false };
1095
1260
  }
1096
- const option = { value: trimmed, label: trimmed };
1261
+ if (this.opts.beforeCreate?.(trimmed) === false) return void 0;
1262
+ const created = this.opts.createOption?.(trimmed) ?? { value: trimmed, label: trimmed };
1263
+ if (created instanceof Promise) {
1264
+ return created.then((option) => option ? this.addCreatedOption(option) : void 0);
1265
+ }
1266
+ return created ? this.addCreatedOption(created) : void 0;
1267
+ }
1268
+ addCreatedOption(option) {
1097
1269
  if (this.opts.multiple && !this.canSelectOption(option)) {
1098
1270
  this.announceMaximum(option);
1099
1271
  return void 0;
1100
1272
  }
1273
+ const duplicate = this.findOption(option.value);
1274
+ if (duplicate) {
1275
+ if (this.selected.includes(duplicate.value)) return void 0;
1276
+ this.selectValue(duplicate.value, false);
1277
+ return { option: duplicate, created: false };
1278
+ }
1101
1279
  this.data.push(option);
1280
+ this.rebuildOptionIndexes();
1102
1281
  this.selectValue(option.value, false);
1103
1282
  return { option, created: true };
1104
1283
  }
@@ -1106,8 +1285,18 @@ var ForgeSelect = class {
1106
1285
  const label = this.query.trim();
1107
1286
  if (!label) return;
1108
1287
  const result = this.createTag(label);
1109
- if (!result) return;
1110
- if (this.searchInput) {
1288
+ if (result instanceof Promise) {
1289
+ void result.then((created) => this.finishCreateFromQuery(created, label)).catch((cause) => {
1290
+ const error = cause instanceof Error ? cause : new Error(String(cause));
1291
+ this.emitter.emit("error", error);
1292
+ });
1293
+ return;
1294
+ }
1295
+ this.finishCreateFromQuery(result, label);
1296
+ }
1297
+ finishCreateFromQuery(result, sourceLabel) {
1298
+ if (!result || this.destroyed) return;
1299
+ if (this.searchInput && this.query.trim() === sourceLabel) {
1111
1300
  this.searchInput.value = "";
1112
1301
  this.query = "";
1113
1302
  }
@@ -1115,6 +1304,7 @@ var ForgeSelect = class {
1115
1304
  if (result.created) this.emitter.emit("create", result.option);
1116
1305
  this.emitter.emit("select", result.option);
1117
1306
  if (!this.opts.multiple || this.opts.closeOnSelect) this.close();
1307
+ else if (this.isOpen) this.renderList();
1118
1308
  }
1119
1309
  activateNavItem(navIndex) {
1120
1310
  const item = this.navItems[navIndex];
@@ -1127,9 +1317,11 @@ var ForgeSelect = class {
1127
1317
  if (this.opts.multiple) {
1128
1318
  let changed = false;
1129
1319
  if (this.selected.includes(value)) {
1320
+ if (this.opts.beforeUnselect?.(item.option) === false) return;
1130
1321
  this.deselectValue(value, true);
1131
1322
  changed = true;
1132
1323
  } else if (this.canSelectOption(item.option)) {
1324
+ if (this.opts.beforeSelect?.(item.option) === false) return;
1133
1325
  this.selectValue(value, true);
1134
1326
  changed = true;
1135
1327
  } else {
@@ -1137,6 +1329,7 @@ var ForgeSelect = class {
1137
1329
  }
1138
1330
  if (changed && this.opts.closeOnSelect) this.close();
1139
1331
  } else {
1332
+ if (this.opts.beforeSelect?.(item.option) === false) return;
1140
1333
  this.selectValue(value, true);
1141
1334
  this.close();
1142
1335
  this.control.focus();
@@ -1161,7 +1354,7 @@ var ForgeSelect = class {
1161
1354
  tag.className = "forge-select__tag";
1162
1355
  const label = document.createElement("span");
1163
1356
  label.className = "forge-select__tag-label";
1164
- renderOptionContent(label, option, this.opts.templateSelection, "inline");
1357
+ renderOptionContent(label, option, this.opts.templateSelection, "inline", this.opts.sanitizeTemplate);
1165
1358
  const remove = document.createElement("button");
1166
1359
  remove.type = "button";
1167
1360
  remove.className = "forge-select__tag-remove";
@@ -1169,7 +1362,7 @@ var ForgeSelect = class {
1169
1362
  remove.textContent = "\xD7";
1170
1363
  remove.addEventListener("click", (event) => {
1171
1364
  event.stopPropagation();
1172
- if (!this.isDisabled) this.deselectValue(value, true);
1365
+ if (!this.isDisabled && this.opts.beforeUnselect?.(option) !== false) this.deselectValue(value, true);
1173
1366
  });
1174
1367
  tag.append(label, remove);
1175
1368
  if (this.opts.sortable) {
@@ -1189,7 +1382,7 @@ var ForgeSelect = class {
1189
1382
  };
1190
1383
  const span = document.createElement("span");
1191
1384
  span.className = "forge-select__single-value";
1192
- renderOptionContent(span, option, this.opts.templateSelection, "inline");
1385
+ renderOptionContent(span, option, this.opts.templateSelection, "inline", this.opts.sanitizeTemplate);
1193
1386
  this.valueEl.append(span);
1194
1387
  }
1195
1388
  }
@@ -1289,6 +1482,7 @@ var ForgeSelect = class {
1289
1482
  buildRows() {
1290
1483
  this.rows = [];
1291
1484
  this.navItems = [];
1485
+ this.rowOffsetsCache = null;
1292
1486
  const trimmedQuery = this.query.trim();
1293
1487
  const query = normalizeSearchText(trimmedQuery, this.opts.accentInsensitive);
1294
1488
  const matches = (option) => query === "" || (this.opts.filterOption ? this.opts.filterOption(option, trimmedQuery) : this.searchIndex.score(option, trimmedQuery, {
@@ -1297,7 +1491,14 @@ var ForgeSelect = class {
1297
1491
  accentInsensitive: this.opts.accentInsensitive,
1298
1492
  scorer: this.opts.searchScorer
1299
1493
  }) > 0);
1300
- const subtreeMatches = (option) => query === "" || matches(option) || (option.children ?? []).some(subtreeMatches);
1494
+ const subtreeMatchCache = /* @__PURE__ */ new Map();
1495
+ const subtreeMatches = (option) => {
1496
+ const cached = subtreeMatchCache.get(option);
1497
+ if (cached !== void 0) return cached;
1498
+ const result = query === "" || matches(option) || (option.children ?? []).some(subtreeMatches);
1499
+ subtreeMatchCache.set(option, result);
1500
+ return result;
1501
+ };
1301
1502
  const pushOption = (option, depth, parentValue) => {
1302
1503
  let navIndex = -1;
1303
1504
  const interactionDisabled = this.isOptionDisabled(option) || this.hasReachedMaximum() && !this.selected.includes(option.value);
@@ -1353,7 +1554,7 @@ var ForgeSelect = class {
1353
1554
  return this.opts.virtualScroll !== false && this.rows.length > VIRTUAL_THRESHOLD;
1354
1555
  }
1355
1556
  rowKey(row, index) {
1356
- if (row.kind === "option") return `option:${row.option.value}`;
1557
+ if (row.kind === "option") return `option:${row.option.value}:${index}`;
1357
1558
  if (row.kind === "group") return `group:${row.label}:${index}`;
1358
1559
  return `${row.kind}:${index}`;
1359
1560
  }
@@ -1367,8 +1568,10 @@ var ForgeSelect = class {
1367
1568
  return offset;
1368
1569
  }
1369
1570
  rowOffsets() {
1571
+ if (this.rowOffsetsCache) return this.rowOffsetsCache;
1370
1572
  const offsets = [0];
1371
1573
  for (let i = 0; i < this.rows.length; i += 1) offsets.push(offsets[i] + this.measuredRowHeight(i));
1574
+ this.rowOffsetsCache = offsets;
1372
1575
  return offsets;
1373
1576
  }
1374
1577
  renderList() {
@@ -1393,7 +1596,14 @@ var ForgeSelect = class {
1393
1596
  if (virtual) {
1394
1597
  const viewport = clientHeight || rowHeight * 8;
1395
1598
  if (this.opts.variableItemHeight) {
1396
- while (start < this.rows.length && offsets[start + 1] < scrollTop) start += 1;
1599
+ let low = 0;
1600
+ let high = this.rows.length;
1601
+ while (low < high) {
1602
+ const middle = low + high >>> 1;
1603
+ if (offsets[middle + 1] < scrollTop) low = middle + 1;
1604
+ else high = middle;
1605
+ }
1606
+ start = low;
1397
1607
  start = Math.max(0, start - VIRTUAL_BUFFER);
1398
1608
  end = start;
1399
1609
  const target = scrollTop + viewport + VIRTUAL_BUFFER * rowHeight;
@@ -1408,12 +1618,27 @@ var ForgeSelect = class {
1408
1618
  topSpacer.style.height = `${offsets?.[start] ?? this.rowOffset(start)}px`;
1409
1619
  this.list.append(topSpacer);
1410
1620
  }
1621
+ const appended = [];
1411
1622
  for (let i = start; i < end; i++) {
1412
- const element = this.renderRow(this.rows[i]);
1623
+ const key = this.rowKey(this.rows[i], i);
1624
+ const element = this.renderRow(this.rows[i], this.rowElementCache.get(key));
1625
+ this.rowElementCache.set(key, element);
1626
+ if (this.rowElementCache.size > ROW_CACHE_LIMIT) {
1627
+ const oldest = this.rowElementCache.keys().next().value;
1628
+ this.rowElementCache.delete(oldest);
1629
+ }
1413
1630
  this.list.append(element);
1414
- if (this.opts.variableItemHeight) {
1631
+ appended.push(element);
1632
+ }
1633
+ if (this.opts.variableItemHeight) {
1634
+ for (let i = start; i < end; i++) {
1635
+ const element = appended[i - start];
1415
1636
  const measured = element.getBoundingClientRect().height || element.offsetHeight;
1416
- if (measured > 0) this.rowHeightCache.set(this.rowKey(this.rows[i], i), measured);
1637
+ if (measured > 0) {
1638
+ const key = this.rowKey(this.rows[i], i);
1639
+ if (this.rowHeightCache.get(key) !== measured) this.rowOffsetsCache = null;
1640
+ this.rowHeightCache.set(key, measured);
1641
+ }
1417
1642
  }
1418
1643
  }
1419
1644
  if (virtual) {
@@ -1428,8 +1653,22 @@ var ForgeSelect = class {
1428
1653
  }
1429
1654
  this.updateActiveDescendant();
1430
1655
  }
1431
- renderRow(row) {
1432
- const li = document.createElement("li");
1656
+ renderRow(row, recycled) {
1657
+ const li = recycled ?? document.createElement("li");
1658
+ li.replaceChildren();
1659
+ li.className = "";
1660
+ for (const attribute of [
1661
+ "role",
1662
+ "id",
1663
+ "aria-hidden",
1664
+ "aria-selected",
1665
+ "aria-disabled",
1666
+ "aria-expanded",
1667
+ "aria-level",
1668
+ "data-nav-index",
1669
+ "data-option-value"
1670
+ ])
1671
+ li.removeAttribute(attribute);
1433
1672
  switch (row.kind) {
1434
1673
  case "group":
1435
1674
  li.className = "forge-select__group-label";
@@ -1549,7 +1788,7 @@ var ForgeSelect = class {
1549
1788
  if (!cached) {
1550
1789
  const holder = document.createElement("span");
1551
1790
  holder.className = "forge-select__option-content";
1552
- renderOptionContent(holder, option, this.opts.templateResult);
1791
+ renderOptionContent(holder, option, this.opts.templateResult, "row", this.opts.sanitizeTemplate);
1553
1792
  if (this.rowContentCache.size >= ROW_CACHE_LIMIT) {
1554
1793
  const oldest = this.rowContentCache.keys().next().value;
1555
1794
  this.rowContentCache.delete(oldest);
@@ -1565,6 +1804,7 @@ var ForgeSelect = class {
1565
1804
  this.focusNavIndex(next);
1566
1805
  }
1567
1806
  focusNavIndex(next) {
1807
+ if (this.navItems.length === 0) return;
1568
1808
  this.highlightedIndex = next;
1569
1809
  if (this.usesVirtualScroll()) {
1570
1810
  const rowIndex = this.rows.findIndex(
@@ -1637,6 +1877,7 @@ var ForgeSelect = class {
1637
1877
  this.ajaxController = null;
1638
1878
  this.page = 0;
1639
1879
  this.hasMore = true;
1880
+ this.nextCursor = void 0;
1640
1881
  this.setLoading(true);
1641
1882
  this.loadingMore = false;
1642
1883
  this.loadError = null;
@@ -1651,17 +1892,27 @@ var ForgeSelect = class {
1651
1892
  this.loading = loading;
1652
1893
  this.emitter.emit("loading", loading);
1653
1894
  }
1654
- remoteCacheKey(query, page) {
1655
- return `${query}\0${page}`;
1895
+ remoteCacheKey(query, page, cursor) {
1896
+ return `${query}\0${cursor ?? page}`;
1656
1897
  }
1657
- async requestRemote(query, page, signal) {
1898
+ fetchRemoteResult(query, page, signal, cursor) {
1899
+ const key = this.remoteCacheKey(query, page, cursor);
1900
+ const pending = this.remoteInFlight.get(key);
1901
+ if (pending) return pending;
1902
+ const ajax = this.opts.ajax;
1903
+ const request = this.requestRemote(query, page, signal, cursor).then((json) => normalizeRemoteResult(ajax, json)).finally(() => this.remoteInFlight.delete(key));
1904
+ this.remoteInFlight.set(key, request);
1905
+ return request;
1906
+ }
1907
+ async requestRemote(query, page, signal, cursor) {
1658
1908
  const ajax = this.opts.ajax;
1659
1909
  const attempts = Math.max(0, Math.floor(ajax.retry ?? 0)) + 1;
1660
1910
  let lastError;
1661
1911
  for (let attempt = 0; attempt < attempts; attempt += 1) {
1662
1912
  try {
1663
- if (ajax.request) return await ajax.request(query, page, signal);
1664
- const response = await fetch(buildUrl(ajax, query, page), { signal });
1913
+ if (ajax.request)
1914
+ return cursor === void 0 ? await ajax.request(query, page, signal) : await ajax.request(query, page, signal, cursor);
1915
+ const response = await fetch(buildUrl(ajax, query, page, cursor), { signal });
1665
1916
  if (response.ok === false) throw new Error(`ForgeSelect: remote request failed with HTTP ${response.status}`);
1666
1917
  return await response.json();
1667
1918
  } catch (error) {
@@ -1689,10 +1940,13 @@ var ForgeSelect = class {
1689
1940
  const key = this.remoteCacheKey(query, 0);
1690
1941
  if (this.remoteCache.get(key)) return;
1691
1942
  const controller = new AbortController();
1943
+ this.prefetchControllers.add(controller);
1692
1944
  try {
1693
- const json = await this.requestRemote(query, 0, controller.signal);
1694
- this.remoteCache.set(key, normalizeRemoteResult(ajax, json), ajax.cacheTtl ?? 3e4);
1945
+ const result = await this.fetchRemoteResult(query, 0, controller.signal);
1946
+ this.remoteCache.set(key, result, ajax.cacheTtl ?? 3e4);
1695
1947
  } catch {
1948
+ } finally {
1949
+ this.prefetchControllers.delete(controller);
1696
1950
  }
1697
1951
  }
1698
1952
  /**
@@ -1718,12 +1972,12 @@ var ForgeSelect = class {
1718
1972
  const controller = new AbortController();
1719
1973
  this.ajaxController = controller;
1720
1974
  const page = append ? this.page + 1 : 0;
1975
+ const cursor = append ? this.nextCursor : void 0;
1721
1976
  try {
1722
- const key = this.remoteCacheKey(query, page);
1977
+ const key = this.remoteCacheKey(query, page, cursor);
1723
1978
  let result = this.remoteCache.get(key);
1724
1979
  if (!result) {
1725
- const json = await this.requestRemote(query, page, controller.signal);
1726
- result = normalizeRemoteResult(ajax, json);
1980
+ result = await this.fetchRemoteResult(query, page, controller.signal, cursor);
1727
1981
  this.remoteCache.set(key, result, ajax.cacheTtl ?? 3e4);
1728
1982
  }
1729
1983
  if (activeRequestId !== this.ajaxRequestId || this.destroyed) return;
@@ -1734,17 +1988,21 @@ var ForgeSelect = class {
1734
1988
  } else {
1735
1989
  this.data = options;
1736
1990
  this.rowContentCache.clear();
1991
+ this.rowHeightCache.clear();
1737
1992
  }
1738
1993
  this.page = page;
1739
1994
  this.hasMore = hasMore;
1995
+ this.nextCursor = result.nextCursor;
1740
1996
  this.remoteLoaded = true;
1741
1997
  this.loadError = null;
1998
+ this.rebuildOptionIndexes();
1742
1999
  } catch (cause) {
1743
2000
  if (activeRequestId !== this.ajaxRequestId || this.destroyed || controller.signal.aborted) return;
1744
2001
  const error = cause instanceof Error ? cause : new Error(String(cause));
1745
2002
  if (!append) {
1746
2003
  this.data = [];
1747
2004
  this.rowContentCache.clear();
2005
+ this.rowHeightCache.clear();
1748
2006
  }
1749
2007
  this.hasMore = false;
1750
2008
  this.loadError = error;