forge-select 0.6.0 → 0.7.1

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
- if (typeof ajax.url === "function") return ajax.url(query, page);
168
+ if (typeof ajax.url === "function") return ajax.url(query, page, cursor);
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,7 +180,12 @@ 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
@@ -294,21 +299,6 @@ function computeCheckState(option, selected, isDisabled = defaultIsDisabled) {
294
299
  if (states.every((state) => state === "none")) return "none";
295
300
  return "some";
296
301
  }
297
- function findOption(items, value) {
298
- const search = (options) => {
299
- for (const option of options) {
300
- if (option.value === value) return option;
301
- const found = option.children ? search(option.children) : void 0;
302
- if (found) return found;
303
- }
304
- return void 0;
305
- };
306
- for (const item of items) {
307
- const found = search(isGroup(item) ? item.options : [item]);
308
- if (found) return found;
309
- }
310
- return void 0;
311
- }
312
302
  function syncTreeAncestors(items, selected, isDisabled = defaultIsDisabled) {
313
303
  const sync = (option) => {
314
304
  if (!option.children?.length) return;
@@ -343,6 +333,8 @@ var TYPEAHEAD_RESET_MS = 500;
343
333
  var uidCounter = 0;
344
334
  var ForgeSelect = class {
345
335
  constructor(target, options = {}) {
336
+ this.optionByValue = /* @__PURE__ */ new Map();
337
+ this.optionByLabel = /* @__PURE__ */ new Map();
346
338
  this.selected = [];
347
339
  this.selectedOptions = /* @__PURE__ */ new Map();
348
340
  this.suppressNextTagClick = false;
@@ -360,6 +352,7 @@ var ForgeSelect = class {
360
352
  this.typeaheadBuffer = "";
361
353
  this.typeaheadTimer = null;
362
354
  this.rowContentCache = /* @__PURE__ */ new Map();
355
+ this.rowElementCache = /* @__PURE__ */ new Map();
363
356
  this.rowHeightCache = /* @__PURE__ */ new Map();
364
357
  this.rowOffsetsCache = null;
365
358
  this.scrollRafId = null;
@@ -375,6 +368,8 @@ var ForgeSelect = class {
375
368
  this.ajaxController = null;
376
369
  this.remoteLoaded = false;
377
370
  this.remoteCache = new RemoteCache();
371
+ this.remoteInFlight = /* @__PURE__ */ new Map();
372
+ this.prefetchControllers = /* @__PURE__ */ new Set();
378
373
  this.loadError = null;
379
374
  this.originalDisplay = "";
380
375
  this.originalDisabled = false;
@@ -443,6 +438,13 @@ var ForgeSelect = class {
443
438
  ajax: options.ajax,
444
439
  templateResult: options.templateResult,
445
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",
446
448
  filterOption: options.filterOption,
447
449
  searchFields: options.searchFields ?? ["label", "description"],
448
450
  tokenSearch: options.tokenSearch ?? true,
@@ -464,6 +466,7 @@ var ForgeSelect = class {
464
466
  this.plugins = this.opts.plugins;
465
467
  if (nativeSelect) nativeSelect.required = this.opts.required;
466
468
  this.data = this.opts.data ?? (nativeSelect ? parseNativeOptions(nativeSelect) : []);
469
+ this.rebuildOptionIndexes();
467
470
  if (nativeSelect && !this.opts.data) {
468
471
  const nativeOptions = Array.from(nativeSelect.options);
469
472
  const hasIntentionalSelection = nativeSelect.multiple || nativeSelect.selectedIndex > 0 || nativeOptions.some((option) => option.defaultSelected);
@@ -501,6 +504,8 @@ var ForgeSelect = class {
501
504
  this.renderList();
502
505
  this.positionDropdown();
503
506
  window.addEventListener("resize", this.onWindowResize);
507
+ window.visualViewport?.addEventListener("resize", this.onWindowResize);
508
+ window.visualViewport?.addEventListener("scroll", this.onWindowResize);
504
509
  document.addEventListener("scroll", this.onAncestorScroll, true);
505
510
  if (this.searchInput && !this.searchInput.hidden) this.searchInput.focus();
506
511
  this.emitter.emit("open");
@@ -515,6 +520,8 @@ var ForgeSelect = class {
515
520
  this.control.setAttribute("aria-expanded", "false");
516
521
  document.removeEventListener("mousedown", this.onDocumentMouseDown);
517
522
  window.removeEventListener("resize", this.onWindowResize);
523
+ window.visualViewport?.removeEventListener("resize", this.onWindowResize);
524
+ window.visualViewport?.removeEventListener("scroll", this.onWindowResize);
518
525
  document.removeEventListener("scroll", this.onAncestorScroll, true);
519
526
  if (this.ancestorScrollRafId != null) {
520
527
  cancelAnimationFrame(this.ancestorScrollRafId);
@@ -546,11 +553,16 @@ var ForgeSelect = class {
546
553
  */
547
554
  positionDropdown() {
548
555
  const controlRect = this.control.getBoundingClientRect();
549
- 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
+ );
550
562
  this.root.classList.toggle("forge-select--drop-up", placement.dropUp);
551
563
  if (this.portalHost) {
552
- this.portalHost.style.top = `${placement.top}px`;
553
- 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`;
554
566
  this.portalHost.style.width = `${controlRect.width}px`;
555
567
  }
556
568
  }
@@ -561,13 +573,15 @@ var ForgeSelect = class {
561
573
  this.destroyed = true;
562
574
  if (this.ajaxTimer) clearTimeout(this.ajaxTimer);
563
575
  this.ajaxController?.abort();
576
+ for (const controller of this.prefetchControllers) controller.abort();
577
+ this.prefetchControllers.clear();
578
+ this.remoteInFlight.clear();
564
579
  if (this.scrollRafId != null) cancelAnimationFrame(this.scrollRafId);
565
580
  if (this.typeaheadTimer) clearTimeout(this.typeaheadTimer);
566
581
  this.nativeSelect?.removeEventListener("change", this.onNativeChange);
567
582
  this.nativeSelect?.removeEventListener("invalid", this.onNativeInvalid);
568
583
  this.nativeForm?.removeEventListener("reset", this.onFormReset);
569
- this.rowContentCache.clear();
570
- this.rowHeightCache.clear();
584
+ this.clearRowCaches();
571
585
  this.searchIndex.clear();
572
586
  this.portalHost?.remove();
573
587
  this.root.remove();
@@ -615,10 +629,23 @@ var ForgeSelect = class {
615
629
  }
616
630
  if (options.templateResult !== void 0) this.opts.templateResult = options.templateResult;
617
631
  if (options.templateSelection !== void 0) this.opts.templateSelection = options.templateSelection;
632
+ if (options.sanitizeTemplate !== void 0) this.opts.sanitizeTemplate = options.sanitizeTemplate;
633
+ if (options.beforeSelect !== void 0) this.opts.beforeSelect = options.beforeSelect;
634
+ if (options.beforeUnselect !== void 0) this.opts.beforeUnselect = options.beforeUnselect;
635
+ if (options.beforeCreate !== void 0) this.opts.beforeCreate = options.beforeCreate;
636
+ if (options.createOption !== void 0) this.opts.createOption = options.createOption;
637
+ if (options.missingSelectionPolicy !== void 0) this.opts.missingSelectionPolicy = options.missingSelectionPolicy;
638
+ if (options.duplicateValuePolicy !== void 0) {
639
+ this.opts.duplicateValuePolicy = options.duplicateValuePolicy;
640
+ this.rebuildOptionIndexes();
641
+ }
618
642
  if (options.filterOption !== void 0) this.opts.filterOption = options.filterOption;
619
643
  if (options.searchFields !== void 0) this.opts.searchFields = options.searchFields;
620
644
  if (options.tokenSearch !== void 0) this.opts.tokenSearch = options.tokenSearch;
621
- if (options.accentInsensitive !== void 0) this.opts.accentInsensitive = options.accentInsensitive;
645
+ if (options.accentInsensitive !== void 0) {
646
+ this.opts.accentInsensitive = options.accentInsensitive;
647
+ this.rebuildOptionIndexes();
648
+ }
622
649
  if (options.searchScorer !== void 0) this.opts.searchScorer = options.searchScorer;
623
650
  if (options.highlightSearch !== void 0) this.opts.highlightSearch = options.highlightSearch;
624
651
  if (options.minSearchLength !== void 0)
@@ -646,8 +673,7 @@ var ForgeSelect = class {
646
673
  }
647
674
  this.root.classList.toggle("forge-select--sortable", this.opts.sortable && this.opts.multiple);
648
675
  this.updateSearchVisibility();
649
- this.rowContentCache.clear();
650
- this.rowHeightCache.clear();
676
+ this.clearRowCaches();
651
677
  this.searchIndex.clear();
652
678
  this.renderValue();
653
679
  if (this.isOpen) this.renderList();
@@ -679,6 +705,7 @@ var ForgeSelect = class {
679
705
  }
680
706
  clearRemoteCache() {
681
707
  this.remoteCache.clear();
708
+ this.remoteInFlight.clear();
682
709
  }
683
710
  setValue(value, options = {}) {
684
711
  const values = value == null ? [] : Array.isArray(value) ? value : [value];
@@ -708,11 +735,29 @@ var ForgeSelect = class {
708
735
  this.remoteLoaded = true;
709
736
  this.page = 0;
710
737
  this.hasMore = false;
738
+ this.nextCursor = void 0;
739
+ const previousData = this.data;
711
740
  this.data = data;
741
+ try {
742
+ this.rebuildOptionIndexes();
743
+ } catch (error) {
744
+ this.data = previousData;
745
+ this.rebuildOptionIndexes();
746
+ throw error;
747
+ }
748
+ const missing = this.selected.filter((value) => !this.optionByValue.has(value));
749
+ if (missing.length > 0 && this.opts.missingSelectionPolicy === "error") {
750
+ this.data = previousData;
751
+ this.rebuildOptionIndexes();
752
+ throw new Error(`ForgeSelect: setData() is missing selected value(s): ${missing.join(", ")}`);
753
+ }
712
754
  this.opts.data = data;
755
+ if (missing.length > 0 && this.opts.missingSelectionPolicy === "prune") {
756
+ this.selected = this.selected.filter((value) => this.optionByValue.has(value));
757
+ this.afterSelectionChange();
758
+ }
713
759
  this.updateSearchVisibility();
714
- this.rowContentCache.clear();
715
- this.rowHeightCache.clear();
760
+ this.clearRowCaches();
716
761
  this.searchIndex.clear();
717
762
  this.highlightedIndex = -1;
718
763
  if (this.isOpen) this.renderList();
@@ -853,6 +898,7 @@ var ForgeSelect = class {
853
898
  if (portalParent) {
854
899
  this.portalHost = document.createElement("div");
855
900
  this.portalHost.className = "forge-select forge-select--portal-host";
901
+ this.portalHost.style.direction = getComputedStyle(this.root).direction;
856
902
  this.portalHost.dataset.theme = this.opts.theme;
857
903
  this.portalHost.style.setProperty("--fs-item-height", `${this.opts.itemHeight}px`);
858
904
  this.portalHost.append(this.dropdown);
@@ -883,10 +929,24 @@ var ForgeSelect = class {
883
929
  });
884
930
  this.clearBtn.addEventListener("click", (event) => {
885
931
  event.stopPropagation();
932
+ if (this.selected.some((value) => {
933
+ const option = this.findOption(value) ?? this.selectedOptions.get(value) ?? { value, label: value };
934
+ return this.opts.beforeUnselect?.(option) === false;
935
+ }))
936
+ return;
886
937
  this.clearSelection();
887
938
  });
888
939
  if (this.searchInput) {
940
+ let composing = false;
941
+ this.searchInput.addEventListener("compositionstart", () => {
942
+ composing = true;
943
+ });
944
+ this.searchInput.addEventListener("compositionend", () => {
945
+ composing = false;
946
+ this.applySearchQuery(this.searchInput.value, true);
947
+ });
889
948
  this.searchInput.addEventListener("input", () => {
949
+ if (composing) return;
890
950
  this.applySearchQuery(this.searchInput.value, true);
891
951
  });
892
952
  this.searchInput.addEventListener("keydown", (event) => this.handleKeydown(event));
@@ -896,21 +956,22 @@ var ForgeSelect = class {
896
956
  const labels = text.split(/[,\n]+/).map((s) => s.trim()).filter(Boolean);
897
957
  if (labels.length < 2) return;
898
958
  event.preventDefault();
899
- const created = [];
900
- for (const label of labels) {
901
- const result = this.createTag(label);
902
- if (result) created.push(result);
903
- }
904
- if (created.length === 0) return;
905
- this.searchInput.value = "";
906
- this.query = "";
907
- this.afterSelectionChange();
908
- for (const result of created) {
909
- if (result.created) this.emitter.emit("create", result.option);
910
- this.emitter.emit("select", result.option);
911
- }
912
- if (this.opts.closeOnSelect) this.close();
913
- else this.renderList();
959
+ void Promise.all(labels.map((label) => this.createTag(label))).then((results) => {
960
+ const created = results.filter((result) => result !== void 0);
961
+ if (created.length === 0 || this.destroyed) return;
962
+ this.searchInput.value = "";
963
+ this.query = "";
964
+ this.afterSelectionChange();
965
+ for (const result of created) {
966
+ if (result.created) this.emitter.emit("create", result.option);
967
+ this.emitter.emit("select", result.option);
968
+ }
969
+ if (this.opts.closeOnSelect) this.close();
970
+ else this.renderList();
971
+ }).catch((cause) => {
972
+ const error = cause instanceof Error ? cause : new Error(String(cause));
973
+ this.emitter.emit("error", error);
974
+ });
914
975
  });
915
976
  }
916
977
  this.list.addEventListener("click", (event) => {
@@ -965,7 +1026,7 @@ var ForgeSelect = class {
965
1026
  this.renderList();
966
1027
  }
967
1028
  handleKeydown(event) {
968
- if (this.isDisabled) return;
1029
+ if (this.isDisabled || event.isComposing || event.keyCode === 229) return;
969
1030
  switch (event.key) {
970
1031
  case "Enter":
971
1032
  event.preventDefault();
@@ -1183,23 +1244,27 @@ var ForgeSelect = class {
1183
1244
  }
1184
1245
  }
1185
1246
  findOption(value) {
1186
- return findOption(this.data, value);
1247
+ return this.optionByValue.get(value);
1187
1248
  }
1188
1249
  findOptionByLabel(label) {
1189
- const lower = label.toLowerCase();
1190
- const search = (options) => {
1191
- for (const option of options) {
1192
- if (option.label.toLowerCase() === lower) return option;
1193
- const found = option.children ? search(option.children) : void 0;
1194
- if (found) return found;
1195
- }
1196
- return void 0;
1250
+ return this.optionByLabel.get(normalizeSearchText(label, this.opts.accentInsensitive));
1251
+ }
1252
+ rebuildOptionIndexes() {
1253
+ this.optionByValue.clear();
1254
+ this.optionByLabel.clear();
1255
+ const duplicates = /* @__PURE__ */ new Set();
1256
+ const visit = (option) => {
1257
+ if (this.optionByValue.has(option.value)) duplicates.add(option.value);
1258
+ else this.optionByValue.set(option.value, option);
1259
+ const label = normalizeSearchText(option.label, this.opts.accentInsensitive);
1260
+ if (!this.optionByLabel.has(label)) this.optionByLabel.set(label, option);
1261
+ option.children?.forEach(visit);
1197
1262
  };
1198
- for (const item of this.data) {
1199
- const found = search(isGroup(item) ? item.options : [item]);
1200
- if (found) return found;
1201
- }
1202
- return void 0;
1263
+ for (const item of this.data) (isGroup(item) ? item.options : [item]).forEach(visit);
1264
+ if (duplicates.size === 0 || this.opts.duplicateValuePolicy === "ignore") return;
1265
+ const message = `ForgeSelect: duplicate option value(s): ${[...duplicates].join(", ")}`;
1266
+ if (this.opts.duplicateValuePolicy === "error") throw new Error(message);
1267
+ console.warn(message);
1203
1268
  }
1204
1269
  /** Selects an existing option matching `label` exactly, or creates and selects a new one. */
1205
1270
  createTag(label) {
@@ -1212,15 +1277,31 @@ var ForgeSelect = class {
1212
1277
  this.announceMaximum(existing);
1213
1278
  return void 0;
1214
1279
  }
1280
+ if (this.opts.beforeSelect?.(existing) === false) return void 0;
1215
1281
  this.selectValue(existing.value, false);
1216
1282
  return { option: existing, created: false };
1217
1283
  }
1218
- const option = { value: trimmed, label: trimmed };
1284
+ if (this.opts.beforeCreate?.(trimmed) === false) return void 0;
1285
+ const created = this.opts.createOption?.(trimmed) ?? { value: trimmed, label: trimmed };
1286
+ if (created instanceof Promise) {
1287
+ return created.then((option) => option ? this.addCreatedOption(option) : void 0);
1288
+ }
1289
+ return created ? this.addCreatedOption(created) : void 0;
1290
+ }
1291
+ addCreatedOption(option) {
1219
1292
  if (this.opts.multiple && !this.canSelectOption(option)) {
1220
1293
  this.announceMaximum(option);
1221
1294
  return void 0;
1222
1295
  }
1296
+ const duplicate = this.findOption(option.value);
1297
+ if (duplicate) {
1298
+ if (this.selected.includes(duplicate.value)) return void 0;
1299
+ if (this.opts.beforeSelect?.(duplicate) === false) return void 0;
1300
+ this.selectValue(duplicate.value, false);
1301
+ return { option: duplicate, created: false };
1302
+ }
1223
1303
  this.data.push(option);
1304
+ this.rebuildOptionIndexes();
1224
1305
  this.selectValue(option.value, false);
1225
1306
  return { option, created: true };
1226
1307
  }
@@ -1228,8 +1309,18 @@ var ForgeSelect = class {
1228
1309
  const label = this.query.trim();
1229
1310
  if (!label) return;
1230
1311
  const result = this.createTag(label);
1231
- if (!result) return;
1232
- if (this.searchInput) {
1312
+ if (result instanceof Promise) {
1313
+ void result.then((created) => this.finishCreateFromQuery(created, label)).catch((cause) => {
1314
+ const error = cause instanceof Error ? cause : new Error(String(cause));
1315
+ this.emitter.emit("error", error);
1316
+ });
1317
+ return;
1318
+ }
1319
+ this.finishCreateFromQuery(result, label);
1320
+ }
1321
+ finishCreateFromQuery(result, sourceLabel) {
1322
+ if (!result || this.destroyed) return;
1323
+ if (this.searchInput && this.query.trim() === sourceLabel) {
1233
1324
  this.searchInput.value = "";
1234
1325
  this.query = "";
1235
1326
  }
@@ -1250,9 +1341,11 @@ var ForgeSelect = class {
1250
1341
  if (this.opts.multiple) {
1251
1342
  let changed = false;
1252
1343
  if (this.selected.includes(value)) {
1344
+ if (this.opts.beforeUnselect?.(item.option) === false) return;
1253
1345
  this.deselectValue(value, true);
1254
1346
  changed = true;
1255
1347
  } else if (this.canSelectOption(item.option)) {
1348
+ if (this.opts.beforeSelect?.(item.option) === false) return;
1256
1349
  this.selectValue(value, true);
1257
1350
  changed = true;
1258
1351
  } else {
@@ -1260,6 +1353,7 @@ var ForgeSelect = class {
1260
1353
  }
1261
1354
  if (changed && this.opts.closeOnSelect) this.close();
1262
1355
  } else {
1356
+ if (this.opts.beforeSelect?.(item.option) === false) return;
1263
1357
  this.selectValue(value, true);
1264
1358
  this.close();
1265
1359
  this.control.focus();
@@ -1284,7 +1378,7 @@ var ForgeSelect = class {
1284
1378
  tag.className = "forge-select__tag";
1285
1379
  const label = document.createElement("span");
1286
1380
  label.className = "forge-select__tag-label";
1287
- renderOptionContent(label, option, this.opts.templateSelection, "inline");
1381
+ renderOptionContent(label, option, this.opts.templateSelection, "inline", this.opts.sanitizeTemplate);
1288
1382
  const remove = document.createElement("button");
1289
1383
  remove.type = "button";
1290
1384
  remove.className = "forge-select__tag-remove";
@@ -1292,7 +1386,7 @@ var ForgeSelect = class {
1292
1386
  remove.textContent = "\xD7";
1293
1387
  remove.addEventListener("click", (event) => {
1294
1388
  event.stopPropagation();
1295
- if (!this.isDisabled) this.deselectValue(value, true);
1389
+ if (!this.isDisabled && this.opts.beforeUnselect?.(option) !== false) this.deselectValue(value, true);
1296
1390
  });
1297
1391
  tag.append(label, remove);
1298
1392
  if (this.opts.sortable) {
@@ -1312,7 +1406,7 @@ var ForgeSelect = class {
1312
1406
  };
1313
1407
  const span = document.createElement("span");
1314
1408
  span.className = "forge-select__single-value";
1315
- renderOptionContent(span, option, this.opts.templateSelection, "inline");
1409
+ renderOptionContent(span, option, this.opts.templateSelection, "inline", this.opts.sanitizeTemplate);
1316
1410
  this.valueEl.append(span);
1317
1411
  }
1318
1412
  }
@@ -1421,7 +1515,14 @@ var ForgeSelect = class {
1421
1515
  accentInsensitive: this.opts.accentInsensitive,
1422
1516
  scorer: this.opts.searchScorer
1423
1517
  }) > 0);
1424
- const subtreeMatches = (option) => query === "" || matches(option) || (option.children ?? []).some(subtreeMatches);
1518
+ const subtreeMatchCache = /* @__PURE__ */ new Map();
1519
+ const subtreeMatches = (option) => {
1520
+ const cached = subtreeMatchCache.get(option);
1521
+ if (cached !== void 0) return cached;
1522
+ const result = query === "" || matches(option) || (option.children ?? []).some(subtreeMatches);
1523
+ subtreeMatchCache.set(option, result);
1524
+ return result;
1525
+ };
1425
1526
  const pushOption = (option, depth, parentValue) => {
1426
1527
  let navIndex = -1;
1427
1528
  const interactionDisabled = this.isOptionDisabled(option) || this.hasReachedMaximum() && !this.selected.includes(option.value);
@@ -1476,8 +1577,20 @@ var ForgeSelect = class {
1476
1577
  usesVirtualScroll() {
1477
1578
  return this.opts.virtualScroll !== false && this.rows.length > VIRTUAL_THRESHOLD;
1478
1579
  }
1580
+ /**
1581
+ * Drops every per-row cache at once. Rendered content, recycled <li>
1582
+ * elements, and measured heights are all keyed off the current `data`, so
1583
+ * they must be invalidated together whenever `data` is replaced — clearing
1584
+ * only some of them leaves recycled rows carrying state from the old list.
1585
+ */
1586
+ clearRowCaches() {
1587
+ this.rowContentCache.clear();
1588
+ this.rowElementCache.clear();
1589
+ this.rowHeightCache.clear();
1590
+ this.rowOffsetsCache = null;
1591
+ }
1479
1592
  rowKey(row, index) {
1480
- if (row.kind === "option") return `option:${row.option.value}`;
1593
+ if (row.kind === "option") return `option:${row.option.value}:${index}`;
1481
1594
  if (row.kind === "group") return `group:${row.label}:${index}`;
1482
1595
  return `${row.kind}:${index}`;
1483
1596
  }
@@ -1519,7 +1632,14 @@ var ForgeSelect = class {
1519
1632
  if (virtual) {
1520
1633
  const viewport = clientHeight || rowHeight * 8;
1521
1634
  if (this.opts.variableItemHeight) {
1522
- while (start < this.rows.length && offsets[start + 1] < scrollTop) start += 1;
1635
+ let low = 0;
1636
+ let high = this.rows.length;
1637
+ while (low < high) {
1638
+ const middle = low + high >>> 1;
1639
+ if (offsets[middle + 1] < scrollTop) low = middle + 1;
1640
+ else high = middle;
1641
+ }
1642
+ start = low;
1523
1643
  start = Math.max(0, start - VIRTUAL_BUFFER);
1524
1644
  end = start;
1525
1645
  const target = scrollTop + viewport + VIRTUAL_BUFFER * rowHeight;
@@ -1536,7 +1656,13 @@ var ForgeSelect = class {
1536
1656
  }
1537
1657
  const appended = [];
1538
1658
  for (let i = start; i < end; i++) {
1539
- const element = this.renderRow(this.rows[i]);
1659
+ const key = this.rowKey(this.rows[i], i);
1660
+ const element = this.renderRow(this.rows[i], this.rowElementCache.get(key));
1661
+ this.rowElementCache.set(key, element);
1662
+ if (this.rowElementCache.size > ROW_CACHE_LIMIT) {
1663
+ const oldest = this.rowElementCache.keys().next().value;
1664
+ this.rowElementCache.delete(oldest);
1665
+ }
1540
1666
  this.list.append(element);
1541
1667
  appended.push(element);
1542
1668
  }
@@ -1563,8 +1689,27 @@ var ForgeSelect = class {
1563
1689
  }
1564
1690
  this.updateActiveDescendant();
1565
1691
  }
1566
- renderRow(row) {
1567
- const li = document.createElement("li");
1692
+ renderRow(row, recycled) {
1693
+ const li = recycled ?? document.createElement("li");
1694
+ li.replaceChildren();
1695
+ li.className = "";
1696
+ for (const attribute of [
1697
+ "role",
1698
+ "id",
1699
+ "aria-hidden",
1700
+ "aria-selected",
1701
+ "aria-disabled",
1702
+ "aria-expanded",
1703
+ "aria-level",
1704
+ "data-nav-index",
1705
+ "data-option-value",
1706
+ "data-selection-state",
1707
+ // Tree rows set an inline padding-left indent; clearing the whole
1708
+ // attribute keeps recycling self-contained, so a row reused at a
1709
+ // shallower depth can't inherit the previous row's indent.
1710
+ "style"
1711
+ ])
1712
+ li.removeAttribute(attribute);
1568
1713
  switch (row.kind) {
1569
1714
  case "group":
1570
1715
  li.className = "forge-select__group-label";
@@ -1612,45 +1757,47 @@ var ForgeSelect = class {
1612
1757
  li.textContent = format(this.strings.createOption, { query: this.query.trim() });
1613
1758
  if (row.navIndex === this.highlightedIndex) li.classList.add("forge-select__option--highlighted");
1614
1759
  break;
1615
- case "option": {
1616
- li.className = "forge-select__option";
1617
- li.dataset.optionValue = row.option.value;
1618
- if (row.option.className) li.classList.add(...row.option.className.trim().split(/\s+/).filter(Boolean));
1619
- li.setAttribute("role", "option");
1620
- const isSelected = this.selected.includes(row.option.value);
1621
- li.setAttribute("aria-selected", String(isSelected));
1622
- if (isSelected) li.classList.add("forge-select__option--selected");
1623
- if (this.opts.multiple && row.hasChildren && computeCheckState(row.option, this.selected, this.isOptionDisabled) === "some") {
1624
- li.classList.add("forge-select__option--indeterminate");
1625
- li.dataset.selectionState = "mixed";
1626
- }
1627
- if (row.depth > 0) {
1628
- li.style.paddingLeft = `calc(12px + ${row.depth} * var(--fs-tree-indent, 18px))`;
1629
- }
1630
- if (this.isOptionDisabled(row.option) || this.hasReachedMaximum() && !this.selected.includes(row.option.value)) {
1631
- li.classList.add("forge-select__option--disabled");
1632
- li.setAttribute("aria-disabled", "true");
1633
- } else {
1634
- li.id = `${this.uid}-nav-${row.navIndex}`;
1635
- li.dataset.navIndex = String(row.navIndex);
1636
- if (row.navIndex === this.highlightedIndex) li.classList.add("forge-select__option--highlighted");
1637
- }
1638
- if (row.hasChildren) {
1639
- const expanded = this.query !== "" || this.expandedValues.has(row.option.value);
1640
- li.setAttribute("aria-expanded", String(expanded));
1641
- const twisty = document.createElement("span");
1642
- twisty.className = "forge-select__twisty";
1643
- twisty.dataset.twisty = row.option.value;
1644
- twisty.setAttribute("aria-hidden", "true");
1645
- twisty.textContent = expanded ? "\u25BC" : "\u25B6";
1646
- li.append(twisty);
1647
- }
1648
- li.append(this.optionContent(row.option));
1760
+ case "option":
1761
+ this.renderOptionRow(li, row);
1649
1762
  break;
1650
- }
1651
1763
  }
1652
1764
  return li;
1653
1765
  }
1766
+ renderOptionRow(li, row) {
1767
+ li.className = "forge-select__option";
1768
+ li.dataset.optionValue = row.option.value;
1769
+ if (row.option.className) li.classList.add(...row.option.className.trim().split(/\s+/).filter(Boolean));
1770
+ li.setAttribute("role", "option");
1771
+ const isSelected = this.selected.includes(row.option.value);
1772
+ li.setAttribute("aria-selected", String(isSelected));
1773
+ if (isSelected) li.classList.add("forge-select__option--selected");
1774
+ if (this.opts.multiple && row.hasChildren && computeCheckState(row.option, this.selected, this.isOptionDisabled) === "some") {
1775
+ li.classList.add("forge-select__option--indeterminate");
1776
+ li.dataset.selectionState = "mixed";
1777
+ }
1778
+ if (row.depth > 0) {
1779
+ li.style.paddingLeft = `calc(12px + ${row.depth} * var(--fs-tree-indent, 18px))`;
1780
+ }
1781
+ if (this.isOptionDisabled(row.option) || this.hasReachedMaximum() && !this.selected.includes(row.option.value)) {
1782
+ li.classList.add("forge-select__option--disabled");
1783
+ li.setAttribute("aria-disabled", "true");
1784
+ } else {
1785
+ li.id = `${this.uid}-nav-${row.navIndex}`;
1786
+ li.dataset.navIndex = String(row.navIndex);
1787
+ if (row.navIndex === this.highlightedIndex) li.classList.add("forge-select__option--highlighted");
1788
+ }
1789
+ if (row.hasChildren) {
1790
+ const expanded = this.query !== "" || this.expandedValues.has(row.option.value);
1791
+ li.setAttribute("aria-expanded", String(expanded));
1792
+ const twisty = document.createElement("span");
1793
+ twisty.className = "forge-select__twisty";
1794
+ twisty.dataset.twisty = row.option.value;
1795
+ twisty.setAttribute("aria-hidden", "true");
1796
+ twisty.textContent = expanded ? "\u25BC" : "\u25B6";
1797
+ li.append(twisty);
1798
+ }
1799
+ li.append(this.optionContent(row.option));
1800
+ }
1654
1801
  /**
1655
1802
  * Rendered row content is cached per option value and cloned on each render,
1656
1803
  * so templates run once per option instead of once per scroll frame.
@@ -1684,7 +1831,7 @@ var ForgeSelect = class {
1684
1831
  if (!cached) {
1685
1832
  const holder = document.createElement("span");
1686
1833
  holder.className = "forge-select__option-content";
1687
- renderOptionContent(holder, option, this.opts.templateResult);
1834
+ renderOptionContent(holder, option, this.opts.templateResult, "row", this.opts.sanitizeTemplate);
1688
1835
  if (this.rowContentCache.size >= ROW_CACHE_LIMIT) {
1689
1836
  const oldest = this.rowContentCache.keys().next().value;
1690
1837
  this.rowContentCache.delete(oldest);
@@ -1773,6 +1920,7 @@ var ForgeSelect = class {
1773
1920
  this.ajaxController = null;
1774
1921
  this.page = 0;
1775
1922
  this.hasMore = true;
1923
+ this.nextCursor = void 0;
1776
1924
  this.setLoading(true);
1777
1925
  this.loadingMore = false;
1778
1926
  this.loadError = null;
@@ -1787,17 +1935,27 @@ var ForgeSelect = class {
1787
1935
  this.loading = loading;
1788
1936
  this.emitter.emit("loading", loading);
1789
1937
  }
1790
- remoteCacheKey(query, page) {
1791
- return `${query}\0${page}`;
1938
+ remoteCacheKey(query, page, cursor) {
1939
+ return `${query}\0${cursor ?? page}`;
1792
1940
  }
1793
- async requestRemote(query, page, signal) {
1941
+ fetchRemoteResult(query, page, signal, cursor) {
1942
+ const key = this.remoteCacheKey(query, page, cursor);
1943
+ const pending = this.remoteInFlight.get(key);
1944
+ if (pending) return pending;
1945
+ const ajax = this.opts.ajax;
1946
+ const request = this.requestRemote(query, page, signal, cursor).then((json) => normalizeRemoteResult(ajax, json)).finally(() => this.remoteInFlight.delete(key));
1947
+ this.remoteInFlight.set(key, request);
1948
+ return request;
1949
+ }
1950
+ async requestRemote(query, page, signal, cursor) {
1794
1951
  const ajax = this.opts.ajax;
1795
1952
  const attempts = Math.max(0, Math.floor(ajax.retry ?? 0)) + 1;
1796
1953
  let lastError;
1797
1954
  for (let attempt = 0; attempt < attempts; attempt += 1) {
1798
1955
  try {
1799
- if (ajax.request) return await ajax.request(query, page, signal);
1800
- const response = await fetch(buildUrl(ajax, query, page), { signal });
1956
+ if (ajax.request)
1957
+ return cursor === void 0 ? await ajax.request(query, page, signal) : await ajax.request(query, page, signal, cursor);
1958
+ const response = await fetch(buildUrl(ajax, query, page, cursor), { signal });
1801
1959
  if (response.ok === false) throw new Error(`ForgeSelect: remote request failed with HTTP ${response.status}`);
1802
1960
  return await response.json();
1803
1961
  } catch (error) {
@@ -1825,10 +1983,13 @@ var ForgeSelect = class {
1825
1983
  const key = this.remoteCacheKey(query, 0);
1826
1984
  if (this.remoteCache.get(key)) return;
1827
1985
  const controller = new AbortController();
1986
+ this.prefetchControllers.add(controller);
1828
1987
  try {
1829
- const json = await this.requestRemote(query, 0, controller.signal);
1830
- this.remoteCache.set(key, normalizeRemoteResult(ajax, json), ajax.cacheTtl ?? 3e4);
1988
+ const result = await this.fetchRemoteResult(query, 0, controller.signal);
1989
+ this.remoteCache.set(key, result, ajax.cacheTtl ?? 3e4);
1831
1990
  } catch {
1991
+ } finally {
1992
+ this.prefetchControllers.delete(controller);
1832
1993
  }
1833
1994
  }
1834
1995
  /**
@@ -1854,12 +2015,12 @@ var ForgeSelect = class {
1854
2015
  const controller = new AbortController();
1855
2016
  this.ajaxController = controller;
1856
2017
  const page = append ? this.page + 1 : 0;
2018
+ const cursor = append ? this.nextCursor : void 0;
1857
2019
  try {
1858
- const key = this.remoteCacheKey(query, page);
2020
+ const key = this.remoteCacheKey(query, page, cursor);
1859
2021
  let result = this.remoteCache.get(key);
1860
2022
  if (!result) {
1861
- const json = await this.requestRemote(query, page, controller.signal);
1862
- result = normalizeRemoteResult(ajax, json);
2023
+ result = await this.fetchRemoteResult(query, page, controller.signal, cursor);
1863
2024
  this.remoteCache.set(key, result, ajax.cacheTtl ?? 3e4);
1864
2025
  }
1865
2026
  if (activeRequestId !== this.ajaxRequestId || this.destroyed) return;
@@ -1869,20 +2030,20 @@ var ForgeSelect = class {
1869
2030
  this.data = [...this.data, ...options.filter((o) => !existing.has(o.value))];
1870
2031
  } else {
1871
2032
  this.data = options;
1872
- this.rowContentCache.clear();
1873
- this.rowHeightCache.clear();
2033
+ this.clearRowCaches();
1874
2034
  }
1875
2035
  this.page = page;
1876
2036
  this.hasMore = hasMore;
2037
+ this.nextCursor = result.nextCursor;
1877
2038
  this.remoteLoaded = true;
1878
2039
  this.loadError = null;
2040
+ this.rebuildOptionIndexes();
1879
2041
  } catch (cause) {
1880
2042
  if (activeRequestId !== this.ajaxRequestId || this.destroyed || controller.signal.aborted) return;
1881
2043
  const error = cause instanceof Error ? cause : new Error(String(cause));
1882
2044
  if (!append) {
1883
2045
  this.data = [];
1884
- this.rowContentCache.clear();
1885
- this.rowHeightCache.clear();
2046
+ this.clearRowCaches();
1886
2047
  }
1887
2048
  this.hasMore = false;
1888
2049
  this.loadError = error;