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.js CHANGED
@@ -96,10 +96,10 @@ function parseOption(option) {
96
96
  }
97
97
 
98
98
  // src/option-renderer.ts
99
- function renderOptionContent(container, option, template, variant = "row") {
99
+ function renderOptionContent(container, option, template, variant = "row", sanitizeTemplate) {
100
100
  if (template) {
101
101
  const result = template(option);
102
- if (typeof result === "string") container.innerHTML = result;
102
+ if (typeof result === "string") container.innerHTML = sanitizeTemplate ? sanitizeTemplate(result, option) : result;
103
103
  else container.append(result);
104
104
  return;
105
105
  }
@@ -136,12 +136,12 @@ function renderOptionContent(container, option, template, variant = "row") {
136
136
  }
137
137
 
138
138
  // src/remote.ts
139
- function buildUrl(ajax, query, page) {
139
+ function buildUrl(ajax, query, page, cursor) {
140
140
  if (!ajax.url) throw new Error("ForgeSelect: ajax requires either url or request.");
141
141
  if (typeof ajax.url === "function") return ajax.url(query, page);
142
142
  if (!ajax.params) return ajax.url;
143
143
  const params = new URLSearchParams();
144
- for (const [key, value] of Object.entries(ajax.params(query, page))) params.set(key, String(value));
144
+ for (const [key, value] of Object.entries(ajax.params(query, page, cursor))) params.set(key, String(value));
145
145
  const separator = ajax.url.includes("?") ? "&" : "?";
146
146
  return `${ajax.url}${separator}${params.toString()}`;
147
147
  }
@@ -153,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,12 +546,16 @@ 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
557
  this.rowContentCache.clear();
558
+ this.rowElementCache.clear();
543
559
  this.rowHeightCache.clear();
544
560
  this.searchIndex.clear();
545
561
  this.portalHost?.remove();
@@ -588,10 +604,23 @@ var ForgeSelect = class {
588
604
  }
589
605
  if (options.templateResult !== void 0) this.opts.templateResult = options.templateResult;
590
606
  if (options.templateSelection !== void 0) this.opts.templateSelection = options.templateSelection;
607
+ if (options.sanitizeTemplate !== void 0) this.opts.sanitizeTemplate = options.sanitizeTemplate;
608
+ if (options.beforeSelect !== void 0) this.opts.beforeSelect = options.beforeSelect;
609
+ if (options.beforeUnselect !== void 0) this.opts.beforeUnselect = options.beforeUnselect;
610
+ if (options.beforeCreate !== void 0) this.opts.beforeCreate = options.beforeCreate;
611
+ if (options.createOption !== void 0) this.opts.createOption = options.createOption;
612
+ if (options.missingSelectionPolicy !== void 0) this.opts.missingSelectionPolicy = options.missingSelectionPolicy;
613
+ if (options.duplicateValuePolicy !== void 0) {
614
+ this.opts.duplicateValuePolicy = options.duplicateValuePolicy;
615
+ this.rebuildOptionIndexes();
616
+ }
591
617
  if (options.filterOption !== void 0) this.opts.filterOption = options.filterOption;
592
618
  if (options.searchFields !== void 0) this.opts.searchFields = options.searchFields;
593
619
  if (options.tokenSearch !== void 0) this.opts.tokenSearch = options.tokenSearch;
594
- if (options.accentInsensitive !== void 0) this.opts.accentInsensitive = options.accentInsensitive;
620
+ if (options.accentInsensitive !== void 0) {
621
+ this.opts.accentInsensitive = options.accentInsensitive;
622
+ this.rebuildOptionIndexes();
623
+ }
595
624
  if (options.searchScorer !== void 0) this.opts.searchScorer = options.searchScorer;
596
625
  if (options.highlightSearch !== void 0) this.opts.highlightSearch = options.highlightSearch;
597
626
  if (options.minSearchLength !== void 0)
@@ -620,6 +649,7 @@ var ForgeSelect = class {
620
649
  this.root.classList.toggle("forge-select--sortable", this.opts.sortable && this.opts.multiple);
621
650
  this.updateSearchVisibility();
622
651
  this.rowContentCache.clear();
652
+ this.rowElementCache.clear();
623
653
  this.rowHeightCache.clear();
624
654
  this.searchIndex.clear();
625
655
  this.renderValue();
@@ -652,6 +682,7 @@ var ForgeSelect = class {
652
682
  }
653
683
  clearRemoteCache() {
654
684
  this.remoteCache.clear();
685
+ this.remoteInFlight.clear();
655
686
  }
656
687
  setValue(value, options = {}) {
657
688
  const values = value == null ? [] : Array.isArray(value) ? value : [value];
@@ -681,10 +712,30 @@ var ForgeSelect = class {
681
712
  this.remoteLoaded = true;
682
713
  this.page = 0;
683
714
  this.hasMore = false;
715
+ this.nextCursor = void 0;
716
+ const previousData = this.data;
684
717
  this.data = data;
718
+ try {
719
+ this.rebuildOptionIndexes();
720
+ } catch (error) {
721
+ this.data = previousData;
722
+ this.rebuildOptionIndexes();
723
+ throw error;
724
+ }
725
+ const missing = this.selected.filter((value) => !this.optionByValue.has(value));
726
+ if (missing.length > 0 && this.opts.missingSelectionPolicy === "error") {
727
+ this.data = previousData;
728
+ this.rebuildOptionIndexes();
729
+ throw new Error(`ForgeSelect: setData() is missing selected value(s): ${missing.join(", ")}`);
730
+ }
685
731
  this.opts.data = data;
732
+ if (missing.length > 0 && this.opts.missingSelectionPolicy === "prune") {
733
+ this.selected = this.selected.filter((value) => this.optionByValue.has(value));
734
+ this.afterSelectionChange();
735
+ }
686
736
  this.updateSearchVisibility();
687
737
  this.rowContentCache.clear();
738
+ this.rowElementCache.clear();
688
739
  this.rowHeightCache.clear();
689
740
  this.searchIndex.clear();
690
741
  this.highlightedIndex = -1;
@@ -856,10 +907,24 @@ var ForgeSelect = class {
856
907
  });
857
908
  this.clearBtn.addEventListener("click", (event) => {
858
909
  event.stopPropagation();
910
+ if (this.selected.some((value) => {
911
+ const option = this.findOption(value) ?? this.selectedOptions.get(value) ?? { value, label: value };
912
+ return this.opts.beforeUnselect?.(option) === false;
913
+ }))
914
+ return;
859
915
  this.clearSelection();
860
916
  });
861
917
  if (this.searchInput) {
918
+ let composing = false;
919
+ this.searchInput.addEventListener("compositionstart", () => {
920
+ composing = true;
921
+ });
922
+ this.searchInput.addEventListener("compositionend", () => {
923
+ composing = false;
924
+ this.applySearchQuery(this.searchInput.value, true);
925
+ });
862
926
  this.searchInput.addEventListener("input", () => {
927
+ if (composing) return;
863
928
  this.applySearchQuery(this.searchInput.value, true);
864
929
  });
865
930
  this.searchInput.addEventListener("keydown", (event) => this.handleKeydown(event));
@@ -869,21 +934,22 @@ var ForgeSelect = class {
869
934
  const labels = text.split(/[,\n]+/).map((s) => s.trim()).filter(Boolean);
870
935
  if (labels.length < 2) return;
871
936
  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();
937
+ void Promise.all(labels.map((label) => this.createTag(label))).then((results) => {
938
+ const created = results.filter((result) => result !== void 0);
939
+ if (created.length === 0 || this.destroyed) return;
940
+ this.searchInput.value = "";
941
+ this.query = "";
942
+ this.afterSelectionChange();
943
+ for (const result of created) {
944
+ if (result.created) this.emitter.emit("create", result.option);
945
+ this.emitter.emit("select", result.option);
946
+ }
947
+ if (this.opts.closeOnSelect) this.close();
948
+ else this.renderList();
949
+ }).catch((cause) => {
950
+ const error = cause instanceof Error ? cause : new Error(String(cause));
951
+ this.emitter.emit("error", error);
952
+ });
887
953
  });
888
954
  }
889
955
  this.list.addEventListener("click", (event) => {
@@ -938,7 +1004,7 @@ var ForgeSelect = class {
938
1004
  this.renderList();
939
1005
  }
940
1006
  handleKeydown(event) {
941
- if (this.isDisabled) return;
1007
+ if (this.isDisabled || event.isComposing || event.keyCode === 229) return;
942
1008
  switch (event.key) {
943
1009
  case "Enter":
944
1010
  event.preventDefault();
@@ -1156,23 +1222,27 @@ var ForgeSelect = class {
1156
1222
  }
1157
1223
  }
1158
1224
  findOption(value) {
1159
- return findOption(this.data, value);
1225
+ return this.optionByValue.get(value);
1160
1226
  }
1161
1227
  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;
1228
+ return this.optionByLabel.get(normalizeSearchText(label, this.opts.accentInsensitive));
1229
+ }
1230
+ rebuildOptionIndexes() {
1231
+ this.optionByValue.clear();
1232
+ this.optionByLabel.clear();
1233
+ const duplicates = /* @__PURE__ */ new Set();
1234
+ const visit = (option) => {
1235
+ if (this.optionByValue.has(option.value)) duplicates.add(option.value);
1236
+ else this.optionByValue.set(option.value, option);
1237
+ const label = normalizeSearchText(option.label, this.opts.accentInsensitive);
1238
+ if (!this.optionByLabel.has(label)) this.optionByLabel.set(label, option);
1239
+ option.children?.forEach(visit);
1170
1240
  };
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;
1241
+ for (const item of this.data) (isGroup(item) ? item.options : [item]).forEach(visit);
1242
+ if (duplicates.size === 0 || this.opts.duplicateValuePolicy === "ignore") return;
1243
+ const message = `ForgeSelect: duplicate option value(s): ${[...duplicates].join(", ")}`;
1244
+ if (this.opts.duplicateValuePolicy === "error") throw new Error(message);
1245
+ console.warn(message);
1176
1246
  }
1177
1247
  /** Selects an existing option matching `label` exactly, or creates and selects a new one. */
1178
1248
  createTag(label) {
@@ -1188,12 +1258,26 @@ var ForgeSelect = class {
1188
1258
  this.selectValue(existing.value, false);
1189
1259
  return { option: existing, created: false };
1190
1260
  }
1191
- const option = { value: trimmed, label: trimmed };
1261
+ if (this.opts.beforeCreate?.(trimmed) === false) return void 0;
1262
+ const created = this.opts.createOption?.(trimmed) ?? { value: trimmed, label: trimmed };
1263
+ if (created instanceof Promise) {
1264
+ return created.then((option) => option ? this.addCreatedOption(option) : void 0);
1265
+ }
1266
+ return created ? this.addCreatedOption(created) : void 0;
1267
+ }
1268
+ addCreatedOption(option) {
1192
1269
  if (this.opts.multiple && !this.canSelectOption(option)) {
1193
1270
  this.announceMaximum(option);
1194
1271
  return void 0;
1195
1272
  }
1273
+ const duplicate = this.findOption(option.value);
1274
+ if (duplicate) {
1275
+ if (this.selected.includes(duplicate.value)) return void 0;
1276
+ this.selectValue(duplicate.value, false);
1277
+ return { option: duplicate, created: false };
1278
+ }
1196
1279
  this.data.push(option);
1280
+ this.rebuildOptionIndexes();
1197
1281
  this.selectValue(option.value, false);
1198
1282
  return { option, created: true };
1199
1283
  }
@@ -1201,8 +1285,18 @@ var ForgeSelect = class {
1201
1285
  const label = this.query.trim();
1202
1286
  if (!label) return;
1203
1287
  const result = this.createTag(label);
1204
- if (!result) return;
1205
- if (this.searchInput) {
1288
+ if (result instanceof Promise) {
1289
+ void result.then((created) => this.finishCreateFromQuery(created, label)).catch((cause) => {
1290
+ const error = cause instanceof Error ? cause : new Error(String(cause));
1291
+ this.emitter.emit("error", error);
1292
+ });
1293
+ return;
1294
+ }
1295
+ this.finishCreateFromQuery(result, label);
1296
+ }
1297
+ finishCreateFromQuery(result, sourceLabel) {
1298
+ if (!result || this.destroyed) return;
1299
+ if (this.searchInput && this.query.trim() === sourceLabel) {
1206
1300
  this.searchInput.value = "";
1207
1301
  this.query = "";
1208
1302
  }
@@ -1223,9 +1317,11 @@ var ForgeSelect = class {
1223
1317
  if (this.opts.multiple) {
1224
1318
  let changed = false;
1225
1319
  if (this.selected.includes(value)) {
1320
+ if (this.opts.beforeUnselect?.(item.option) === false) return;
1226
1321
  this.deselectValue(value, true);
1227
1322
  changed = true;
1228
1323
  } else if (this.canSelectOption(item.option)) {
1324
+ if (this.opts.beforeSelect?.(item.option) === false) return;
1229
1325
  this.selectValue(value, true);
1230
1326
  changed = true;
1231
1327
  } else {
@@ -1233,6 +1329,7 @@ var ForgeSelect = class {
1233
1329
  }
1234
1330
  if (changed && this.opts.closeOnSelect) this.close();
1235
1331
  } else {
1332
+ if (this.opts.beforeSelect?.(item.option) === false) return;
1236
1333
  this.selectValue(value, true);
1237
1334
  this.close();
1238
1335
  this.control.focus();
@@ -1257,7 +1354,7 @@ var ForgeSelect = class {
1257
1354
  tag.className = "forge-select__tag";
1258
1355
  const label = document.createElement("span");
1259
1356
  label.className = "forge-select__tag-label";
1260
- renderOptionContent(label, option, this.opts.templateSelection, "inline");
1357
+ renderOptionContent(label, option, this.opts.templateSelection, "inline", this.opts.sanitizeTemplate);
1261
1358
  const remove = document.createElement("button");
1262
1359
  remove.type = "button";
1263
1360
  remove.className = "forge-select__tag-remove";
@@ -1265,7 +1362,7 @@ var ForgeSelect = class {
1265
1362
  remove.textContent = "\xD7";
1266
1363
  remove.addEventListener("click", (event) => {
1267
1364
  event.stopPropagation();
1268
- if (!this.isDisabled) this.deselectValue(value, true);
1365
+ if (!this.isDisabled && this.opts.beforeUnselect?.(option) !== false) this.deselectValue(value, true);
1269
1366
  });
1270
1367
  tag.append(label, remove);
1271
1368
  if (this.opts.sortable) {
@@ -1285,7 +1382,7 @@ var ForgeSelect = class {
1285
1382
  };
1286
1383
  const span = document.createElement("span");
1287
1384
  span.className = "forge-select__single-value";
1288
- renderOptionContent(span, option, this.opts.templateSelection, "inline");
1385
+ renderOptionContent(span, option, this.opts.templateSelection, "inline", this.opts.sanitizeTemplate);
1289
1386
  this.valueEl.append(span);
1290
1387
  }
1291
1388
  }
@@ -1394,7 +1491,14 @@ var ForgeSelect = class {
1394
1491
  accentInsensitive: this.opts.accentInsensitive,
1395
1492
  scorer: this.opts.searchScorer
1396
1493
  }) > 0);
1397
- const subtreeMatches = (option) => query === "" || matches(option) || (option.children ?? []).some(subtreeMatches);
1494
+ const subtreeMatchCache = /* @__PURE__ */ new Map();
1495
+ const subtreeMatches = (option) => {
1496
+ const cached = subtreeMatchCache.get(option);
1497
+ if (cached !== void 0) return cached;
1498
+ const result = query === "" || matches(option) || (option.children ?? []).some(subtreeMatches);
1499
+ subtreeMatchCache.set(option, result);
1500
+ return result;
1501
+ };
1398
1502
  const pushOption = (option, depth, parentValue) => {
1399
1503
  let navIndex = -1;
1400
1504
  const interactionDisabled = this.isOptionDisabled(option) || this.hasReachedMaximum() && !this.selected.includes(option.value);
@@ -1450,7 +1554,7 @@ var ForgeSelect = class {
1450
1554
  return this.opts.virtualScroll !== false && this.rows.length > VIRTUAL_THRESHOLD;
1451
1555
  }
1452
1556
  rowKey(row, index) {
1453
- if (row.kind === "option") return `option:${row.option.value}`;
1557
+ if (row.kind === "option") return `option:${row.option.value}:${index}`;
1454
1558
  if (row.kind === "group") return `group:${row.label}:${index}`;
1455
1559
  return `${row.kind}:${index}`;
1456
1560
  }
@@ -1492,7 +1596,14 @@ var ForgeSelect = class {
1492
1596
  if (virtual) {
1493
1597
  const viewport = clientHeight || rowHeight * 8;
1494
1598
  if (this.opts.variableItemHeight) {
1495
- while (start < this.rows.length && offsets[start + 1] < scrollTop) start += 1;
1599
+ let low = 0;
1600
+ let high = this.rows.length;
1601
+ while (low < high) {
1602
+ const middle = low + high >>> 1;
1603
+ if (offsets[middle + 1] < scrollTop) low = middle + 1;
1604
+ else high = middle;
1605
+ }
1606
+ start = low;
1496
1607
  start = Math.max(0, start - VIRTUAL_BUFFER);
1497
1608
  end = start;
1498
1609
  const target = scrollTop + viewport + VIRTUAL_BUFFER * rowHeight;
@@ -1509,7 +1620,13 @@ var ForgeSelect = class {
1509
1620
  }
1510
1621
  const appended = [];
1511
1622
  for (let i = start; i < end; i++) {
1512
- const element = this.renderRow(this.rows[i]);
1623
+ const key = this.rowKey(this.rows[i], i);
1624
+ const element = this.renderRow(this.rows[i], this.rowElementCache.get(key));
1625
+ this.rowElementCache.set(key, element);
1626
+ if (this.rowElementCache.size > ROW_CACHE_LIMIT) {
1627
+ const oldest = this.rowElementCache.keys().next().value;
1628
+ this.rowElementCache.delete(oldest);
1629
+ }
1513
1630
  this.list.append(element);
1514
1631
  appended.push(element);
1515
1632
  }
@@ -1536,8 +1653,22 @@ var ForgeSelect = class {
1536
1653
  }
1537
1654
  this.updateActiveDescendant();
1538
1655
  }
1539
- renderRow(row) {
1540
- const li = document.createElement("li");
1656
+ renderRow(row, recycled) {
1657
+ const li = recycled ?? document.createElement("li");
1658
+ li.replaceChildren();
1659
+ li.className = "";
1660
+ for (const attribute of [
1661
+ "role",
1662
+ "id",
1663
+ "aria-hidden",
1664
+ "aria-selected",
1665
+ "aria-disabled",
1666
+ "aria-expanded",
1667
+ "aria-level",
1668
+ "data-nav-index",
1669
+ "data-option-value"
1670
+ ])
1671
+ li.removeAttribute(attribute);
1541
1672
  switch (row.kind) {
1542
1673
  case "group":
1543
1674
  li.className = "forge-select__group-label";
@@ -1657,7 +1788,7 @@ var ForgeSelect = class {
1657
1788
  if (!cached) {
1658
1789
  const holder = document.createElement("span");
1659
1790
  holder.className = "forge-select__option-content";
1660
- renderOptionContent(holder, option, this.opts.templateResult);
1791
+ renderOptionContent(holder, option, this.opts.templateResult, "row", this.opts.sanitizeTemplate);
1661
1792
  if (this.rowContentCache.size >= ROW_CACHE_LIMIT) {
1662
1793
  const oldest = this.rowContentCache.keys().next().value;
1663
1794
  this.rowContentCache.delete(oldest);
@@ -1746,6 +1877,7 @@ var ForgeSelect = class {
1746
1877
  this.ajaxController = null;
1747
1878
  this.page = 0;
1748
1879
  this.hasMore = true;
1880
+ this.nextCursor = void 0;
1749
1881
  this.setLoading(true);
1750
1882
  this.loadingMore = false;
1751
1883
  this.loadError = null;
@@ -1760,17 +1892,27 @@ var ForgeSelect = class {
1760
1892
  this.loading = loading;
1761
1893
  this.emitter.emit("loading", loading);
1762
1894
  }
1763
- remoteCacheKey(query, page) {
1764
- return `${query}\0${page}`;
1895
+ remoteCacheKey(query, page, cursor) {
1896
+ return `${query}\0${cursor ?? page}`;
1765
1897
  }
1766
- async requestRemote(query, page, signal) {
1898
+ fetchRemoteResult(query, page, signal, cursor) {
1899
+ const key = this.remoteCacheKey(query, page, cursor);
1900
+ const pending = this.remoteInFlight.get(key);
1901
+ if (pending) return pending;
1902
+ const ajax = this.opts.ajax;
1903
+ const request = this.requestRemote(query, page, signal, cursor).then((json) => normalizeRemoteResult(ajax, json)).finally(() => this.remoteInFlight.delete(key));
1904
+ this.remoteInFlight.set(key, request);
1905
+ return request;
1906
+ }
1907
+ async requestRemote(query, page, signal, cursor) {
1767
1908
  const ajax = this.opts.ajax;
1768
1909
  const attempts = Math.max(0, Math.floor(ajax.retry ?? 0)) + 1;
1769
1910
  let lastError;
1770
1911
  for (let attempt = 0; attempt < attempts; attempt += 1) {
1771
1912
  try {
1772
- if (ajax.request) return await ajax.request(query, page, signal);
1773
- const response = await fetch(buildUrl(ajax, query, page), { signal });
1913
+ if (ajax.request)
1914
+ return cursor === void 0 ? await ajax.request(query, page, signal) : await ajax.request(query, page, signal, cursor);
1915
+ const response = await fetch(buildUrl(ajax, query, page, cursor), { signal });
1774
1916
  if (response.ok === false) throw new Error(`ForgeSelect: remote request failed with HTTP ${response.status}`);
1775
1917
  return await response.json();
1776
1918
  } catch (error) {
@@ -1798,10 +1940,13 @@ var ForgeSelect = class {
1798
1940
  const key = this.remoteCacheKey(query, 0);
1799
1941
  if (this.remoteCache.get(key)) return;
1800
1942
  const controller = new AbortController();
1943
+ this.prefetchControllers.add(controller);
1801
1944
  try {
1802
- const json = await this.requestRemote(query, 0, controller.signal);
1803
- this.remoteCache.set(key, normalizeRemoteResult(ajax, json), ajax.cacheTtl ?? 3e4);
1945
+ const result = await this.fetchRemoteResult(query, 0, controller.signal);
1946
+ this.remoteCache.set(key, result, ajax.cacheTtl ?? 3e4);
1804
1947
  } catch {
1948
+ } finally {
1949
+ this.prefetchControllers.delete(controller);
1805
1950
  }
1806
1951
  }
1807
1952
  /**
@@ -1827,12 +1972,12 @@ var ForgeSelect = class {
1827
1972
  const controller = new AbortController();
1828
1973
  this.ajaxController = controller;
1829
1974
  const page = append ? this.page + 1 : 0;
1975
+ const cursor = append ? this.nextCursor : void 0;
1830
1976
  try {
1831
- const key = this.remoteCacheKey(query, page);
1977
+ const key = this.remoteCacheKey(query, page, cursor);
1832
1978
  let result = this.remoteCache.get(key);
1833
1979
  if (!result) {
1834
- const json = await this.requestRemote(query, page, controller.signal);
1835
- result = normalizeRemoteResult(ajax, json);
1980
+ result = await this.fetchRemoteResult(query, page, controller.signal, cursor);
1836
1981
  this.remoteCache.set(key, result, ajax.cacheTtl ?? 3e4);
1837
1982
  }
1838
1983
  if (activeRequestId !== this.ajaxRequestId || this.destroyed) return;
@@ -1847,8 +1992,10 @@ var ForgeSelect = class {
1847
1992
  }
1848
1993
  this.page = page;
1849
1994
  this.hasMore = hasMore;
1995
+ this.nextCursor = result.nextCursor;
1850
1996
  this.remoteLoaded = true;
1851
1997
  this.loadError = null;
1998
+ this.rebuildOptionIndexes();
1852
1999
  } catch (cause) {
1853
2000
  if (activeRequestId !== this.ajaxRequestId || this.destroyed || controller.signal.aborted) return;
1854
2001
  const error = cause instanceof Error ? cause : new Error(String(cause));