forge-select 0.4.0 → 0.5.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
@@ -156,6 +156,89 @@ function normalizeRemoteResult(ajax, response) {
156
156
  return { options: result.options, hasMore: ajax.pagination ? Boolean(result.hasMore) : false };
157
157
  }
158
158
 
159
+ // src/remote-cache.ts
160
+ var RemoteCache = class {
161
+ constructor() {
162
+ this.entries = /* @__PURE__ */ new Map();
163
+ }
164
+ get(key, now = Date.now()) {
165
+ const entry = this.entries.get(key);
166
+ if (!entry) return void 0;
167
+ if (entry.expiresAt <= now) {
168
+ this.entries.delete(key);
169
+ return void 0;
170
+ }
171
+ return entry.value;
172
+ }
173
+ set(key, value, ttl, now = Date.now()) {
174
+ if (ttl > 0) this.entries.set(key, { value, expiresAt: now + ttl });
175
+ }
176
+ clear() {
177
+ this.entries.clear();
178
+ }
179
+ };
180
+
181
+ // src/search.ts
182
+ function normalizeSearchText(value, accentInsensitive = true) {
183
+ const lower = value.toLocaleLowerCase();
184
+ return accentInsensitive ? lower.normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/đ/g, "d") : lower;
185
+ }
186
+ function getSearchField(option, field) {
187
+ if (field === "label") return option.label;
188
+ if (field === "description") return option.description ?? "";
189
+ const path = field.slice(5).split(".");
190
+ let value = option.meta;
191
+ for (const key of path) {
192
+ if (!value || typeof value !== "object") return "";
193
+ value = value[key];
194
+ }
195
+ return value == null ? "" : String(value);
196
+ }
197
+ var SearchIndex = class {
198
+ constructor() {
199
+ this.cache = /* @__PURE__ */ new WeakMap();
200
+ }
201
+ clear() {
202
+ this.cache = /* @__PURE__ */ new WeakMap();
203
+ }
204
+ score(option, query, config) {
205
+ const normalizedQuery = normalizeSearchText(query.trim(), config.accentInsensitive);
206
+ if (!normalizedQuery) return 1;
207
+ if (config.scorer) return config.scorer(option, query.trim(), normalizedQuery);
208
+ const key = `${config.accentInsensitive ? "1" : "0"}:${config.fields.join("\0")}`;
209
+ let variants = this.cache.get(option);
210
+ if (!variants) {
211
+ variants = /* @__PURE__ */ new Map();
212
+ this.cache.set(option, variants);
213
+ }
214
+ let haystacks = variants.get(key);
215
+ if (!haystacks) {
216
+ haystacks = config.fields.map(
217
+ (field) => normalizeSearchText(getSearchField(option, field), config.accentInsensitive)
218
+ );
219
+ variants.set(key, haystacks);
220
+ }
221
+ const tokens = config.tokenSearch ? normalizedQuery.split(/\s+/).filter(Boolean) : [normalizedQuery];
222
+ if (!tokens.every((token) => haystacks.some((field) => field.includes(token)))) return 0;
223
+ const label = haystacks[config.fields.indexOf("label")];
224
+ if (label === normalizedQuery) return 4;
225
+ if (label?.startsWith(normalizedQuery)) return 3;
226
+ if (label?.includes(normalizedQuery)) return 2;
227
+ return 1;
228
+ }
229
+ };
230
+ function findNormalizedRanges(label, query, accentInsensitive = true) {
231
+ const tokens = normalizeSearchText(query.trim(), accentInsensitive).split(/\s+/).filter(Boolean);
232
+ if (!tokens.length) return [];
233
+ const normalized = normalizeSearchText(label, accentInsensitive);
234
+ const ranges = [];
235
+ for (const token of tokens) {
236
+ const index = normalized.indexOf(token);
237
+ if (index >= 0) ranges.push([index, index + token.length]);
238
+ }
239
+ return ranges.sort((a, b) => a[0] - b[0]);
240
+ }
241
+
159
242
  // src/selection.ts
160
243
  function isGroup(item) {
161
244
  return item.options !== void 0;
@@ -240,6 +323,8 @@ var ForgeSelect = class {
240
323
  this.navItems = [];
241
324
  this.highlightedIndex = -1;
242
325
  this.rowContentCache = /* @__PURE__ */ new Map();
326
+ this.rowHeightCache = /* @__PURE__ */ new Map();
327
+ this.searchIndex = new SearchIndex();
243
328
  this.expandedValues = /* @__PURE__ */ new Set();
244
329
  this.loading = false;
245
330
  this.loadingMore = false;
@@ -249,6 +334,7 @@ var ForgeSelect = class {
249
334
  this.ajaxRequestId = 0;
250
335
  this.ajaxController = null;
251
336
  this.remoteLoaded = false;
337
+ this.remoteCache = new RemoteCache();
252
338
  this.loadError = null;
253
339
  this.originalDisplay = "";
254
340
  this.originalDisabled = false;
@@ -274,6 +360,7 @@ var ForgeSelect = class {
274
360
  this.control.setAttribute("aria-invalid", "true");
275
361
  if (!this.isOpen) this.open();
276
362
  this.control.focus();
363
+ this.emitter.emit("invalid", this.nativeSelect?.validationMessage ?? "");
277
364
  };
278
365
  this.onNativeChange = () => {
279
366
  if (!this.nativeSelect || this.destroyed || this.syncingNative) return;
@@ -312,11 +399,17 @@ var ForgeSelect = class {
312
399
  templateResult: options.templateResult,
313
400
  templateSelection: options.templateSelection,
314
401
  filterOption: options.filterOption,
402
+ searchFields: options.searchFields ?? ["label", "description"],
403
+ tokenSearch: options.tokenSearch ?? true,
404
+ accentInsensitive: options.accentInsensitive ?? true,
405
+ searchScorer: options.searchScorer,
406
+ highlightSearch: options.highlightSearch ?? false,
315
407
  minSearchLength: Math.max(0, Math.floor(options.minSearchLength ?? 0)),
316
408
  minResultsForSearch: Math.max(0, Math.floor(options.minResultsForSearch ?? 0)),
317
409
  isOptionDisabled: options.isOptionDisabled,
318
410
  virtualScroll: options.virtualScroll,
319
- itemHeight: options.itemHeight ?? DEFAULT_ITEM_HEIGHT,
411
+ itemHeight: typeof options.itemHeight === "number" ? Math.max(1, options.itemHeight) : DEFAULT_ITEM_HEIGHT,
412
+ variableItemHeight: options.itemHeight === "auto",
320
413
  language: options.language ?? "en",
321
414
  plugins: options.plugins ?? [],
322
415
  openOnFocus: options.openOnFocus ?? false,
@@ -340,6 +433,7 @@ var ForgeSelect = class {
340
433
  nativeSelect?.addEventListener("invalid", this.onNativeInvalid);
341
434
  this.nativeForm?.addEventListener("reset", this.onFormReset);
342
435
  for (const plugin of this.plugins) plugin.onInit?.(this);
436
+ for (const query of this.opts.ajax?.prefetch ?? []) void this.prefetchRemote(query);
343
437
  }
344
438
  applyNativeValues(values) {
345
439
  this.selected = [];
@@ -356,7 +450,7 @@ var ForgeSelect = class {
356
450
  this.root.classList.add("forge-select--open");
357
451
  this.control.setAttribute("aria-expanded", "true");
358
452
  document.addEventListener("mousedown", this.onDocumentMouseDown);
359
- if (this.opts.ajax && !this.remoteLoaded) {
453
+ if (this.opts.ajax && (this.opts.ajax.loadOnOpen ?? true) && !this.remoteLoaded) {
360
454
  this.scheduleRemoteLoad(this.query, 0);
361
455
  }
362
456
  this.renderList();
@@ -413,6 +507,8 @@ var ForgeSelect = class {
413
507
  this.nativeSelect?.removeEventListener("invalid", this.onNativeInvalid);
414
508
  this.nativeForm?.removeEventListener("reset", this.onFormReset);
415
509
  this.rowContentCache.clear();
510
+ this.rowHeightCache.clear();
511
+ this.searchIndex.clear();
416
512
  this.portalHost?.remove();
417
513
  this.root.remove();
418
514
  this.el.style.display = this.originalDisplay;
@@ -423,6 +519,106 @@ var ForgeSelect = class {
423
519
  if (this.opts.multiple) return [...this.selected];
424
520
  return this.selected[0] ?? null;
425
521
  }
522
+ getSearchQuery() {
523
+ return this.query;
524
+ }
525
+ setSearchQuery(query, options = {}) {
526
+ this.applySearchQuery(query, options.emitSearch ?? true);
527
+ }
528
+ isDropdownOpen() {
529
+ return this.isOpen;
530
+ }
531
+ updateOptions(options) {
532
+ if (options.data) this.setData(options.data);
533
+ if ("ajax" in options && options.ajax !== this.opts.ajax) {
534
+ this.opts.ajax = options.ajax;
535
+ this.remoteLoaded = false;
536
+ this.clearRemoteCache();
537
+ }
538
+ if (options.placeholder !== void 0) this.opts.placeholder = options.placeholder;
539
+ if (options.clearable !== void 0) this.opts.clearable = options.clearable;
540
+ if (options.allowCreate !== void 0) this.opts.allowCreate = options.allowCreate;
541
+ if (options.sortable !== void 0) this.opts.sortable = options.sortable;
542
+ if (options.closeOnSelect !== void 0) this.opts.closeOnSelect = options.closeOnSelect;
543
+ if ("maxSelections" in options)
544
+ this.opts.maxSelections = options.maxSelections == null || !Number.isFinite(options.maxSelections) ? void 0 : Math.max(0, Math.floor(options.maxSelections));
545
+ if (options.theme !== void 0) {
546
+ this.opts.theme = options.theme;
547
+ this.root.dataset.theme = options.theme;
548
+ if (this.portalHost) this.portalHost.dataset.theme = options.theme;
549
+ }
550
+ if (options.required !== void 0) {
551
+ this.opts.required = options.required;
552
+ if (options.required) this.control.setAttribute("aria-required", "true");
553
+ else this.control.removeAttribute("aria-required");
554
+ if (this.nativeSelect) this.nativeSelect.required = options.required;
555
+ }
556
+ if (options.templateResult !== void 0) this.opts.templateResult = options.templateResult;
557
+ if (options.templateSelection !== void 0) this.opts.templateSelection = options.templateSelection;
558
+ if (options.filterOption !== void 0) this.opts.filterOption = options.filterOption;
559
+ if (options.searchFields !== void 0) this.opts.searchFields = options.searchFields;
560
+ if (options.tokenSearch !== void 0) this.opts.tokenSearch = options.tokenSearch;
561
+ if (options.accentInsensitive !== void 0) this.opts.accentInsensitive = options.accentInsensitive;
562
+ if (options.searchScorer !== void 0) this.opts.searchScorer = options.searchScorer;
563
+ if (options.highlightSearch !== void 0) this.opts.highlightSearch = options.highlightSearch;
564
+ if (options.minSearchLength !== void 0)
565
+ this.opts.minSearchLength = Math.max(0, Math.floor(options.minSearchLength));
566
+ if (options.minResultsForSearch !== void 0)
567
+ this.opts.minResultsForSearch = Math.max(0, Math.floor(options.minResultsForSearch));
568
+ if (options.isOptionDisabled !== void 0) this.opts.isOptionDisabled = options.isOptionDisabled;
569
+ if (options.virtualScroll !== void 0) this.opts.virtualScroll = options.virtualScroll;
570
+ if (options.itemHeight !== void 0) {
571
+ this.opts.variableItemHeight = options.itemHeight === "auto";
572
+ if (typeof options.itemHeight === "number") this.opts.itemHeight = Math.max(1, options.itemHeight);
573
+ this.root.style.setProperty("--fs-item-height", `${this.opts.itemHeight}px`);
574
+ this.portalHost?.style.setProperty("--fs-item-height", `${this.opts.itemHeight}px`);
575
+ }
576
+ if (options.language !== void 0) {
577
+ this.opts.language = options.language;
578
+ this.strings = getStrings(options.language);
579
+ this.clearBtn.setAttribute("aria-label", this.strings.clearSelection);
580
+ this.searchInput?.setAttribute("aria-label", this.strings.search);
581
+ }
582
+ if (options.openOnFocus !== void 0) this.opts.openOnFocus = options.openOnFocus;
583
+ if (options.disabled !== void 0) {
584
+ if (options.disabled) this.disable();
585
+ else this.enable();
586
+ }
587
+ this.root.classList.toggle("forge-select--sortable", this.opts.sortable && this.opts.multiple);
588
+ this.updateSearchVisibility();
589
+ this.rowContentCache.clear();
590
+ this.searchIndex.clear();
591
+ this.renderValue();
592
+ if (this.isOpen) this.renderList();
593
+ }
594
+ validate() {
595
+ const valid = (!this.opts.required || this.selected.length > 0) && (this.control.dataset.validationMessage ?? "") === "";
596
+ this.control.classList.toggle("forge-select__control--invalid", !valid);
597
+ this.control.setAttribute("aria-invalid", String(!valid));
598
+ return valid;
599
+ }
600
+ setCustomValidity(message) {
601
+ this.nativeSelect?.setCustomValidity(message);
602
+ this.control.dataset.validationMessage = message;
603
+ }
604
+ reportValidity() {
605
+ const valid = this.validate() && (this.nativeSelect?.checkValidity() ?? true);
606
+ if (!valid) {
607
+ const message = this.nativeSelect?.validationMessage ?? this.control.dataset.validationMessage ?? "";
608
+ if (this.nativeSelect) return this.nativeSelect.reportValidity();
609
+ this.emitter.emit("invalid", message);
610
+ }
611
+ return valid;
612
+ }
613
+ reload() {
614
+ if (!this.opts.ajax) return;
615
+ this.clearRemoteCache();
616
+ this.remoteLoaded = false;
617
+ this.scheduleRemoteLoad(this.query, 0);
618
+ }
619
+ clearRemoteCache() {
620
+ this.remoteCache.clear();
621
+ }
426
622
  setValue(value, options = {}) {
427
623
  const values = value == null ? [] : Array.isArray(value) ? value : [value];
428
624
  const next = this.opts.multiple ? values : values.slice(0, 1);
@@ -445,7 +641,7 @@ var ForgeSelect = class {
445
641
  this.ajaxController?.abort();
446
642
  this.ajaxController = null;
447
643
  this.ajaxRequestId += 1;
448
- this.loading = false;
644
+ this.setLoading(false);
449
645
  this.loadingMore = false;
450
646
  this.loadError = null;
451
647
  this.remoteLoaded = true;
@@ -455,6 +651,7 @@ var ForgeSelect = class {
455
651
  this.opts.data = data;
456
652
  this.updateSearchVisibility();
457
653
  this.rowContentCache.clear();
654
+ this.searchIndex.clear();
458
655
  this.highlightedIndex = -1;
459
656
  if (this.isOpen) this.renderList();
460
657
  }
@@ -628,25 +825,7 @@ var ForgeSelect = class {
628
825
  });
629
826
  if (this.searchInput) {
630
827
  this.searchInput.addEventListener("input", () => {
631
- this.query = this.searchInput.value;
632
- this.highlightedIndex = -1;
633
- this.list.scrollTop = 0;
634
- this.emitter.emit("search", this.query);
635
- const trimmed = this.query.trim();
636
- const belowMinLength = trimmed !== "" && trimmed.length < this.opts.minSearchLength;
637
- if (this.opts.ajax && !belowMinLength) {
638
- this.scheduleRemoteLoad(this.query, this.opts.ajax.debounce ?? 250);
639
- } else {
640
- if (belowMinLength) {
641
- if (this.ajaxTimer) {
642
- clearTimeout(this.ajaxTimer);
643
- this.ajaxTimer = null;
644
- }
645
- this.ajaxController?.abort();
646
- this.loading = false;
647
- }
648
- this.renderList();
649
- }
828
+ this.applySearchQuery(this.searchInput.value, true);
650
829
  });
651
830
  this.searchInput.addEventListener("keydown", (event) => this.handleKeydown(event));
652
831
  this.searchInput.addEventListener("paste", (event) => {
@@ -697,6 +876,29 @@ var ForgeSelect = class {
697
876
  this.maybeLoadNextPage();
698
877
  });
699
878
  }
879
+ applySearchQuery(query, emitSearch) {
880
+ this.query = query;
881
+ if (this.searchInput && this.searchInput.value !== query) this.searchInput.value = query;
882
+ this.highlightedIndex = -1;
883
+ this.list.scrollTop = 0;
884
+ this.rowContentCache.clear();
885
+ if (emitSearch) this.emitter.emit("search", query);
886
+ const trimmed = query.trim();
887
+ const belowMinLength = trimmed !== "" && trimmed.length < this.opts.minSearchLength;
888
+ if (this.opts.ajax && !belowMinLength) {
889
+ this.scheduleRemoteLoad(query, this.opts.ajax.debounce ?? 250);
890
+ return;
891
+ }
892
+ if (belowMinLength) {
893
+ if (this.ajaxTimer) {
894
+ clearTimeout(this.ajaxTimer);
895
+ this.ajaxTimer = null;
896
+ }
897
+ this.ajaxController?.abort();
898
+ this.setLoading(false);
899
+ }
900
+ this.renderList();
901
+ }
700
902
  handleKeydown(event) {
701
903
  if (this.isDisabled) return;
702
904
  switch (event.key) {
@@ -1088,8 +1290,13 @@ var ForgeSelect = class {
1088
1290
  this.rows = [];
1089
1291
  this.navItems = [];
1090
1292
  const trimmedQuery = this.query.trim();
1091
- const query = trimmedQuery.toLowerCase();
1092
- const matches = (option) => query === "" || (this.opts.filterOption ? this.opts.filterOption(option, trimmedQuery) : option.label.toLowerCase().includes(query) || (option.description?.toLowerCase().includes(query) ?? false));
1293
+ const query = normalizeSearchText(trimmedQuery, this.opts.accentInsensitive);
1294
+ const matches = (option) => query === "" || (this.opts.filterOption ? this.opts.filterOption(option, trimmedQuery) : this.searchIndex.score(option, trimmedQuery, {
1295
+ fields: this.opts.searchFields,
1296
+ tokenSearch: this.opts.tokenSearch,
1297
+ accentInsensitive: this.opts.accentInsensitive,
1298
+ scorer: this.opts.searchScorer
1299
+ }) > 0);
1093
1300
  const subtreeMatches = (option) => query === "" || matches(option) || (option.children ?? []).some(subtreeMatches);
1094
1301
  const pushOption = (option, depth, parentValue) => {
1095
1302
  let navIndex = -1;
@@ -1145,6 +1352,25 @@ var ForgeSelect = class {
1145
1352
  usesVirtualScroll() {
1146
1353
  return this.opts.virtualScroll !== false && this.rows.length > VIRTUAL_THRESHOLD;
1147
1354
  }
1355
+ rowKey(row, index) {
1356
+ if (row.kind === "option") return `option:${row.option.value}`;
1357
+ if (row.kind === "group") return `group:${row.label}:${index}`;
1358
+ return `${row.kind}:${index}`;
1359
+ }
1360
+ measuredRowHeight(index) {
1361
+ return this.opts.variableItemHeight ? this.rowHeightCache.get(this.rowKey(this.rows[index], index)) ?? this.opts.itemHeight : this.opts.itemHeight;
1362
+ }
1363
+ rowOffset(index) {
1364
+ if (!this.opts.variableItemHeight) return index * this.opts.itemHeight;
1365
+ let offset = 0;
1366
+ for (let i = 0; i < index; i += 1) offset += this.measuredRowHeight(i);
1367
+ return offset;
1368
+ }
1369
+ rowOffsets() {
1370
+ const offsets = [0];
1371
+ for (let i = 0; i < this.rows.length; i += 1) offsets.push(offsets[i] + this.measuredRowHeight(i));
1372
+ return offsets;
1373
+ }
1148
1374
  renderList() {
1149
1375
  this.buildRows();
1150
1376
  this.renderRows();
@@ -1161,26 +1387,40 @@ var ForgeSelect = class {
1161
1387
  const virtual = this.usesVirtualScroll();
1162
1388
  this.list.textContent = "";
1163
1389
  const rowHeight = this.opts.itemHeight;
1390
+ const offsets = this.opts.variableItemHeight ? this.rowOffsets() : null;
1164
1391
  let start = 0;
1165
1392
  let end = this.rows.length;
1166
1393
  if (virtual) {
1167
1394
  const viewport = clientHeight || rowHeight * 8;
1168
- start = Math.max(0, Math.floor(scrollTop / rowHeight) - VIRTUAL_BUFFER);
1169
- end = Math.min(this.rows.length, start + Math.ceil(viewport / rowHeight) + VIRTUAL_BUFFER * 2);
1395
+ if (this.opts.variableItemHeight) {
1396
+ while (start < this.rows.length && offsets[start + 1] < scrollTop) start += 1;
1397
+ start = Math.max(0, start - VIRTUAL_BUFFER);
1398
+ end = start;
1399
+ const target = scrollTop + viewport + VIRTUAL_BUFFER * rowHeight;
1400
+ while (end < this.rows.length && offsets[end] < target) end += 1;
1401
+ } else {
1402
+ start = Math.max(0, Math.floor(scrollTop / rowHeight) - VIRTUAL_BUFFER);
1403
+ end = Math.min(this.rows.length, start + Math.ceil(viewport / rowHeight) + VIRTUAL_BUFFER * 2);
1404
+ }
1170
1405
  const topSpacer = document.createElement("li");
1171
1406
  topSpacer.className = "forge-select__spacer";
1172
1407
  topSpacer.setAttribute("aria-hidden", "true");
1173
- topSpacer.style.height = `${start * rowHeight}px`;
1408
+ topSpacer.style.height = `${offsets?.[start] ?? this.rowOffset(start)}px`;
1174
1409
  this.list.append(topSpacer);
1175
1410
  }
1176
1411
  for (let i = start; i < end; i++) {
1177
- this.list.append(this.renderRow(this.rows[i]));
1412
+ const element = this.renderRow(this.rows[i]);
1413
+ this.list.append(element);
1414
+ if (this.opts.variableItemHeight) {
1415
+ const measured = element.getBoundingClientRect().height || element.offsetHeight;
1416
+ if (measured > 0) this.rowHeightCache.set(this.rowKey(this.rows[i], i), measured);
1417
+ }
1178
1418
  }
1179
1419
  if (virtual) {
1180
1420
  const bottomSpacer = document.createElement("li");
1181
1421
  bottomSpacer.className = "forge-select__spacer";
1182
1422
  bottomSpacer.setAttribute("aria-hidden", "true");
1183
- bottomSpacer.style.height = `${(this.rows.length - end) * rowHeight}px`;
1423
+ bottomSpacer.style.height = `${offsets ? offsets[this.rows.length] - offsets[end] : this.rowOffset(this.rows.length) - this.rowOffset(end)}px`;
1184
1424
  this.list.append(bottomSpacer);
1185
1425
  if (this.list.scrollTop !== scrollTop) {
1186
1426
  this.list.scrollTop = scrollTop;
@@ -1247,6 +1487,7 @@ var ForgeSelect = class {
1247
1487
  if (isSelected) li.classList.add("forge-select__option--selected");
1248
1488
  if (this.opts.multiple && row.hasChildren && computeCheckState(row.option, this.selected, this.isOptionDisabled) === "some") {
1249
1489
  li.classList.add("forge-select__option--indeterminate");
1490
+ li.dataset.selectionState = "mixed";
1250
1491
  }
1251
1492
  if (row.depth > 0) {
1252
1493
  li.style.paddingLeft = `calc(12px + ${row.depth} * var(--fs-tree-indent, 18px))`;
@@ -1282,6 +1523,28 @@ var ForgeSelect = class {
1282
1523
  * cached content state-free.
1283
1524
  */
1284
1525
  optionContent(option) {
1526
+ if (this.opts.highlightSearch && this.query.trim() && !this.opts.templateResult) {
1527
+ const holder = document.createElement("span");
1528
+ holder.className = "forge-select__option-content";
1529
+ renderOptionContent(holder, option, void 0);
1530
+ const label = holder.querySelector(".forge-select__option-label") ?? holder;
1531
+ const ranges = findNormalizedRanges(option.label, this.query, this.opts.accentInsensitive);
1532
+ if (ranges.length) {
1533
+ label.textContent = "";
1534
+ let cursor = 0;
1535
+ for (const [start, end] of ranges) {
1536
+ if (start < cursor) continue;
1537
+ label.append(document.createTextNode(option.label.slice(cursor, start)));
1538
+ const mark = document.createElement("mark");
1539
+ mark.className = "forge-select__match";
1540
+ mark.textContent = option.label.slice(start, end);
1541
+ label.append(mark);
1542
+ cursor = end;
1543
+ }
1544
+ label.append(document.createTextNode(option.label.slice(cursor)));
1545
+ }
1546
+ return holder;
1547
+ }
1285
1548
  let cached = this.rowContentCache.get(option.value);
1286
1549
  if (!cached) {
1287
1550
  const holder = document.createElement("span");
@@ -1308,8 +1571,8 @@ var ForgeSelect = class {
1308
1571
  (row) => (row.kind === "option" || row.kind === "create") && row.navIndex === next
1309
1572
  );
1310
1573
  if (rowIndex >= 0) {
1311
- const rowHeight = this.opts.itemHeight;
1312
- const top = rowIndex * rowHeight;
1574
+ const rowHeight = this.measuredRowHeight(rowIndex);
1575
+ const top = this.rowOffset(rowIndex);
1313
1576
  const viewport = this.list.clientHeight || rowHeight * 8;
1314
1577
  let target = this.list.scrollTop;
1315
1578
  if (top < target) target = top;
@@ -1374,7 +1637,7 @@ var ForgeSelect = class {
1374
1637
  this.ajaxController = null;
1375
1638
  this.page = 0;
1376
1639
  this.hasMore = true;
1377
- this.loading = true;
1640
+ this.setLoading(true);
1378
1641
  this.loadingMore = false;
1379
1642
  this.loadError = null;
1380
1643
  this.renderList();
@@ -1383,6 +1646,55 @@ var ForgeSelect = class {
1383
1646
  void this.loadRemote(query, { requestId });
1384
1647
  }, delay);
1385
1648
  }
1649
+ setLoading(loading) {
1650
+ if (this.loading === loading) return;
1651
+ this.loading = loading;
1652
+ this.emitter.emit("loading", loading);
1653
+ }
1654
+ remoteCacheKey(query, page) {
1655
+ return `${query}\0${page}`;
1656
+ }
1657
+ async requestRemote(query, page, signal) {
1658
+ const ajax = this.opts.ajax;
1659
+ const attempts = Math.max(0, Math.floor(ajax.retry ?? 0)) + 1;
1660
+ let lastError;
1661
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
1662
+ try {
1663
+ if (ajax.request) return await ajax.request(query, page, signal);
1664
+ const response = await fetch(buildUrl(ajax, query, page), { signal });
1665
+ if (response.ok === false) throw new Error(`ForgeSelect: remote request failed with HTTP ${response.status}`);
1666
+ return await response.json();
1667
+ } catch (error) {
1668
+ lastError = error;
1669
+ if (signal.aborted || attempt === attempts - 1) throw error;
1670
+ const delay = Math.max(0, ajax.retryDelay ?? 250) * 2 ** attempt;
1671
+ await new Promise((resolve, reject) => {
1672
+ const timer = setTimeout(resolve, delay);
1673
+ signal.addEventListener(
1674
+ "abort",
1675
+ () => {
1676
+ clearTimeout(timer);
1677
+ reject(new DOMException("Aborted", "AbortError"));
1678
+ },
1679
+ { once: true }
1680
+ );
1681
+ });
1682
+ }
1683
+ }
1684
+ throw lastError;
1685
+ }
1686
+ async prefetchRemote(query) {
1687
+ const ajax = this.opts.ajax;
1688
+ if (!ajax || (ajax.cacheTtl ?? 3e4) <= 0) return;
1689
+ const key = this.remoteCacheKey(query, 0);
1690
+ if (this.remoteCache.get(key)) return;
1691
+ const controller = new AbortController();
1692
+ try {
1693
+ const json = await this.requestRemote(query, 0, controller.signal);
1694
+ this.remoteCache.set(key, normalizeRemoteResult(ajax, json), ajax.cacheTtl ?? 3e4);
1695
+ } catch {
1696
+ }
1697
+ }
1386
1698
  /**
1387
1699
  * Fires on every list scroll. Only acts when pagination is opted into via
1388
1700
  * `ajax.pagination`; reads real scroll geometry rather than row counts so
@@ -1407,17 +1719,15 @@ var ForgeSelect = class {
1407
1719
  this.ajaxController = controller;
1408
1720
  const page = append ? this.page + 1 : 0;
1409
1721
  try {
1410
- let json;
1411
- if (ajax.request) {
1412
- json = await ajax.request(query, page, controller.signal);
1413
- } else {
1414
- const url = buildUrl(ajax, query, page);
1415
- const response = await fetch(url, { signal: controller.signal });
1416
- if (response.ok === false) throw new Error(`ForgeSelect: remote request failed with HTTP ${response.status}`);
1417
- json = await response.json();
1722
+ const key = this.remoteCacheKey(query, page);
1723
+ let result = this.remoteCache.get(key);
1724
+ if (!result) {
1725
+ const json = await this.requestRemote(query, page, controller.signal);
1726
+ result = normalizeRemoteResult(ajax, json);
1727
+ this.remoteCache.set(key, result, ajax.cacheTtl ?? 3e4);
1418
1728
  }
1419
1729
  if (activeRequestId !== this.ajaxRequestId || this.destroyed) return;
1420
- const { options, hasMore } = normalizeRemoteResult(ajax, json);
1730
+ const { options, hasMore } = result;
1421
1731
  if (append) {
1422
1732
  const existing = collectValues(this.data);
1423
1733
  this.data = [...this.data, ...options.filter((o) => !existing.has(o.value))];
@@ -1442,7 +1752,7 @@ var ForgeSelect = class {
1442
1752
  } finally {
1443
1753
  if (activeRequestId === this.ajaxRequestId && !this.destroyed) {
1444
1754
  this.ajaxController = null;
1445
- this.loading = false;
1755
+ this.setLoading(false);
1446
1756
  this.loadingMore = false;
1447
1757
  if (this.isOpen) this.renderList();
1448
1758
  }