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/README.md CHANGED
@@ -159,7 +159,7 @@ Write and run Forge Select code in the browser at **<https://forgeselect.konexfo
159
159
  | `minResultsForSearch` | `number` | `0` | Hide local search below an option-count threshold |
160
160
  | `isOptionDisabled` | `(option) => boolean` | `undefined` | Dynamically disable an option per render |
161
161
  | `virtualScroll` | `boolean` | _(auto)_ | Virtualize the list once it exceeds ~100 rows |
162
- | `itemHeight` | `number` | `36` | Row height in px used by the virtual scroller |
162
+ | `itemHeight` | `number \| "auto"` | `36` | Fixed or measured variable-height virtual rows |
163
163
  | `language` | `string \| Record<string, string>` | `"en"` | Locale code or a custom string table for i18n |
164
164
  | `plugins` | `Array<ForgeSelectPlugin>` | `[]` | Plugins to register on this instance |
165
165
  | `openOnFocus` | `boolean` | `false` | Open the dropdown on keyboard focus |
package/dist/index.cjs CHANGED
@@ -183,6 +183,89 @@ function normalizeRemoteResult(ajax, response) {
183
183
  return { options: result.options, hasMore: ajax.pagination ? Boolean(result.hasMore) : false };
184
184
  }
185
185
 
186
+ // src/remote-cache.ts
187
+ var RemoteCache = class {
188
+ constructor() {
189
+ this.entries = /* @__PURE__ */ new Map();
190
+ }
191
+ get(key, now = Date.now()) {
192
+ const entry = this.entries.get(key);
193
+ if (!entry) return void 0;
194
+ if (entry.expiresAt <= now) {
195
+ this.entries.delete(key);
196
+ return void 0;
197
+ }
198
+ return entry.value;
199
+ }
200
+ set(key, value, ttl, now = Date.now()) {
201
+ if (ttl > 0) this.entries.set(key, { value, expiresAt: now + ttl });
202
+ }
203
+ clear() {
204
+ this.entries.clear();
205
+ }
206
+ };
207
+
208
+ // src/search.ts
209
+ function normalizeSearchText(value, accentInsensitive = true) {
210
+ const lower = value.toLocaleLowerCase();
211
+ return accentInsensitive ? lower.normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/đ/g, "d") : lower;
212
+ }
213
+ function getSearchField(option, field) {
214
+ if (field === "label") return option.label;
215
+ if (field === "description") return option.description ?? "";
216
+ const path = field.slice(5).split(".");
217
+ let value = option.meta;
218
+ for (const key of path) {
219
+ if (!value || typeof value !== "object") return "";
220
+ value = value[key];
221
+ }
222
+ return value == null ? "" : String(value);
223
+ }
224
+ var SearchIndex = class {
225
+ constructor() {
226
+ this.cache = /* @__PURE__ */ new WeakMap();
227
+ }
228
+ clear() {
229
+ this.cache = /* @__PURE__ */ new WeakMap();
230
+ }
231
+ score(option, query, config) {
232
+ const normalizedQuery = normalizeSearchText(query.trim(), config.accentInsensitive);
233
+ if (!normalizedQuery) return 1;
234
+ if (config.scorer) return config.scorer(option, query.trim(), normalizedQuery);
235
+ const key = `${config.accentInsensitive ? "1" : "0"}:${config.fields.join("\0")}`;
236
+ let variants = this.cache.get(option);
237
+ if (!variants) {
238
+ variants = /* @__PURE__ */ new Map();
239
+ this.cache.set(option, variants);
240
+ }
241
+ let haystacks = variants.get(key);
242
+ if (!haystacks) {
243
+ haystacks = config.fields.map(
244
+ (field) => normalizeSearchText(getSearchField(option, field), config.accentInsensitive)
245
+ );
246
+ variants.set(key, haystacks);
247
+ }
248
+ const tokens = config.tokenSearch ? normalizedQuery.split(/\s+/).filter(Boolean) : [normalizedQuery];
249
+ if (!tokens.every((token) => haystacks.some((field) => field.includes(token)))) return 0;
250
+ const label = haystacks[config.fields.indexOf("label")];
251
+ if (label === normalizedQuery) return 4;
252
+ if (label?.startsWith(normalizedQuery)) return 3;
253
+ if (label?.includes(normalizedQuery)) return 2;
254
+ return 1;
255
+ }
256
+ };
257
+ function findNormalizedRanges(label, query, accentInsensitive = true) {
258
+ const tokens = normalizeSearchText(query.trim(), accentInsensitive).split(/\s+/).filter(Boolean);
259
+ if (!tokens.length) return [];
260
+ const normalized = normalizeSearchText(label, accentInsensitive);
261
+ const ranges = [];
262
+ for (const token of tokens) {
263
+ const index = normalized.indexOf(token);
264
+ if (index >= 0) ranges.push([index, index + token.length]);
265
+ }
266
+ return ranges.sort((a, b) => a[0] - b[0]);
267
+ }
268
+
186
269
  // src/selection.ts
187
270
  function isGroup(item) {
188
271
  return item.options !== void 0;
@@ -267,6 +350,8 @@ var ForgeSelect = class {
267
350
  this.navItems = [];
268
351
  this.highlightedIndex = -1;
269
352
  this.rowContentCache = /* @__PURE__ */ new Map();
353
+ this.rowHeightCache = /* @__PURE__ */ new Map();
354
+ this.searchIndex = new SearchIndex();
270
355
  this.expandedValues = /* @__PURE__ */ new Set();
271
356
  this.loading = false;
272
357
  this.loadingMore = false;
@@ -276,6 +361,7 @@ var ForgeSelect = class {
276
361
  this.ajaxRequestId = 0;
277
362
  this.ajaxController = null;
278
363
  this.remoteLoaded = false;
364
+ this.remoteCache = new RemoteCache();
279
365
  this.loadError = null;
280
366
  this.originalDisplay = "";
281
367
  this.originalDisabled = false;
@@ -301,6 +387,7 @@ var ForgeSelect = class {
301
387
  this.control.setAttribute("aria-invalid", "true");
302
388
  if (!this.isOpen) this.open();
303
389
  this.control.focus();
390
+ this.emitter.emit("invalid", this.nativeSelect?.validationMessage ?? "");
304
391
  };
305
392
  this.onNativeChange = () => {
306
393
  if (!this.nativeSelect || this.destroyed || this.syncingNative) return;
@@ -339,11 +426,17 @@ var ForgeSelect = class {
339
426
  templateResult: options.templateResult,
340
427
  templateSelection: options.templateSelection,
341
428
  filterOption: options.filterOption,
429
+ searchFields: options.searchFields ?? ["label", "description"],
430
+ tokenSearch: options.tokenSearch ?? true,
431
+ accentInsensitive: options.accentInsensitive ?? true,
432
+ searchScorer: options.searchScorer,
433
+ highlightSearch: options.highlightSearch ?? false,
342
434
  minSearchLength: Math.max(0, Math.floor(options.minSearchLength ?? 0)),
343
435
  minResultsForSearch: Math.max(0, Math.floor(options.minResultsForSearch ?? 0)),
344
436
  isOptionDisabled: options.isOptionDisabled,
345
437
  virtualScroll: options.virtualScroll,
346
- itemHeight: options.itemHeight ?? DEFAULT_ITEM_HEIGHT,
438
+ itemHeight: typeof options.itemHeight === "number" ? Math.max(1, options.itemHeight) : DEFAULT_ITEM_HEIGHT,
439
+ variableItemHeight: options.itemHeight === "auto",
347
440
  language: options.language ?? "en",
348
441
  plugins: options.plugins ?? [],
349
442
  openOnFocus: options.openOnFocus ?? false,
@@ -367,6 +460,7 @@ var ForgeSelect = class {
367
460
  nativeSelect?.addEventListener("invalid", this.onNativeInvalid);
368
461
  this.nativeForm?.addEventListener("reset", this.onFormReset);
369
462
  for (const plugin of this.plugins) plugin.onInit?.(this);
463
+ for (const query of this.opts.ajax?.prefetch ?? []) void this.prefetchRemote(query);
370
464
  }
371
465
  applyNativeValues(values) {
372
466
  this.selected = [];
@@ -383,7 +477,7 @@ var ForgeSelect = class {
383
477
  this.root.classList.add("forge-select--open");
384
478
  this.control.setAttribute("aria-expanded", "true");
385
479
  document.addEventListener("mousedown", this.onDocumentMouseDown);
386
- if (this.opts.ajax && !this.remoteLoaded) {
480
+ if (this.opts.ajax && (this.opts.ajax.loadOnOpen ?? true) && !this.remoteLoaded) {
387
481
  this.scheduleRemoteLoad(this.query, 0);
388
482
  }
389
483
  this.renderList();
@@ -440,6 +534,8 @@ var ForgeSelect = class {
440
534
  this.nativeSelect?.removeEventListener("invalid", this.onNativeInvalid);
441
535
  this.nativeForm?.removeEventListener("reset", this.onFormReset);
442
536
  this.rowContentCache.clear();
537
+ this.rowHeightCache.clear();
538
+ this.searchIndex.clear();
443
539
  this.portalHost?.remove();
444
540
  this.root.remove();
445
541
  this.el.style.display = this.originalDisplay;
@@ -450,6 +546,106 @@ var ForgeSelect = class {
450
546
  if (this.opts.multiple) return [...this.selected];
451
547
  return this.selected[0] ?? null;
452
548
  }
549
+ getSearchQuery() {
550
+ return this.query;
551
+ }
552
+ setSearchQuery(query, options = {}) {
553
+ this.applySearchQuery(query, options.emitSearch ?? true);
554
+ }
555
+ isDropdownOpen() {
556
+ return this.isOpen;
557
+ }
558
+ updateOptions(options) {
559
+ if (options.data) this.setData(options.data);
560
+ if ("ajax" in options && options.ajax !== this.opts.ajax) {
561
+ this.opts.ajax = options.ajax;
562
+ this.remoteLoaded = false;
563
+ this.clearRemoteCache();
564
+ }
565
+ if (options.placeholder !== void 0) this.opts.placeholder = options.placeholder;
566
+ if (options.clearable !== void 0) this.opts.clearable = options.clearable;
567
+ if (options.allowCreate !== void 0) this.opts.allowCreate = options.allowCreate;
568
+ if (options.sortable !== void 0) this.opts.sortable = options.sortable;
569
+ if (options.closeOnSelect !== void 0) this.opts.closeOnSelect = options.closeOnSelect;
570
+ if ("maxSelections" in options)
571
+ this.opts.maxSelections = options.maxSelections == null || !Number.isFinite(options.maxSelections) ? void 0 : Math.max(0, Math.floor(options.maxSelections));
572
+ if (options.theme !== void 0) {
573
+ this.opts.theme = options.theme;
574
+ this.root.dataset.theme = options.theme;
575
+ if (this.portalHost) this.portalHost.dataset.theme = options.theme;
576
+ }
577
+ if (options.required !== void 0) {
578
+ this.opts.required = options.required;
579
+ if (options.required) this.control.setAttribute("aria-required", "true");
580
+ else this.control.removeAttribute("aria-required");
581
+ if (this.nativeSelect) this.nativeSelect.required = options.required;
582
+ }
583
+ if (options.templateResult !== void 0) this.opts.templateResult = options.templateResult;
584
+ if (options.templateSelection !== void 0) this.opts.templateSelection = options.templateSelection;
585
+ if (options.filterOption !== void 0) this.opts.filterOption = options.filterOption;
586
+ if (options.searchFields !== void 0) this.opts.searchFields = options.searchFields;
587
+ if (options.tokenSearch !== void 0) this.opts.tokenSearch = options.tokenSearch;
588
+ if (options.accentInsensitive !== void 0) this.opts.accentInsensitive = options.accentInsensitive;
589
+ if (options.searchScorer !== void 0) this.opts.searchScorer = options.searchScorer;
590
+ if (options.highlightSearch !== void 0) this.opts.highlightSearch = options.highlightSearch;
591
+ if (options.minSearchLength !== void 0)
592
+ this.opts.minSearchLength = Math.max(0, Math.floor(options.minSearchLength));
593
+ if (options.minResultsForSearch !== void 0)
594
+ this.opts.minResultsForSearch = Math.max(0, Math.floor(options.minResultsForSearch));
595
+ if (options.isOptionDisabled !== void 0) this.opts.isOptionDisabled = options.isOptionDisabled;
596
+ if (options.virtualScroll !== void 0) this.opts.virtualScroll = options.virtualScroll;
597
+ if (options.itemHeight !== void 0) {
598
+ this.opts.variableItemHeight = options.itemHeight === "auto";
599
+ if (typeof options.itemHeight === "number") this.opts.itemHeight = Math.max(1, options.itemHeight);
600
+ this.root.style.setProperty("--fs-item-height", `${this.opts.itemHeight}px`);
601
+ this.portalHost?.style.setProperty("--fs-item-height", `${this.opts.itemHeight}px`);
602
+ }
603
+ if (options.language !== void 0) {
604
+ this.opts.language = options.language;
605
+ this.strings = getStrings(options.language);
606
+ this.clearBtn.setAttribute("aria-label", this.strings.clearSelection);
607
+ this.searchInput?.setAttribute("aria-label", this.strings.search);
608
+ }
609
+ if (options.openOnFocus !== void 0) this.opts.openOnFocus = options.openOnFocus;
610
+ if (options.disabled !== void 0) {
611
+ if (options.disabled) this.disable();
612
+ else this.enable();
613
+ }
614
+ this.root.classList.toggle("forge-select--sortable", this.opts.sortable && this.opts.multiple);
615
+ this.updateSearchVisibility();
616
+ this.rowContentCache.clear();
617
+ this.searchIndex.clear();
618
+ this.renderValue();
619
+ if (this.isOpen) this.renderList();
620
+ }
621
+ validate() {
622
+ const valid = (!this.opts.required || this.selected.length > 0) && (this.control.dataset.validationMessage ?? "") === "";
623
+ this.control.classList.toggle("forge-select__control--invalid", !valid);
624
+ this.control.setAttribute("aria-invalid", String(!valid));
625
+ return valid;
626
+ }
627
+ setCustomValidity(message) {
628
+ this.nativeSelect?.setCustomValidity(message);
629
+ this.control.dataset.validationMessage = message;
630
+ }
631
+ reportValidity() {
632
+ const valid = this.validate() && (this.nativeSelect?.checkValidity() ?? true);
633
+ if (!valid) {
634
+ const message = this.nativeSelect?.validationMessage ?? this.control.dataset.validationMessage ?? "";
635
+ if (this.nativeSelect) return this.nativeSelect.reportValidity();
636
+ this.emitter.emit("invalid", message);
637
+ }
638
+ return valid;
639
+ }
640
+ reload() {
641
+ if (!this.opts.ajax) return;
642
+ this.clearRemoteCache();
643
+ this.remoteLoaded = false;
644
+ this.scheduleRemoteLoad(this.query, 0);
645
+ }
646
+ clearRemoteCache() {
647
+ this.remoteCache.clear();
648
+ }
453
649
  setValue(value, options = {}) {
454
650
  const values = value == null ? [] : Array.isArray(value) ? value : [value];
455
651
  const next = this.opts.multiple ? values : values.slice(0, 1);
@@ -472,7 +668,7 @@ var ForgeSelect = class {
472
668
  this.ajaxController?.abort();
473
669
  this.ajaxController = null;
474
670
  this.ajaxRequestId += 1;
475
- this.loading = false;
671
+ this.setLoading(false);
476
672
  this.loadingMore = false;
477
673
  this.loadError = null;
478
674
  this.remoteLoaded = true;
@@ -482,6 +678,7 @@ var ForgeSelect = class {
482
678
  this.opts.data = data;
483
679
  this.updateSearchVisibility();
484
680
  this.rowContentCache.clear();
681
+ this.searchIndex.clear();
485
682
  this.highlightedIndex = -1;
486
683
  if (this.isOpen) this.renderList();
487
684
  }
@@ -655,25 +852,7 @@ var ForgeSelect = class {
655
852
  });
656
853
  if (this.searchInput) {
657
854
  this.searchInput.addEventListener("input", () => {
658
- this.query = this.searchInput.value;
659
- this.highlightedIndex = -1;
660
- this.list.scrollTop = 0;
661
- this.emitter.emit("search", this.query);
662
- const trimmed = this.query.trim();
663
- const belowMinLength = trimmed !== "" && trimmed.length < this.opts.minSearchLength;
664
- if (this.opts.ajax && !belowMinLength) {
665
- this.scheduleRemoteLoad(this.query, this.opts.ajax.debounce ?? 250);
666
- } else {
667
- if (belowMinLength) {
668
- if (this.ajaxTimer) {
669
- clearTimeout(this.ajaxTimer);
670
- this.ajaxTimer = null;
671
- }
672
- this.ajaxController?.abort();
673
- this.loading = false;
674
- }
675
- this.renderList();
676
- }
855
+ this.applySearchQuery(this.searchInput.value, true);
677
856
  });
678
857
  this.searchInput.addEventListener("keydown", (event) => this.handleKeydown(event));
679
858
  this.searchInput.addEventListener("paste", (event) => {
@@ -724,6 +903,29 @@ var ForgeSelect = class {
724
903
  this.maybeLoadNextPage();
725
904
  });
726
905
  }
906
+ applySearchQuery(query, emitSearch) {
907
+ this.query = query;
908
+ if (this.searchInput && this.searchInput.value !== query) this.searchInput.value = query;
909
+ this.highlightedIndex = -1;
910
+ this.list.scrollTop = 0;
911
+ this.rowContentCache.clear();
912
+ if (emitSearch) this.emitter.emit("search", query);
913
+ const trimmed = query.trim();
914
+ const belowMinLength = trimmed !== "" && trimmed.length < this.opts.minSearchLength;
915
+ if (this.opts.ajax && !belowMinLength) {
916
+ this.scheduleRemoteLoad(query, this.opts.ajax.debounce ?? 250);
917
+ return;
918
+ }
919
+ if (belowMinLength) {
920
+ if (this.ajaxTimer) {
921
+ clearTimeout(this.ajaxTimer);
922
+ this.ajaxTimer = null;
923
+ }
924
+ this.ajaxController?.abort();
925
+ this.setLoading(false);
926
+ }
927
+ this.renderList();
928
+ }
727
929
  handleKeydown(event) {
728
930
  if (this.isDisabled) return;
729
931
  switch (event.key) {
@@ -1115,8 +1317,13 @@ var ForgeSelect = class {
1115
1317
  this.rows = [];
1116
1318
  this.navItems = [];
1117
1319
  const trimmedQuery = this.query.trim();
1118
- const query = trimmedQuery.toLowerCase();
1119
- const matches = (option) => query === "" || (this.opts.filterOption ? this.opts.filterOption(option, trimmedQuery) : option.label.toLowerCase().includes(query) || (option.description?.toLowerCase().includes(query) ?? false));
1320
+ const query = normalizeSearchText(trimmedQuery, this.opts.accentInsensitive);
1321
+ const matches = (option) => query === "" || (this.opts.filterOption ? this.opts.filterOption(option, trimmedQuery) : this.searchIndex.score(option, trimmedQuery, {
1322
+ fields: this.opts.searchFields,
1323
+ tokenSearch: this.opts.tokenSearch,
1324
+ accentInsensitive: this.opts.accentInsensitive,
1325
+ scorer: this.opts.searchScorer
1326
+ }) > 0);
1120
1327
  const subtreeMatches = (option) => query === "" || matches(option) || (option.children ?? []).some(subtreeMatches);
1121
1328
  const pushOption = (option, depth, parentValue) => {
1122
1329
  let navIndex = -1;
@@ -1172,6 +1379,25 @@ var ForgeSelect = class {
1172
1379
  usesVirtualScroll() {
1173
1380
  return this.opts.virtualScroll !== false && this.rows.length > VIRTUAL_THRESHOLD;
1174
1381
  }
1382
+ rowKey(row, index) {
1383
+ if (row.kind === "option") return `option:${row.option.value}`;
1384
+ if (row.kind === "group") return `group:${row.label}:${index}`;
1385
+ return `${row.kind}:${index}`;
1386
+ }
1387
+ measuredRowHeight(index) {
1388
+ return this.opts.variableItemHeight ? this.rowHeightCache.get(this.rowKey(this.rows[index], index)) ?? this.opts.itemHeight : this.opts.itemHeight;
1389
+ }
1390
+ rowOffset(index) {
1391
+ if (!this.opts.variableItemHeight) return index * this.opts.itemHeight;
1392
+ let offset = 0;
1393
+ for (let i = 0; i < index; i += 1) offset += this.measuredRowHeight(i);
1394
+ return offset;
1395
+ }
1396
+ rowOffsets() {
1397
+ const offsets = [0];
1398
+ for (let i = 0; i < this.rows.length; i += 1) offsets.push(offsets[i] + this.measuredRowHeight(i));
1399
+ return offsets;
1400
+ }
1175
1401
  renderList() {
1176
1402
  this.buildRows();
1177
1403
  this.renderRows();
@@ -1188,26 +1414,40 @@ var ForgeSelect = class {
1188
1414
  const virtual = this.usesVirtualScroll();
1189
1415
  this.list.textContent = "";
1190
1416
  const rowHeight = this.opts.itemHeight;
1417
+ const offsets = this.opts.variableItemHeight ? this.rowOffsets() : null;
1191
1418
  let start = 0;
1192
1419
  let end = this.rows.length;
1193
1420
  if (virtual) {
1194
1421
  const viewport = clientHeight || rowHeight * 8;
1195
- start = Math.max(0, Math.floor(scrollTop / rowHeight) - VIRTUAL_BUFFER);
1196
- end = Math.min(this.rows.length, start + Math.ceil(viewport / rowHeight) + VIRTUAL_BUFFER * 2);
1422
+ if (this.opts.variableItemHeight) {
1423
+ while (start < this.rows.length && offsets[start + 1] < scrollTop) start += 1;
1424
+ start = Math.max(0, start - VIRTUAL_BUFFER);
1425
+ end = start;
1426
+ const target = scrollTop + viewport + VIRTUAL_BUFFER * rowHeight;
1427
+ while (end < this.rows.length && offsets[end] < target) end += 1;
1428
+ } else {
1429
+ start = Math.max(0, Math.floor(scrollTop / rowHeight) - VIRTUAL_BUFFER);
1430
+ end = Math.min(this.rows.length, start + Math.ceil(viewport / rowHeight) + VIRTUAL_BUFFER * 2);
1431
+ }
1197
1432
  const topSpacer = document.createElement("li");
1198
1433
  topSpacer.className = "forge-select__spacer";
1199
1434
  topSpacer.setAttribute("aria-hidden", "true");
1200
- topSpacer.style.height = `${start * rowHeight}px`;
1435
+ topSpacer.style.height = `${offsets?.[start] ?? this.rowOffset(start)}px`;
1201
1436
  this.list.append(topSpacer);
1202
1437
  }
1203
1438
  for (let i = start; i < end; i++) {
1204
- this.list.append(this.renderRow(this.rows[i]));
1439
+ const element = this.renderRow(this.rows[i]);
1440
+ this.list.append(element);
1441
+ if (this.opts.variableItemHeight) {
1442
+ const measured = element.getBoundingClientRect().height || element.offsetHeight;
1443
+ if (measured > 0) this.rowHeightCache.set(this.rowKey(this.rows[i], i), measured);
1444
+ }
1205
1445
  }
1206
1446
  if (virtual) {
1207
1447
  const bottomSpacer = document.createElement("li");
1208
1448
  bottomSpacer.className = "forge-select__spacer";
1209
1449
  bottomSpacer.setAttribute("aria-hidden", "true");
1210
- bottomSpacer.style.height = `${(this.rows.length - end) * rowHeight}px`;
1450
+ bottomSpacer.style.height = `${offsets ? offsets[this.rows.length] - offsets[end] : this.rowOffset(this.rows.length) - this.rowOffset(end)}px`;
1211
1451
  this.list.append(bottomSpacer);
1212
1452
  if (this.list.scrollTop !== scrollTop) {
1213
1453
  this.list.scrollTop = scrollTop;
@@ -1274,6 +1514,7 @@ var ForgeSelect = class {
1274
1514
  if (isSelected) li.classList.add("forge-select__option--selected");
1275
1515
  if (this.opts.multiple && row.hasChildren && computeCheckState(row.option, this.selected, this.isOptionDisabled) === "some") {
1276
1516
  li.classList.add("forge-select__option--indeterminate");
1517
+ li.dataset.selectionState = "mixed";
1277
1518
  }
1278
1519
  if (row.depth > 0) {
1279
1520
  li.style.paddingLeft = `calc(12px + ${row.depth} * var(--fs-tree-indent, 18px))`;
@@ -1309,6 +1550,28 @@ var ForgeSelect = class {
1309
1550
  * cached content state-free.
1310
1551
  */
1311
1552
  optionContent(option) {
1553
+ if (this.opts.highlightSearch && this.query.trim() && !this.opts.templateResult) {
1554
+ const holder = document.createElement("span");
1555
+ holder.className = "forge-select__option-content";
1556
+ renderOptionContent(holder, option, void 0);
1557
+ const label = holder.querySelector(".forge-select__option-label") ?? holder;
1558
+ const ranges = findNormalizedRanges(option.label, this.query, this.opts.accentInsensitive);
1559
+ if (ranges.length) {
1560
+ label.textContent = "";
1561
+ let cursor = 0;
1562
+ for (const [start, end] of ranges) {
1563
+ if (start < cursor) continue;
1564
+ label.append(document.createTextNode(option.label.slice(cursor, start)));
1565
+ const mark = document.createElement("mark");
1566
+ mark.className = "forge-select__match";
1567
+ mark.textContent = option.label.slice(start, end);
1568
+ label.append(mark);
1569
+ cursor = end;
1570
+ }
1571
+ label.append(document.createTextNode(option.label.slice(cursor)));
1572
+ }
1573
+ return holder;
1574
+ }
1312
1575
  let cached = this.rowContentCache.get(option.value);
1313
1576
  if (!cached) {
1314
1577
  const holder = document.createElement("span");
@@ -1335,8 +1598,8 @@ var ForgeSelect = class {
1335
1598
  (row) => (row.kind === "option" || row.kind === "create") && row.navIndex === next
1336
1599
  );
1337
1600
  if (rowIndex >= 0) {
1338
- const rowHeight = this.opts.itemHeight;
1339
- const top = rowIndex * rowHeight;
1601
+ const rowHeight = this.measuredRowHeight(rowIndex);
1602
+ const top = this.rowOffset(rowIndex);
1340
1603
  const viewport = this.list.clientHeight || rowHeight * 8;
1341
1604
  let target = this.list.scrollTop;
1342
1605
  if (top < target) target = top;
@@ -1401,7 +1664,7 @@ var ForgeSelect = class {
1401
1664
  this.ajaxController = null;
1402
1665
  this.page = 0;
1403
1666
  this.hasMore = true;
1404
- this.loading = true;
1667
+ this.setLoading(true);
1405
1668
  this.loadingMore = false;
1406
1669
  this.loadError = null;
1407
1670
  this.renderList();
@@ -1410,6 +1673,55 @@ var ForgeSelect = class {
1410
1673
  void this.loadRemote(query, { requestId });
1411
1674
  }, delay);
1412
1675
  }
1676
+ setLoading(loading) {
1677
+ if (this.loading === loading) return;
1678
+ this.loading = loading;
1679
+ this.emitter.emit("loading", loading);
1680
+ }
1681
+ remoteCacheKey(query, page) {
1682
+ return `${query}\0${page}`;
1683
+ }
1684
+ async requestRemote(query, page, signal) {
1685
+ const ajax = this.opts.ajax;
1686
+ const attempts = Math.max(0, Math.floor(ajax.retry ?? 0)) + 1;
1687
+ let lastError;
1688
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
1689
+ try {
1690
+ if (ajax.request) return await ajax.request(query, page, signal);
1691
+ const response = await fetch(buildUrl(ajax, query, page), { signal });
1692
+ if (response.ok === false) throw new Error(`ForgeSelect: remote request failed with HTTP ${response.status}`);
1693
+ return await response.json();
1694
+ } catch (error) {
1695
+ lastError = error;
1696
+ if (signal.aborted || attempt === attempts - 1) throw error;
1697
+ const delay = Math.max(0, ajax.retryDelay ?? 250) * 2 ** attempt;
1698
+ await new Promise((resolve, reject) => {
1699
+ const timer = setTimeout(resolve, delay);
1700
+ signal.addEventListener(
1701
+ "abort",
1702
+ () => {
1703
+ clearTimeout(timer);
1704
+ reject(new DOMException("Aborted", "AbortError"));
1705
+ },
1706
+ { once: true }
1707
+ );
1708
+ });
1709
+ }
1710
+ }
1711
+ throw lastError;
1712
+ }
1713
+ async prefetchRemote(query) {
1714
+ const ajax = this.opts.ajax;
1715
+ if (!ajax || (ajax.cacheTtl ?? 3e4) <= 0) return;
1716
+ const key = this.remoteCacheKey(query, 0);
1717
+ if (this.remoteCache.get(key)) return;
1718
+ const controller = new AbortController();
1719
+ try {
1720
+ const json = await this.requestRemote(query, 0, controller.signal);
1721
+ this.remoteCache.set(key, normalizeRemoteResult(ajax, json), ajax.cacheTtl ?? 3e4);
1722
+ } catch {
1723
+ }
1724
+ }
1413
1725
  /**
1414
1726
  * Fires on every list scroll. Only acts when pagination is opted into via
1415
1727
  * `ajax.pagination`; reads real scroll geometry rather than row counts so
@@ -1434,17 +1746,15 @@ var ForgeSelect = class {
1434
1746
  this.ajaxController = controller;
1435
1747
  const page = append ? this.page + 1 : 0;
1436
1748
  try {
1437
- let json;
1438
- if (ajax.request) {
1439
- json = await ajax.request(query, page, controller.signal);
1440
- } else {
1441
- const url = buildUrl(ajax, query, page);
1442
- const response = await fetch(url, { signal: controller.signal });
1443
- if (response.ok === false) throw new Error(`ForgeSelect: remote request failed with HTTP ${response.status}`);
1444
- json = await response.json();
1749
+ const key = this.remoteCacheKey(query, page);
1750
+ let result = this.remoteCache.get(key);
1751
+ if (!result) {
1752
+ const json = await this.requestRemote(query, page, controller.signal);
1753
+ result = normalizeRemoteResult(ajax, json);
1754
+ this.remoteCache.set(key, result, ajax.cacheTtl ?? 3e4);
1445
1755
  }
1446
1756
  if (activeRequestId !== this.ajaxRequestId || this.destroyed) return;
1447
- const { options, hasMore } = normalizeRemoteResult(ajax, json);
1757
+ const { options, hasMore } = result;
1448
1758
  if (append) {
1449
1759
  const existing = collectValues(this.data);
1450
1760
  this.data = [...this.data, ...options.filter((o) => !existing.has(o.value))];
@@ -1469,7 +1779,7 @@ var ForgeSelect = class {
1469
1779
  } finally {
1470
1780
  if (activeRequestId === this.ajaxRequestId && !this.destroyed) {
1471
1781
  this.ajaxController = null;
1472
- this.loading = false;
1782
+ this.setLoading(false);
1473
1783
  this.loadingMore = false;
1474
1784
  if (this.isOpen) this.renderList();
1475
1785
  }