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.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
- if (typeof ajax.url === "function") return ajax.url(query, page);
141
+ if (typeof ajax.url === "function") return ajax.url(query, page, cursor);
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,7 +153,12 @@ 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
@@ -267,21 +272,6 @@ function computeCheckState(option, selected, isDisabled = defaultIsDisabled) {
267
272
  if (states.every((state) => state === "none")) return "none";
268
273
  return "some";
269
274
  }
270
- function findOption(items, value) {
271
- const search = (options) => {
272
- for (const option of options) {
273
- if (option.value === value) return option;
274
- const found = option.children ? search(option.children) : void 0;
275
- if (found) return found;
276
- }
277
- return void 0;
278
- };
279
- for (const item of items) {
280
- const found = search(isGroup(item) ? item.options : [item]);
281
- if (found) return found;
282
- }
283
- return void 0;
284
- }
285
275
  function syncTreeAncestors(items, selected, isDisabled = defaultIsDisabled) {
286
276
  const sync = (option) => {
287
277
  if (!option.children?.length) return;
@@ -316,6 +306,8 @@ var TYPEAHEAD_RESET_MS = 500;
316
306
  var uidCounter = 0;
317
307
  var ForgeSelect = class {
318
308
  constructor(target, options = {}) {
309
+ this.optionByValue = /* @__PURE__ */ new Map();
310
+ this.optionByLabel = /* @__PURE__ */ new Map();
319
311
  this.selected = [];
320
312
  this.selectedOptions = /* @__PURE__ */ new Map();
321
313
  this.suppressNextTagClick = false;
@@ -333,6 +325,7 @@ var ForgeSelect = class {
333
325
  this.typeaheadBuffer = "";
334
326
  this.typeaheadTimer = null;
335
327
  this.rowContentCache = /* @__PURE__ */ new Map();
328
+ this.rowElementCache = /* @__PURE__ */ new Map();
336
329
  this.rowHeightCache = /* @__PURE__ */ new Map();
337
330
  this.rowOffsetsCache = null;
338
331
  this.scrollRafId = null;
@@ -348,6 +341,8 @@ var ForgeSelect = class {
348
341
  this.ajaxController = null;
349
342
  this.remoteLoaded = false;
350
343
  this.remoteCache = new RemoteCache();
344
+ this.remoteInFlight = /* @__PURE__ */ new Map();
345
+ this.prefetchControllers = /* @__PURE__ */ new Set();
351
346
  this.loadError = null;
352
347
  this.originalDisplay = "";
353
348
  this.originalDisabled = false;
@@ -416,6 +411,13 @@ var ForgeSelect = class {
416
411
  ajax: options.ajax,
417
412
  templateResult: options.templateResult,
418
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",
419
421
  filterOption: options.filterOption,
420
422
  searchFields: options.searchFields ?? ["label", "description"],
421
423
  tokenSearch: options.tokenSearch ?? true,
@@ -437,6 +439,7 @@ var ForgeSelect = class {
437
439
  this.plugins = this.opts.plugins;
438
440
  if (nativeSelect) nativeSelect.required = this.opts.required;
439
441
  this.data = this.opts.data ?? (nativeSelect ? parseNativeOptions(nativeSelect) : []);
442
+ this.rebuildOptionIndexes();
440
443
  if (nativeSelect && !this.opts.data) {
441
444
  const nativeOptions = Array.from(nativeSelect.options);
442
445
  const hasIntentionalSelection = nativeSelect.multiple || nativeSelect.selectedIndex > 0 || nativeOptions.some((option) => option.defaultSelected);
@@ -474,6 +477,8 @@ var ForgeSelect = class {
474
477
  this.renderList();
475
478
  this.positionDropdown();
476
479
  window.addEventListener("resize", this.onWindowResize);
480
+ window.visualViewport?.addEventListener("resize", this.onWindowResize);
481
+ window.visualViewport?.addEventListener("scroll", this.onWindowResize);
477
482
  document.addEventListener("scroll", this.onAncestorScroll, true);
478
483
  if (this.searchInput && !this.searchInput.hidden) this.searchInput.focus();
479
484
  this.emitter.emit("open");
@@ -488,6 +493,8 @@ var ForgeSelect = class {
488
493
  this.control.setAttribute("aria-expanded", "false");
489
494
  document.removeEventListener("mousedown", this.onDocumentMouseDown);
490
495
  window.removeEventListener("resize", this.onWindowResize);
496
+ window.visualViewport?.removeEventListener("resize", this.onWindowResize);
497
+ window.visualViewport?.removeEventListener("scroll", this.onWindowResize);
491
498
  document.removeEventListener("scroll", this.onAncestorScroll, true);
492
499
  if (this.ancestorScrollRafId != null) {
493
500
  cancelAnimationFrame(this.ancestorScrollRafId);
@@ -519,11 +526,16 @@ var ForgeSelect = class {
519
526
  */
520
527
  positionDropdown() {
521
528
  const controlRect = this.control.getBoundingClientRect();
522
- 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
+ );
523
535
  this.root.classList.toggle("forge-select--drop-up", placement.dropUp);
524
536
  if (this.portalHost) {
525
- this.portalHost.style.top = `${placement.top}px`;
526
- 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`;
527
539
  this.portalHost.style.width = `${controlRect.width}px`;
528
540
  }
529
541
  }
@@ -534,13 +546,15 @@ var ForgeSelect = class {
534
546
  this.destroyed = true;
535
547
  if (this.ajaxTimer) clearTimeout(this.ajaxTimer);
536
548
  this.ajaxController?.abort();
549
+ for (const controller of this.prefetchControllers) controller.abort();
550
+ this.prefetchControllers.clear();
551
+ this.remoteInFlight.clear();
537
552
  if (this.scrollRafId != null) cancelAnimationFrame(this.scrollRafId);
538
553
  if (this.typeaheadTimer) clearTimeout(this.typeaheadTimer);
539
554
  this.nativeSelect?.removeEventListener("change", this.onNativeChange);
540
555
  this.nativeSelect?.removeEventListener("invalid", this.onNativeInvalid);
541
556
  this.nativeForm?.removeEventListener("reset", this.onFormReset);
542
- this.rowContentCache.clear();
543
- this.rowHeightCache.clear();
557
+ this.clearRowCaches();
544
558
  this.searchIndex.clear();
545
559
  this.portalHost?.remove();
546
560
  this.root.remove();
@@ -588,10 +602,23 @@ var ForgeSelect = class {
588
602
  }
589
603
  if (options.templateResult !== void 0) this.opts.templateResult = options.templateResult;
590
604
  if (options.templateSelection !== void 0) this.opts.templateSelection = options.templateSelection;
605
+ if (options.sanitizeTemplate !== void 0) this.opts.sanitizeTemplate = options.sanitizeTemplate;
606
+ if (options.beforeSelect !== void 0) this.opts.beforeSelect = options.beforeSelect;
607
+ if (options.beforeUnselect !== void 0) this.opts.beforeUnselect = options.beforeUnselect;
608
+ if (options.beforeCreate !== void 0) this.opts.beforeCreate = options.beforeCreate;
609
+ if (options.createOption !== void 0) this.opts.createOption = options.createOption;
610
+ if (options.missingSelectionPolicy !== void 0) this.opts.missingSelectionPolicy = options.missingSelectionPolicy;
611
+ if (options.duplicateValuePolicy !== void 0) {
612
+ this.opts.duplicateValuePolicy = options.duplicateValuePolicy;
613
+ this.rebuildOptionIndexes();
614
+ }
591
615
  if (options.filterOption !== void 0) this.opts.filterOption = options.filterOption;
592
616
  if (options.searchFields !== void 0) this.opts.searchFields = options.searchFields;
593
617
  if (options.tokenSearch !== void 0) this.opts.tokenSearch = options.tokenSearch;
594
- if (options.accentInsensitive !== void 0) this.opts.accentInsensitive = options.accentInsensitive;
618
+ if (options.accentInsensitive !== void 0) {
619
+ this.opts.accentInsensitive = options.accentInsensitive;
620
+ this.rebuildOptionIndexes();
621
+ }
595
622
  if (options.searchScorer !== void 0) this.opts.searchScorer = options.searchScorer;
596
623
  if (options.highlightSearch !== void 0) this.opts.highlightSearch = options.highlightSearch;
597
624
  if (options.minSearchLength !== void 0)
@@ -619,8 +646,7 @@ var ForgeSelect = class {
619
646
  }
620
647
  this.root.classList.toggle("forge-select--sortable", this.opts.sortable && this.opts.multiple);
621
648
  this.updateSearchVisibility();
622
- this.rowContentCache.clear();
623
- this.rowHeightCache.clear();
649
+ this.clearRowCaches();
624
650
  this.searchIndex.clear();
625
651
  this.renderValue();
626
652
  if (this.isOpen) this.renderList();
@@ -652,6 +678,7 @@ var ForgeSelect = class {
652
678
  }
653
679
  clearRemoteCache() {
654
680
  this.remoteCache.clear();
681
+ this.remoteInFlight.clear();
655
682
  }
656
683
  setValue(value, options = {}) {
657
684
  const values = value == null ? [] : Array.isArray(value) ? value : [value];
@@ -681,11 +708,29 @@ var ForgeSelect = class {
681
708
  this.remoteLoaded = true;
682
709
  this.page = 0;
683
710
  this.hasMore = false;
711
+ this.nextCursor = void 0;
712
+ const previousData = this.data;
684
713
  this.data = data;
714
+ try {
715
+ this.rebuildOptionIndexes();
716
+ } catch (error) {
717
+ this.data = previousData;
718
+ this.rebuildOptionIndexes();
719
+ throw error;
720
+ }
721
+ const missing = this.selected.filter((value) => !this.optionByValue.has(value));
722
+ if (missing.length > 0 && this.opts.missingSelectionPolicy === "error") {
723
+ this.data = previousData;
724
+ this.rebuildOptionIndexes();
725
+ throw new Error(`ForgeSelect: setData() is missing selected value(s): ${missing.join(", ")}`);
726
+ }
685
727
  this.opts.data = data;
728
+ if (missing.length > 0 && this.opts.missingSelectionPolicy === "prune") {
729
+ this.selected = this.selected.filter((value) => this.optionByValue.has(value));
730
+ this.afterSelectionChange();
731
+ }
686
732
  this.updateSearchVisibility();
687
- this.rowContentCache.clear();
688
- this.rowHeightCache.clear();
733
+ this.clearRowCaches();
689
734
  this.searchIndex.clear();
690
735
  this.highlightedIndex = -1;
691
736
  if (this.isOpen) this.renderList();
@@ -826,6 +871,7 @@ var ForgeSelect = class {
826
871
  if (portalParent) {
827
872
  this.portalHost = document.createElement("div");
828
873
  this.portalHost.className = "forge-select forge-select--portal-host";
874
+ this.portalHost.style.direction = getComputedStyle(this.root).direction;
829
875
  this.portalHost.dataset.theme = this.opts.theme;
830
876
  this.portalHost.style.setProperty("--fs-item-height", `${this.opts.itemHeight}px`);
831
877
  this.portalHost.append(this.dropdown);
@@ -856,10 +902,24 @@ var ForgeSelect = class {
856
902
  });
857
903
  this.clearBtn.addEventListener("click", (event) => {
858
904
  event.stopPropagation();
905
+ if (this.selected.some((value) => {
906
+ const option = this.findOption(value) ?? this.selectedOptions.get(value) ?? { value, label: value };
907
+ return this.opts.beforeUnselect?.(option) === false;
908
+ }))
909
+ return;
859
910
  this.clearSelection();
860
911
  });
861
912
  if (this.searchInput) {
913
+ let composing = false;
914
+ this.searchInput.addEventListener("compositionstart", () => {
915
+ composing = true;
916
+ });
917
+ this.searchInput.addEventListener("compositionend", () => {
918
+ composing = false;
919
+ this.applySearchQuery(this.searchInput.value, true);
920
+ });
862
921
  this.searchInput.addEventListener("input", () => {
922
+ if (composing) return;
863
923
  this.applySearchQuery(this.searchInput.value, true);
864
924
  });
865
925
  this.searchInput.addEventListener("keydown", (event) => this.handleKeydown(event));
@@ -869,21 +929,22 @@ var ForgeSelect = class {
869
929
  const labels = text.split(/[,\n]+/).map((s) => s.trim()).filter(Boolean);
870
930
  if (labels.length < 2) return;
871
931
  event.preventDefault();
872
- const created = [];
873
- for (const label of labels) {
874
- const result = this.createTag(label);
875
- if (result) created.push(result);
876
- }
877
- if (created.length === 0) return;
878
- this.searchInput.value = "";
879
- this.query = "";
880
- this.afterSelectionChange();
881
- for (const result of created) {
882
- if (result.created) this.emitter.emit("create", result.option);
883
- this.emitter.emit("select", result.option);
884
- }
885
- if (this.opts.closeOnSelect) this.close();
886
- else this.renderList();
932
+ void Promise.all(labels.map((label) => this.createTag(label))).then((results) => {
933
+ const created = results.filter((result) => result !== void 0);
934
+ if (created.length === 0 || this.destroyed) return;
935
+ this.searchInput.value = "";
936
+ this.query = "";
937
+ this.afterSelectionChange();
938
+ for (const result of created) {
939
+ if (result.created) this.emitter.emit("create", result.option);
940
+ this.emitter.emit("select", result.option);
941
+ }
942
+ if (this.opts.closeOnSelect) this.close();
943
+ else this.renderList();
944
+ }).catch((cause) => {
945
+ const error = cause instanceof Error ? cause : new Error(String(cause));
946
+ this.emitter.emit("error", error);
947
+ });
887
948
  });
888
949
  }
889
950
  this.list.addEventListener("click", (event) => {
@@ -938,7 +999,7 @@ var ForgeSelect = class {
938
999
  this.renderList();
939
1000
  }
940
1001
  handleKeydown(event) {
941
- if (this.isDisabled) return;
1002
+ if (this.isDisabled || event.isComposing || event.keyCode === 229) return;
942
1003
  switch (event.key) {
943
1004
  case "Enter":
944
1005
  event.preventDefault();
@@ -1156,23 +1217,27 @@ var ForgeSelect = class {
1156
1217
  }
1157
1218
  }
1158
1219
  findOption(value) {
1159
- return findOption(this.data, value);
1220
+ return this.optionByValue.get(value);
1160
1221
  }
1161
1222
  findOptionByLabel(label) {
1162
- const lower = label.toLowerCase();
1163
- const search = (options) => {
1164
- for (const option of options) {
1165
- if (option.label.toLowerCase() === lower) return option;
1166
- const found = option.children ? search(option.children) : void 0;
1167
- if (found) return found;
1168
- }
1169
- return void 0;
1223
+ return this.optionByLabel.get(normalizeSearchText(label, this.opts.accentInsensitive));
1224
+ }
1225
+ rebuildOptionIndexes() {
1226
+ this.optionByValue.clear();
1227
+ this.optionByLabel.clear();
1228
+ const duplicates = /* @__PURE__ */ new Set();
1229
+ const visit = (option) => {
1230
+ if (this.optionByValue.has(option.value)) duplicates.add(option.value);
1231
+ else this.optionByValue.set(option.value, option);
1232
+ const label = normalizeSearchText(option.label, this.opts.accentInsensitive);
1233
+ if (!this.optionByLabel.has(label)) this.optionByLabel.set(label, option);
1234
+ option.children?.forEach(visit);
1170
1235
  };
1171
- for (const item of this.data) {
1172
- const found = search(isGroup(item) ? item.options : [item]);
1173
- if (found) return found;
1174
- }
1175
- return void 0;
1236
+ for (const item of this.data) (isGroup(item) ? item.options : [item]).forEach(visit);
1237
+ if (duplicates.size === 0 || this.opts.duplicateValuePolicy === "ignore") return;
1238
+ const message = `ForgeSelect: duplicate option value(s): ${[...duplicates].join(", ")}`;
1239
+ if (this.opts.duplicateValuePolicy === "error") throw new Error(message);
1240
+ console.warn(message);
1176
1241
  }
1177
1242
  /** Selects an existing option matching `label` exactly, or creates and selects a new one. */
1178
1243
  createTag(label) {
@@ -1185,15 +1250,31 @@ var ForgeSelect = class {
1185
1250
  this.announceMaximum(existing);
1186
1251
  return void 0;
1187
1252
  }
1253
+ if (this.opts.beforeSelect?.(existing) === false) return void 0;
1188
1254
  this.selectValue(existing.value, false);
1189
1255
  return { option: existing, created: false };
1190
1256
  }
1191
- const option = { value: trimmed, label: trimmed };
1257
+ if (this.opts.beforeCreate?.(trimmed) === false) return void 0;
1258
+ const created = this.opts.createOption?.(trimmed) ?? { value: trimmed, label: trimmed };
1259
+ if (created instanceof Promise) {
1260
+ return created.then((option) => option ? this.addCreatedOption(option) : void 0);
1261
+ }
1262
+ return created ? this.addCreatedOption(created) : void 0;
1263
+ }
1264
+ addCreatedOption(option) {
1192
1265
  if (this.opts.multiple && !this.canSelectOption(option)) {
1193
1266
  this.announceMaximum(option);
1194
1267
  return void 0;
1195
1268
  }
1269
+ const duplicate = this.findOption(option.value);
1270
+ if (duplicate) {
1271
+ if (this.selected.includes(duplicate.value)) return void 0;
1272
+ if (this.opts.beforeSelect?.(duplicate) === false) return void 0;
1273
+ this.selectValue(duplicate.value, false);
1274
+ return { option: duplicate, created: false };
1275
+ }
1196
1276
  this.data.push(option);
1277
+ this.rebuildOptionIndexes();
1197
1278
  this.selectValue(option.value, false);
1198
1279
  return { option, created: true };
1199
1280
  }
@@ -1201,8 +1282,18 @@ var ForgeSelect = class {
1201
1282
  const label = this.query.trim();
1202
1283
  if (!label) return;
1203
1284
  const result = this.createTag(label);
1204
- if (!result) return;
1205
- if (this.searchInput) {
1285
+ if (result instanceof Promise) {
1286
+ void result.then((created) => this.finishCreateFromQuery(created, label)).catch((cause) => {
1287
+ const error = cause instanceof Error ? cause : new Error(String(cause));
1288
+ this.emitter.emit("error", error);
1289
+ });
1290
+ return;
1291
+ }
1292
+ this.finishCreateFromQuery(result, label);
1293
+ }
1294
+ finishCreateFromQuery(result, sourceLabel) {
1295
+ if (!result || this.destroyed) return;
1296
+ if (this.searchInput && this.query.trim() === sourceLabel) {
1206
1297
  this.searchInput.value = "";
1207
1298
  this.query = "";
1208
1299
  }
@@ -1223,9 +1314,11 @@ var ForgeSelect = class {
1223
1314
  if (this.opts.multiple) {
1224
1315
  let changed = false;
1225
1316
  if (this.selected.includes(value)) {
1317
+ if (this.opts.beforeUnselect?.(item.option) === false) return;
1226
1318
  this.deselectValue(value, true);
1227
1319
  changed = true;
1228
1320
  } else if (this.canSelectOption(item.option)) {
1321
+ if (this.opts.beforeSelect?.(item.option) === false) return;
1229
1322
  this.selectValue(value, true);
1230
1323
  changed = true;
1231
1324
  } else {
@@ -1233,6 +1326,7 @@ var ForgeSelect = class {
1233
1326
  }
1234
1327
  if (changed && this.opts.closeOnSelect) this.close();
1235
1328
  } else {
1329
+ if (this.opts.beforeSelect?.(item.option) === false) return;
1236
1330
  this.selectValue(value, true);
1237
1331
  this.close();
1238
1332
  this.control.focus();
@@ -1257,7 +1351,7 @@ var ForgeSelect = class {
1257
1351
  tag.className = "forge-select__tag";
1258
1352
  const label = document.createElement("span");
1259
1353
  label.className = "forge-select__tag-label";
1260
- renderOptionContent(label, option, this.opts.templateSelection, "inline");
1354
+ renderOptionContent(label, option, this.opts.templateSelection, "inline", this.opts.sanitizeTemplate);
1261
1355
  const remove = document.createElement("button");
1262
1356
  remove.type = "button";
1263
1357
  remove.className = "forge-select__tag-remove";
@@ -1265,7 +1359,7 @@ var ForgeSelect = class {
1265
1359
  remove.textContent = "\xD7";
1266
1360
  remove.addEventListener("click", (event) => {
1267
1361
  event.stopPropagation();
1268
- if (!this.isDisabled) this.deselectValue(value, true);
1362
+ if (!this.isDisabled && this.opts.beforeUnselect?.(option) !== false) this.deselectValue(value, true);
1269
1363
  });
1270
1364
  tag.append(label, remove);
1271
1365
  if (this.opts.sortable) {
@@ -1285,7 +1379,7 @@ var ForgeSelect = class {
1285
1379
  };
1286
1380
  const span = document.createElement("span");
1287
1381
  span.className = "forge-select__single-value";
1288
- renderOptionContent(span, option, this.opts.templateSelection, "inline");
1382
+ renderOptionContent(span, option, this.opts.templateSelection, "inline", this.opts.sanitizeTemplate);
1289
1383
  this.valueEl.append(span);
1290
1384
  }
1291
1385
  }
@@ -1394,7 +1488,14 @@ var ForgeSelect = class {
1394
1488
  accentInsensitive: this.opts.accentInsensitive,
1395
1489
  scorer: this.opts.searchScorer
1396
1490
  }) > 0);
1397
- const subtreeMatches = (option) => query === "" || matches(option) || (option.children ?? []).some(subtreeMatches);
1491
+ const subtreeMatchCache = /* @__PURE__ */ new Map();
1492
+ const subtreeMatches = (option) => {
1493
+ const cached = subtreeMatchCache.get(option);
1494
+ if (cached !== void 0) return cached;
1495
+ const result = query === "" || matches(option) || (option.children ?? []).some(subtreeMatches);
1496
+ subtreeMatchCache.set(option, result);
1497
+ return result;
1498
+ };
1398
1499
  const pushOption = (option, depth, parentValue) => {
1399
1500
  let navIndex = -1;
1400
1501
  const interactionDisabled = this.isOptionDisabled(option) || this.hasReachedMaximum() && !this.selected.includes(option.value);
@@ -1449,8 +1550,20 @@ var ForgeSelect = class {
1449
1550
  usesVirtualScroll() {
1450
1551
  return this.opts.virtualScroll !== false && this.rows.length > VIRTUAL_THRESHOLD;
1451
1552
  }
1553
+ /**
1554
+ * Drops every per-row cache at once. Rendered content, recycled <li>
1555
+ * elements, and measured heights are all keyed off the current `data`, so
1556
+ * they must be invalidated together whenever `data` is replaced — clearing
1557
+ * only some of them leaves recycled rows carrying state from the old list.
1558
+ */
1559
+ clearRowCaches() {
1560
+ this.rowContentCache.clear();
1561
+ this.rowElementCache.clear();
1562
+ this.rowHeightCache.clear();
1563
+ this.rowOffsetsCache = null;
1564
+ }
1452
1565
  rowKey(row, index) {
1453
- if (row.kind === "option") return `option:${row.option.value}`;
1566
+ if (row.kind === "option") return `option:${row.option.value}:${index}`;
1454
1567
  if (row.kind === "group") return `group:${row.label}:${index}`;
1455
1568
  return `${row.kind}:${index}`;
1456
1569
  }
@@ -1492,7 +1605,14 @@ var ForgeSelect = class {
1492
1605
  if (virtual) {
1493
1606
  const viewport = clientHeight || rowHeight * 8;
1494
1607
  if (this.opts.variableItemHeight) {
1495
- while (start < this.rows.length && offsets[start + 1] < scrollTop) start += 1;
1608
+ let low = 0;
1609
+ let high = this.rows.length;
1610
+ while (low < high) {
1611
+ const middle = low + high >>> 1;
1612
+ if (offsets[middle + 1] < scrollTop) low = middle + 1;
1613
+ else high = middle;
1614
+ }
1615
+ start = low;
1496
1616
  start = Math.max(0, start - VIRTUAL_BUFFER);
1497
1617
  end = start;
1498
1618
  const target = scrollTop + viewport + VIRTUAL_BUFFER * rowHeight;
@@ -1509,7 +1629,13 @@ var ForgeSelect = class {
1509
1629
  }
1510
1630
  const appended = [];
1511
1631
  for (let i = start; i < end; i++) {
1512
- const element = this.renderRow(this.rows[i]);
1632
+ const key = this.rowKey(this.rows[i], i);
1633
+ const element = this.renderRow(this.rows[i], this.rowElementCache.get(key));
1634
+ this.rowElementCache.set(key, element);
1635
+ if (this.rowElementCache.size > ROW_CACHE_LIMIT) {
1636
+ const oldest = this.rowElementCache.keys().next().value;
1637
+ this.rowElementCache.delete(oldest);
1638
+ }
1513
1639
  this.list.append(element);
1514
1640
  appended.push(element);
1515
1641
  }
@@ -1536,8 +1662,27 @@ var ForgeSelect = class {
1536
1662
  }
1537
1663
  this.updateActiveDescendant();
1538
1664
  }
1539
- renderRow(row) {
1540
- const li = document.createElement("li");
1665
+ renderRow(row, recycled) {
1666
+ const li = recycled ?? document.createElement("li");
1667
+ li.replaceChildren();
1668
+ li.className = "";
1669
+ for (const attribute of [
1670
+ "role",
1671
+ "id",
1672
+ "aria-hidden",
1673
+ "aria-selected",
1674
+ "aria-disabled",
1675
+ "aria-expanded",
1676
+ "aria-level",
1677
+ "data-nav-index",
1678
+ "data-option-value",
1679
+ "data-selection-state",
1680
+ // Tree rows set an inline padding-left indent; clearing the whole
1681
+ // attribute keeps recycling self-contained, so a row reused at a
1682
+ // shallower depth can't inherit the previous row's indent.
1683
+ "style"
1684
+ ])
1685
+ li.removeAttribute(attribute);
1541
1686
  switch (row.kind) {
1542
1687
  case "group":
1543
1688
  li.className = "forge-select__group-label";
@@ -1585,45 +1730,47 @@ var ForgeSelect = class {
1585
1730
  li.textContent = format(this.strings.createOption, { query: this.query.trim() });
1586
1731
  if (row.navIndex === this.highlightedIndex) li.classList.add("forge-select__option--highlighted");
1587
1732
  break;
1588
- case "option": {
1589
- li.className = "forge-select__option";
1590
- li.dataset.optionValue = row.option.value;
1591
- if (row.option.className) li.classList.add(...row.option.className.trim().split(/\s+/).filter(Boolean));
1592
- li.setAttribute("role", "option");
1593
- const isSelected = this.selected.includes(row.option.value);
1594
- li.setAttribute("aria-selected", String(isSelected));
1595
- if (isSelected) li.classList.add("forge-select__option--selected");
1596
- if (this.opts.multiple && row.hasChildren && computeCheckState(row.option, this.selected, this.isOptionDisabled) === "some") {
1597
- li.classList.add("forge-select__option--indeterminate");
1598
- li.dataset.selectionState = "mixed";
1599
- }
1600
- if (row.depth > 0) {
1601
- li.style.paddingLeft = `calc(12px + ${row.depth} * var(--fs-tree-indent, 18px))`;
1602
- }
1603
- if (this.isOptionDisabled(row.option) || this.hasReachedMaximum() && !this.selected.includes(row.option.value)) {
1604
- li.classList.add("forge-select__option--disabled");
1605
- li.setAttribute("aria-disabled", "true");
1606
- } else {
1607
- li.id = `${this.uid}-nav-${row.navIndex}`;
1608
- li.dataset.navIndex = String(row.navIndex);
1609
- if (row.navIndex === this.highlightedIndex) li.classList.add("forge-select__option--highlighted");
1610
- }
1611
- if (row.hasChildren) {
1612
- const expanded = this.query !== "" || this.expandedValues.has(row.option.value);
1613
- li.setAttribute("aria-expanded", String(expanded));
1614
- const twisty = document.createElement("span");
1615
- twisty.className = "forge-select__twisty";
1616
- twisty.dataset.twisty = row.option.value;
1617
- twisty.setAttribute("aria-hidden", "true");
1618
- twisty.textContent = expanded ? "\u25BC" : "\u25B6";
1619
- li.append(twisty);
1620
- }
1621
- li.append(this.optionContent(row.option));
1733
+ case "option":
1734
+ this.renderOptionRow(li, row);
1622
1735
  break;
1623
- }
1624
1736
  }
1625
1737
  return li;
1626
1738
  }
1739
+ renderOptionRow(li, row) {
1740
+ li.className = "forge-select__option";
1741
+ li.dataset.optionValue = row.option.value;
1742
+ if (row.option.className) li.classList.add(...row.option.className.trim().split(/\s+/).filter(Boolean));
1743
+ li.setAttribute("role", "option");
1744
+ const isSelected = this.selected.includes(row.option.value);
1745
+ li.setAttribute("aria-selected", String(isSelected));
1746
+ if (isSelected) li.classList.add("forge-select__option--selected");
1747
+ if (this.opts.multiple && row.hasChildren && computeCheckState(row.option, this.selected, this.isOptionDisabled) === "some") {
1748
+ li.classList.add("forge-select__option--indeterminate");
1749
+ li.dataset.selectionState = "mixed";
1750
+ }
1751
+ if (row.depth > 0) {
1752
+ li.style.paddingLeft = `calc(12px + ${row.depth} * var(--fs-tree-indent, 18px))`;
1753
+ }
1754
+ if (this.isOptionDisabled(row.option) || this.hasReachedMaximum() && !this.selected.includes(row.option.value)) {
1755
+ li.classList.add("forge-select__option--disabled");
1756
+ li.setAttribute("aria-disabled", "true");
1757
+ } else {
1758
+ li.id = `${this.uid}-nav-${row.navIndex}`;
1759
+ li.dataset.navIndex = String(row.navIndex);
1760
+ if (row.navIndex === this.highlightedIndex) li.classList.add("forge-select__option--highlighted");
1761
+ }
1762
+ if (row.hasChildren) {
1763
+ const expanded = this.query !== "" || this.expandedValues.has(row.option.value);
1764
+ li.setAttribute("aria-expanded", String(expanded));
1765
+ const twisty = document.createElement("span");
1766
+ twisty.className = "forge-select__twisty";
1767
+ twisty.dataset.twisty = row.option.value;
1768
+ twisty.setAttribute("aria-hidden", "true");
1769
+ twisty.textContent = expanded ? "\u25BC" : "\u25B6";
1770
+ li.append(twisty);
1771
+ }
1772
+ li.append(this.optionContent(row.option));
1773
+ }
1627
1774
  /**
1628
1775
  * Rendered row content is cached per option value and cloned on each render,
1629
1776
  * so templates run once per option instead of once per scroll frame.
@@ -1657,7 +1804,7 @@ var ForgeSelect = class {
1657
1804
  if (!cached) {
1658
1805
  const holder = document.createElement("span");
1659
1806
  holder.className = "forge-select__option-content";
1660
- renderOptionContent(holder, option, this.opts.templateResult);
1807
+ renderOptionContent(holder, option, this.opts.templateResult, "row", this.opts.sanitizeTemplate);
1661
1808
  if (this.rowContentCache.size >= ROW_CACHE_LIMIT) {
1662
1809
  const oldest = this.rowContentCache.keys().next().value;
1663
1810
  this.rowContentCache.delete(oldest);
@@ -1746,6 +1893,7 @@ var ForgeSelect = class {
1746
1893
  this.ajaxController = null;
1747
1894
  this.page = 0;
1748
1895
  this.hasMore = true;
1896
+ this.nextCursor = void 0;
1749
1897
  this.setLoading(true);
1750
1898
  this.loadingMore = false;
1751
1899
  this.loadError = null;
@@ -1760,17 +1908,27 @@ var ForgeSelect = class {
1760
1908
  this.loading = loading;
1761
1909
  this.emitter.emit("loading", loading);
1762
1910
  }
1763
- remoteCacheKey(query, page) {
1764
- return `${query}\0${page}`;
1911
+ remoteCacheKey(query, page, cursor) {
1912
+ return `${query}\0${cursor ?? page}`;
1765
1913
  }
1766
- async requestRemote(query, page, signal) {
1914
+ fetchRemoteResult(query, page, signal, cursor) {
1915
+ const key = this.remoteCacheKey(query, page, cursor);
1916
+ const pending = this.remoteInFlight.get(key);
1917
+ if (pending) return pending;
1918
+ const ajax = this.opts.ajax;
1919
+ const request = this.requestRemote(query, page, signal, cursor).then((json) => normalizeRemoteResult(ajax, json)).finally(() => this.remoteInFlight.delete(key));
1920
+ this.remoteInFlight.set(key, request);
1921
+ return request;
1922
+ }
1923
+ async requestRemote(query, page, signal, cursor) {
1767
1924
  const ajax = this.opts.ajax;
1768
1925
  const attempts = Math.max(0, Math.floor(ajax.retry ?? 0)) + 1;
1769
1926
  let lastError;
1770
1927
  for (let attempt = 0; attempt < attempts; attempt += 1) {
1771
1928
  try {
1772
- if (ajax.request) return await ajax.request(query, page, signal);
1773
- const response = await fetch(buildUrl(ajax, query, page), { signal });
1929
+ if (ajax.request)
1930
+ return cursor === void 0 ? await ajax.request(query, page, signal) : await ajax.request(query, page, signal, cursor);
1931
+ const response = await fetch(buildUrl(ajax, query, page, cursor), { signal });
1774
1932
  if (response.ok === false) throw new Error(`ForgeSelect: remote request failed with HTTP ${response.status}`);
1775
1933
  return await response.json();
1776
1934
  } catch (error) {
@@ -1798,10 +1956,13 @@ var ForgeSelect = class {
1798
1956
  const key = this.remoteCacheKey(query, 0);
1799
1957
  if (this.remoteCache.get(key)) return;
1800
1958
  const controller = new AbortController();
1959
+ this.prefetchControllers.add(controller);
1801
1960
  try {
1802
- const json = await this.requestRemote(query, 0, controller.signal);
1803
- this.remoteCache.set(key, normalizeRemoteResult(ajax, json), ajax.cacheTtl ?? 3e4);
1961
+ const result = await this.fetchRemoteResult(query, 0, controller.signal);
1962
+ this.remoteCache.set(key, result, ajax.cacheTtl ?? 3e4);
1804
1963
  } catch {
1964
+ } finally {
1965
+ this.prefetchControllers.delete(controller);
1805
1966
  }
1806
1967
  }
1807
1968
  /**
@@ -1827,12 +1988,12 @@ var ForgeSelect = class {
1827
1988
  const controller = new AbortController();
1828
1989
  this.ajaxController = controller;
1829
1990
  const page = append ? this.page + 1 : 0;
1991
+ const cursor = append ? this.nextCursor : void 0;
1830
1992
  try {
1831
- const key = this.remoteCacheKey(query, page);
1993
+ const key = this.remoteCacheKey(query, page, cursor);
1832
1994
  let result = this.remoteCache.get(key);
1833
1995
  if (!result) {
1834
- const json = await this.requestRemote(query, page, controller.signal);
1835
- result = normalizeRemoteResult(ajax, json);
1996
+ result = await this.fetchRemoteResult(query, page, controller.signal, cursor);
1836
1997
  this.remoteCache.set(key, result, ajax.cacheTtl ?? 3e4);
1837
1998
  }
1838
1999
  if (activeRequestId !== this.ajaxRequestId || this.destroyed) return;
@@ -1842,20 +2003,20 @@ var ForgeSelect = class {
1842
2003
  this.data = [...this.data, ...options.filter((o) => !existing.has(o.value))];
1843
2004
  } else {
1844
2005
  this.data = options;
1845
- this.rowContentCache.clear();
1846
- this.rowHeightCache.clear();
2006
+ this.clearRowCaches();
1847
2007
  }
1848
2008
  this.page = page;
1849
2009
  this.hasMore = hasMore;
2010
+ this.nextCursor = result.nextCursor;
1850
2011
  this.remoteLoaded = true;
1851
2012
  this.loadError = null;
2013
+ this.rebuildOptionIndexes();
1852
2014
  } catch (cause) {
1853
2015
  if (activeRequestId !== this.ajaxRequestId || this.destroyed || controller.signal.aborted) return;
1854
2016
  const error = cause instanceof Error ? cause : new Error(String(cause));
1855
2017
  if (!append) {
1856
2018
  this.data = [];
1857
- this.rowContentCache.clear();
1858
- this.rowHeightCache.clear();
2019
+ this.clearRowCaches();
1859
2020
  }
1860
2021
  this.hasMore = false;
1861
2022
  this.loadError = error;