forge-select 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -123,10 +123,10 @@ function parseOption(option) {
123
123
  }
124
124
 
125
125
  // src/option-renderer.ts
126
- function renderOptionContent(container, option, template, variant = "row") {
126
+ function renderOptionContent(container, option, template, variant = "row", sanitizeTemplate) {
127
127
  if (template) {
128
128
  const result = template(option);
129
- if (typeof result === "string") container.innerHTML = result;
129
+ if (typeof result === "string") container.innerHTML = sanitizeTemplate ? sanitizeTemplate(result, option) : result;
130
130
  else container.append(result);
131
131
  return;
132
132
  }
@@ -163,12 +163,12 @@ function renderOptionContent(container, option, template, variant = "row") {
163
163
  }
164
164
 
165
165
  // src/remote.ts
166
- function buildUrl(ajax, query, page) {
166
+ function buildUrl(ajax, query, page, cursor) {
167
167
  if (!ajax.url) throw new Error("ForgeSelect: ajax requires either url or request.");
168
168
  if (typeof ajax.url === "function") return ajax.url(query, page);
169
169
  if (!ajax.params) return ajax.url;
170
170
  const params = new URLSearchParams();
171
- for (const [key, value] of Object.entries(ajax.params(query, page))) params.set(key, String(value));
171
+ for (const [key, value] of Object.entries(ajax.params(query, page, cursor))) params.set(key, String(value));
172
172
  const separator = ajax.url.includes("?") ? "&" : "?";
173
173
  return `${ajax.url}${separator}${params.toString()}`;
174
174
  }
@@ -180,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,12 +573,16 @@ 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
584
  this.rowContentCache.clear();
585
+ this.rowElementCache.clear();
570
586
  this.rowHeightCache.clear();
571
587
  this.searchIndex.clear();
572
588
  this.portalHost?.remove();
@@ -615,10 +631,23 @@ var ForgeSelect = class {
615
631
  }
616
632
  if (options.templateResult !== void 0) this.opts.templateResult = options.templateResult;
617
633
  if (options.templateSelection !== void 0) this.opts.templateSelection = options.templateSelection;
634
+ if (options.sanitizeTemplate !== void 0) this.opts.sanitizeTemplate = options.sanitizeTemplate;
635
+ if (options.beforeSelect !== void 0) this.opts.beforeSelect = options.beforeSelect;
636
+ if (options.beforeUnselect !== void 0) this.opts.beforeUnselect = options.beforeUnselect;
637
+ if (options.beforeCreate !== void 0) this.opts.beforeCreate = options.beforeCreate;
638
+ if (options.createOption !== void 0) this.opts.createOption = options.createOption;
639
+ if (options.missingSelectionPolicy !== void 0) this.opts.missingSelectionPolicy = options.missingSelectionPolicy;
640
+ if (options.duplicateValuePolicy !== void 0) {
641
+ this.opts.duplicateValuePolicy = options.duplicateValuePolicy;
642
+ this.rebuildOptionIndexes();
643
+ }
618
644
  if (options.filterOption !== void 0) this.opts.filterOption = options.filterOption;
619
645
  if (options.searchFields !== void 0) this.opts.searchFields = options.searchFields;
620
646
  if (options.tokenSearch !== void 0) this.opts.tokenSearch = options.tokenSearch;
621
- if (options.accentInsensitive !== void 0) this.opts.accentInsensitive = options.accentInsensitive;
647
+ if (options.accentInsensitive !== void 0) {
648
+ this.opts.accentInsensitive = options.accentInsensitive;
649
+ this.rebuildOptionIndexes();
650
+ }
622
651
  if (options.searchScorer !== void 0) this.opts.searchScorer = options.searchScorer;
623
652
  if (options.highlightSearch !== void 0) this.opts.highlightSearch = options.highlightSearch;
624
653
  if (options.minSearchLength !== void 0)
@@ -647,6 +676,7 @@ var ForgeSelect = class {
647
676
  this.root.classList.toggle("forge-select--sortable", this.opts.sortable && this.opts.multiple);
648
677
  this.updateSearchVisibility();
649
678
  this.rowContentCache.clear();
679
+ this.rowElementCache.clear();
650
680
  this.rowHeightCache.clear();
651
681
  this.searchIndex.clear();
652
682
  this.renderValue();
@@ -679,6 +709,7 @@ var ForgeSelect = class {
679
709
  }
680
710
  clearRemoteCache() {
681
711
  this.remoteCache.clear();
712
+ this.remoteInFlight.clear();
682
713
  }
683
714
  setValue(value, options = {}) {
684
715
  const values = value == null ? [] : Array.isArray(value) ? value : [value];
@@ -708,10 +739,30 @@ var ForgeSelect = class {
708
739
  this.remoteLoaded = true;
709
740
  this.page = 0;
710
741
  this.hasMore = false;
742
+ this.nextCursor = void 0;
743
+ const previousData = this.data;
711
744
  this.data = data;
745
+ try {
746
+ this.rebuildOptionIndexes();
747
+ } catch (error) {
748
+ this.data = previousData;
749
+ this.rebuildOptionIndexes();
750
+ throw error;
751
+ }
752
+ const missing = this.selected.filter((value) => !this.optionByValue.has(value));
753
+ if (missing.length > 0 && this.opts.missingSelectionPolicy === "error") {
754
+ this.data = previousData;
755
+ this.rebuildOptionIndexes();
756
+ throw new Error(`ForgeSelect: setData() is missing selected value(s): ${missing.join(", ")}`);
757
+ }
712
758
  this.opts.data = data;
759
+ if (missing.length > 0 && this.opts.missingSelectionPolicy === "prune") {
760
+ this.selected = this.selected.filter((value) => this.optionByValue.has(value));
761
+ this.afterSelectionChange();
762
+ }
713
763
  this.updateSearchVisibility();
714
764
  this.rowContentCache.clear();
765
+ this.rowElementCache.clear();
715
766
  this.rowHeightCache.clear();
716
767
  this.searchIndex.clear();
717
768
  this.highlightedIndex = -1;
@@ -883,10 +934,24 @@ var ForgeSelect = class {
883
934
  });
884
935
  this.clearBtn.addEventListener("click", (event) => {
885
936
  event.stopPropagation();
937
+ if (this.selected.some((value) => {
938
+ const option = this.findOption(value) ?? this.selectedOptions.get(value) ?? { value, label: value };
939
+ return this.opts.beforeUnselect?.(option) === false;
940
+ }))
941
+ return;
886
942
  this.clearSelection();
887
943
  });
888
944
  if (this.searchInput) {
945
+ let composing = false;
946
+ this.searchInput.addEventListener("compositionstart", () => {
947
+ composing = true;
948
+ });
949
+ this.searchInput.addEventListener("compositionend", () => {
950
+ composing = false;
951
+ this.applySearchQuery(this.searchInput.value, true);
952
+ });
889
953
  this.searchInput.addEventListener("input", () => {
954
+ if (composing) return;
890
955
  this.applySearchQuery(this.searchInput.value, true);
891
956
  });
892
957
  this.searchInput.addEventListener("keydown", (event) => this.handleKeydown(event));
@@ -896,21 +961,22 @@ var ForgeSelect = class {
896
961
  const labels = text.split(/[,\n]+/).map((s) => s.trim()).filter(Boolean);
897
962
  if (labels.length < 2) return;
898
963
  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();
964
+ void Promise.all(labels.map((label) => this.createTag(label))).then((results) => {
965
+ const created = results.filter((result) => result !== void 0);
966
+ if (created.length === 0 || this.destroyed) return;
967
+ this.searchInput.value = "";
968
+ this.query = "";
969
+ this.afterSelectionChange();
970
+ for (const result of created) {
971
+ if (result.created) this.emitter.emit("create", result.option);
972
+ this.emitter.emit("select", result.option);
973
+ }
974
+ if (this.opts.closeOnSelect) this.close();
975
+ else this.renderList();
976
+ }).catch((cause) => {
977
+ const error = cause instanceof Error ? cause : new Error(String(cause));
978
+ this.emitter.emit("error", error);
979
+ });
914
980
  });
915
981
  }
916
982
  this.list.addEventListener("click", (event) => {
@@ -965,7 +1031,7 @@ var ForgeSelect = class {
965
1031
  this.renderList();
966
1032
  }
967
1033
  handleKeydown(event) {
968
- if (this.isDisabled) return;
1034
+ if (this.isDisabled || event.isComposing || event.keyCode === 229) return;
969
1035
  switch (event.key) {
970
1036
  case "Enter":
971
1037
  event.preventDefault();
@@ -1183,23 +1249,27 @@ var ForgeSelect = class {
1183
1249
  }
1184
1250
  }
1185
1251
  findOption(value) {
1186
- return findOption(this.data, value);
1252
+ return this.optionByValue.get(value);
1187
1253
  }
1188
1254
  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;
1255
+ return this.optionByLabel.get(normalizeSearchText(label, this.opts.accentInsensitive));
1256
+ }
1257
+ rebuildOptionIndexes() {
1258
+ this.optionByValue.clear();
1259
+ this.optionByLabel.clear();
1260
+ const duplicates = /* @__PURE__ */ new Set();
1261
+ const visit = (option) => {
1262
+ if (this.optionByValue.has(option.value)) duplicates.add(option.value);
1263
+ else this.optionByValue.set(option.value, option);
1264
+ const label = normalizeSearchText(option.label, this.opts.accentInsensitive);
1265
+ if (!this.optionByLabel.has(label)) this.optionByLabel.set(label, option);
1266
+ option.children?.forEach(visit);
1197
1267
  };
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;
1268
+ for (const item of this.data) (isGroup(item) ? item.options : [item]).forEach(visit);
1269
+ if (duplicates.size === 0 || this.opts.duplicateValuePolicy === "ignore") return;
1270
+ const message = `ForgeSelect: duplicate option value(s): ${[...duplicates].join(", ")}`;
1271
+ if (this.opts.duplicateValuePolicy === "error") throw new Error(message);
1272
+ console.warn(message);
1203
1273
  }
1204
1274
  /** Selects an existing option matching `label` exactly, or creates and selects a new one. */
1205
1275
  createTag(label) {
@@ -1215,12 +1285,26 @@ var ForgeSelect = class {
1215
1285
  this.selectValue(existing.value, false);
1216
1286
  return { option: existing, created: false };
1217
1287
  }
1218
- const option = { value: trimmed, label: trimmed };
1288
+ if (this.opts.beforeCreate?.(trimmed) === false) return void 0;
1289
+ const created = this.opts.createOption?.(trimmed) ?? { value: trimmed, label: trimmed };
1290
+ if (created instanceof Promise) {
1291
+ return created.then((option) => option ? this.addCreatedOption(option) : void 0);
1292
+ }
1293
+ return created ? this.addCreatedOption(created) : void 0;
1294
+ }
1295
+ addCreatedOption(option) {
1219
1296
  if (this.opts.multiple && !this.canSelectOption(option)) {
1220
1297
  this.announceMaximum(option);
1221
1298
  return void 0;
1222
1299
  }
1300
+ const duplicate = this.findOption(option.value);
1301
+ if (duplicate) {
1302
+ if (this.selected.includes(duplicate.value)) return void 0;
1303
+ this.selectValue(duplicate.value, false);
1304
+ return { option: duplicate, created: false };
1305
+ }
1223
1306
  this.data.push(option);
1307
+ this.rebuildOptionIndexes();
1224
1308
  this.selectValue(option.value, false);
1225
1309
  return { option, created: true };
1226
1310
  }
@@ -1228,8 +1312,18 @@ var ForgeSelect = class {
1228
1312
  const label = this.query.trim();
1229
1313
  if (!label) return;
1230
1314
  const result = this.createTag(label);
1231
- if (!result) return;
1232
- if (this.searchInput) {
1315
+ if (result instanceof Promise) {
1316
+ void result.then((created) => this.finishCreateFromQuery(created, label)).catch((cause) => {
1317
+ const error = cause instanceof Error ? cause : new Error(String(cause));
1318
+ this.emitter.emit("error", error);
1319
+ });
1320
+ return;
1321
+ }
1322
+ this.finishCreateFromQuery(result, label);
1323
+ }
1324
+ finishCreateFromQuery(result, sourceLabel) {
1325
+ if (!result || this.destroyed) return;
1326
+ if (this.searchInput && this.query.trim() === sourceLabel) {
1233
1327
  this.searchInput.value = "";
1234
1328
  this.query = "";
1235
1329
  }
@@ -1250,9 +1344,11 @@ var ForgeSelect = class {
1250
1344
  if (this.opts.multiple) {
1251
1345
  let changed = false;
1252
1346
  if (this.selected.includes(value)) {
1347
+ if (this.opts.beforeUnselect?.(item.option) === false) return;
1253
1348
  this.deselectValue(value, true);
1254
1349
  changed = true;
1255
1350
  } else if (this.canSelectOption(item.option)) {
1351
+ if (this.opts.beforeSelect?.(item.option) === false) return;
1256
1352
  this.selectValue(value, true);
1257
1353
  changed = true;
1258
1354
  } else {
@@ -1260,6 +1356,7 @@ var ForgeSelect = class {
1260
1356
  }
1261
1357
  if (changed && this.opts.closeOnSelect) this.close();
1262
1358
  } else {
1359
+ if (this.opts.beforeSelect?.(item.option) === false) return;
1263
1360
  this.selectValue(value, true);
1264
1361
  this.close();
1265
1362
  this.control.focus();
@@ -1284,7 +1381,7 @@ var ForgeSelect = class {
1284
1381
  tag.className = "forge-select__tag";
1285
1382
  const label = document.createElement("span");
1286
1383
  label.className = "forge-select__tag-label";
1287
- renderOptionContent(label, option, this.opts.templateSelection, "inline");
1384
+ renderOptionContent(label, option, this.opts.templateSelection, "inline", this.opts.sanitizeTemplate);
1288
1385
  const remove = document.createElement("button");
1289
1386
  remove.type = "button";
1290
1387
  remove.className = "forge-select__tag-remove";
@@ -1292,7 +1389,7 @@ var ForgeSelect = class {
1292
1389
  remove.textContent = "\xD7";
1293
1390
  remove.addEventListener("click", (event) => {
1294
1391
  event.stopPropagation();
1295
- if (!this.isDisabled) this.deselectValue(value, true);
1392
+ if (!this.isDisabled && this.opts.beforeUnselect?.(option) !== false) this.deselectValue(value, true);
1296
1393
  });
1297
1394
  tag.append(label, remove);
1298
1395
  if (this.opts.sortable) {
@@ -1312,7 +1409,7 @@ var ForgeSelect = class {
1312
1409
  };
1313
1410
  const span = document.createElement("span");
1314
1411
  span.className = "forge-select__single-value";
1315
- renderOptionContent(span, option, this.opts.templateSelection, "inline");
1412
+ renderOptionContent(span, option, this.opts.templateSelection, "inline", this.opts.sanitizeTemplate);
1316
1413
  this.valueEl.append(span);
1317
1414
  }
1318
1415
  }
@@ -1421,7 +1518,14 @@ var ForgeSelect = class {
1421
1518
  accentInsensitive: this.opts.accentInsensitive,
1422
1519
  scorer: this.opts.searchScorer
1423
1520
  }) > 0);
1424
- const subtreeMatches = (option) => query === "" || matches(option) || (option.children ?? []).some(subtreeMatches);
1521
+ const subtreeMatchCache = /* @__PURE__ */ new Map();
1522
+ const subtreeMatches = (option) => {
1523
+ const cached = subtreeMatchCache.get(option);
1524
+ if (cached !== void 0) return cached;
1525
+ const result = query === "" || matches(option) || (option.children ?? []).some(subtreeMatches);
1526
+ subtreeMatchCache.set(option, result);
1527
+ return result;
1528
+ };
1425
1529
  const pushOption = (option, depth, parentValue) => {
1426
1530
  let navIndex = -1;
1427
1531
  const interactionDisabled = this.isOptionDisabled(option) || this.hasReachedMaximum() && !this.selected.includes(option.value);
@@ -1477,7 +1581,7 @@ var ForgeSelect = class {
1477
1581
  return this.opts.virtualScroll !== false && this.rows.length > VIRTUAL_THRESHOLD;
1478
1582
  }
1479
1583
  rowKey(row, index) {
1480
- if (row.kind === "option") return `option:${row.option.value}`;
1584
+ if (row.kind === "option") return `option:${row.option.value}:${index}`;
1481
1585
  if (row.kind === "group") return `group:${row.label}:${index}`;
1482
1586
  return `${row.kind}:${index}`;
1483
1587
  }
@@ -1519,7 +1623,14 @@ var ForgeSelect = class {
1519
1623
  if (virtual) {
1520
1624
  const viewport = clientHeight || rowHeight * 8;
1521
1625
  if (this.opts.variableItemHeight) {
1522
- while (start < this.rows.length && offsets[start + 1] < scrollTop) start += 1;
1626
+ let low = 0;
1627
+ let high = this.rows.length;
1628
+ while (low < high) {
1629
+ const middle = low + high >>> 1;
1630
+ if (offsets[middle + 1] < scrollTop) low = middle + 1;
1631
+ else high = middle;
1632
+ }
1633
+ start = low;
1523
1634
  start = Math.max(0, start - VIRTUAL_BUFFER);
1524
1635
  end = start;
1525
1636
  const target = scrollTop + viewport + VIRTUAL_BUFFER * rowHeight;
@@ -1536,7 +1647,13 @@ var ForgeSelect = class {
1536
1647
  }
1537
1648
  const appended = [];
1538
1649
  for (let i = start; i < end; i++) {
1539
- const element = this.renderRow(this.rows[i]);
1650
+ const key = this.rowKey(this.rows[i], i);
1651
+ const element = this.renderRow(this.rows[i], this.rowElementCache.get(key));
1652
+ this.rowElementCache.set(key, element);
1653
+ if (this.rowElementCache.size > ROW_CACHE_LIMIT) {
1654
+ const oldest = this.rowElementCache.keys().next().value;
1655
+ this.rowElementCache.delete(oldest);
1656
+ }
1540
1657
  this.list.append(element);
1541
1658
  appended.push(element);
1542
1659
  }
@@ -1563,8 +1680,22 @@ var ForgeSelect = class {
1563
1680
  }
1564
1681
  this.updateActiveDescendant();
1565
1682
  }
1566
- renderRow(row) {
1567
- const li = document.createElement("li");
1683
+ renderRow(row, recycled) {
1684
+ const li = recycled ?? document.createElement("li");
1685
+ li.replaceChildren();
1686
+ li.className = "";
1687
+ for (const attribute of [
1688
+ "role",
1689
+ "id",
1690
+ "aria-hidden",
1691
+ "aria-selected",
1692
+ "aria-disabled",
1693
+ "aria-expanded",
1694
+ "aria-level",
1695
+ "data-nav-index",
1696
+ "data-option-value"
1697
+ ])
1698
+ li.removeAttribute(attribute);
1568
1699
  switch (row.kind) {
1569
1700
  case "group":
1570
1701
  li.className = "forge-select__group-label";
@@ -1684,7 +1815,7 @@ var ForgeSelect = class {
1684
1815
  if (!cached) {
1685
1816
  const holder = document.createElement("span");
1686
1817
  holder.className = "forge-select__option-content";
1687
- renderOptionContent(holder, option, this.opts.templateResult);
1818
+ renderOptionContent(holder, option, this.opts.templateResult, "row", this.opts.sanitizeTemplate);
1688
1819
  if (this.rowContentCache.size >= ROW_CACHE_LIMIT) {
1689
1820
  const oldest = this.rowContentCache.keys().next().value;
1690
1821
  this.rowContentCache.delete(oldest);
@@ -1773,6 +1904,7 @@ var ForgeSelect = class {
1773
1904
  this.ajaxController = null;
1774
1905
  this.page = 0;
1775
1906
  this.hasMore = true;
1907
+ this.nextCursor = void 0;
1776
1908
  this.setLoading(true);
1777
1909
  this.loadingMore = false;
1778
1910
  this.loadError = null;
@@ -1787,17 +1919,27 @@ var ForgeSelect = class {
1787
1919
  this.loading = loading;
1788
1920
  this.emitter.emit("loading", loading);
1789
1921
  }
1790
- remoteCacheKey(query, page) {
1791
- return `${query}\0${page}`;
1922
+ remoteCacheKey(query, page, cursor) {
1923
+ return `${query}\0${cursor ?? page}`;
1792
1924
  }
1793
- async requestRemote(query, page, signal) {
1925
+ fetchRemoteResult(query, page, signal, cursor) {
1926
+ const key = this.remoteCacheKey(query, page, cursor);
1927
+ const pending = this.remoteInFlight.get(key);
1928
+ if (pending) return pending;
1929
+ const ajax = this.opts.ajax;
1930
+ const request = this.requestRemote(query, page, signal, cursor).then((json) => normalizeRemoteResult(ajax, json)).finally(() => this.remoteInFlight.delete(key));
1931
+ this.remoteInFlight.set(key, request);
1932
+ return request;
1933
+ }
1934
+ async requestRemote(query, page, signal, cursor) {
1794
1935
  const ajax = this.opts.ajax;
1795
1936
  const attempts = Math.max(0, Math.floor(ajax.retry ?? 0)) + 1;
1796
1937
  let lastError;
1797
1938
  for (let attempt = 0; attempt < attempts; attempt += 1) {
1798
1939
  try {
1799
- if (ajax.request) return await ajax.request(query, page, signal);
1800
- const response = await fetch(buildUrl(ajax, query, page), { signal });
1940
+ if (ajax.request)
1941
+ return cursor === void 0 ? await ajax.request(query, page, signal) : await ajax.request(query, page, signal, cursor);
1942
+ const response = await fetch(buildUrl(ajax, query, page, cursor), { signal });
1801
1943
  if (response.ok === false) throw new Error(`ForgeSelect: remote request failed with HTTP ${response.status}`);
1802
1944
  return await response.json();
1803
1945
  } catch (error) {
@@ -1825,10 +1967,13 @@ var ForgeSelect = class {
1825
1967
  const key = this.remoteCacheKey(query, 0);
1826
1968
  if (this.remoteCache.get(key)) return;
1827
1969
  const controller = new AbortController();
1970
+ this.prefetchControllers.add(controller);
1828
1971
  try {
1829
- const json = await this.requestRemote(query, 0, controller.signal);
1830
- this.remoteCache.set(key, normalizeRemoteResult(ajax, json), ajax.cacheTtl ?? 3e4);
1972
+ const result = await this.fetchRemoteResult(query, 0, controller.signal);
1973
+ this.remoteCache.set(key, result, ajax.cacheTtl ?? 3e4);
1831
1974
  } catch {
1975
+ } finally {
1976
+ this.prefetchControllers.delete(controller);
1832
1977
  }
1833
1978
  }
1834
1979
  /**
@@ -1854,12 +1999,12 @@ var ForgeSelect = class {
1854
1999
  const controller = new AbortController();
1855
2000
  this.ajaxController = controller;
1856
2001
  const page = append ? this.page + 1 : 0;
2002
+ const cursor = append ? this.nextCursor : void 0;
1857
2003
  try {
1858
- const key = this.remoteCacheKey(query, page);
2004
+ const key = this.remoteCacheKey(query, page, cursor);
1859
2005
  let result = this.remoteCache.get(key);
1860
2006
  if (!result) {
1861
- const json = await this.requestRemote(query, page, controller.signal);
1862
- result = normalizeRemoteResult(ajax, json);
2007
+ result = await this.fetchRemoteResult(query, page, controller.signal, cursor);
1863
2008
  this.remoteCache.set(key, result, ajax.cacheTtl ?? 3e4);
1864
2009
  }
1865
2010
  if (activeRequestId !== this.ajaxRequestId || this.destroyed) return;
@@ -1874,8 +2019,10 @@ var ForgeSelect = class {
1874
2019
  }
1875
2020
  this.page = page;
1876
2021
  this.hasMore = hasMore;
2022
+ this.nextCursor = result.nextCursor;
1877
2023
  this.remoteLoaded = true;
1878
2024
  this.loadError = null;
2025
+ this.rebuildOptionIndexes();
1879
2026
  } catch (cause) {
1880
2027
  if (activeRequestId !== this.ajaxRequestId || this.destroyed || controller.signal.aborted) return;
1881
2028
  const error = cause instanceof Error ? cause : new Error(String(cause));