forge-select 0.5.0 → 0.6.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
@@ -184,6 +184,7 @@ function normalizeRemoteResult(ajax, response) {
184
184
  }
185
185
 
186
186
  // src/remote-cache.ts
187
+ var REMOTE_CACHE_LIMIT = 50;
187
188
  var RemoteCache = class {
188
189
  constructor() {
189
190
  this.entries = /* @__PURE__ */ new Map();
@@ -198,7 +199,12 @@ var RemoteCache = class {
198
199
  return entry.value;
199
200
  }
200
201
  set(key, value, ttl, now = Date.now()) {
201
- if (ttl > 0) this.entries.set(key, { value, expiresAt: now + ttl });
202
+ if (ttl <= 0) return;
203
+ if (this.entries.size >= REMOTE_CACHE_LIMIT && !this.entries.has(key)) {
204
+ const oldest = this.entries.keys().next().value;
205
+ this.entries.delete(oldest);
206
+ }
207
+ this.entries.set(key, { value, expiresAt: now + ttl });
202
208
  }
203
209
  clear() {
204
210
  this.entries.clear();
@@ -332,6 +338,8 @@ var DEFAULT_ITEM_HEIGHT = 36;
332
338
  var VIRTUAL_BUFFER = 5;
333
339
  var VIRTUAL_THRESHOLD = 100;
334
340
  var ROW_CACHE_LIMIT = 2e3;
341
+ var PAGE_SIZE = 10;
342
+ var TYPEAHEAD_RESET_MS = 500;
335
343
  var uidCounter = 0;
336
344
  var ForgeSelect = class {
337
345
  constructor(target, options = {}) {
@@ -349,8 +357,13 @@ var ForgeSelect = class {
349
357
  this.rows = [];
350
358
  this.navItems = [];
351
359
  this.highlightedIndex = -1;
360
+ this.typeaheadBuffer = "";
361
+ this.typeaheadTimer = null;
352
362
  this.rowContentCache = /* @__PURE__ */ new Map();
353
363
  this.rowHeightCache = /* @__PURE__ */ new Map();
364
+ this.rowOffsetsCache = null;
365
+ this.scrollRafId = null;
366
+ this.ancestorScrollRafId = null;
354
367
  this.searchIndex = new SearchIndex();
355
368
  this.expandedValues = /* @__PURE__ */ new Set();
356
369
  this.loading = false;
@@ -379,7 +392,12 @@ var ForgeSelect = class {
379
392
  this.positionDropdown();
380
393
  };
381
394
  this.onAncestorScroll = () => {
382
- if (this.portalHost) this.positionDropdown();
395
+ if (!this.portalHost) return;
396
+ if (this.ancestorScrollRafId != null) return;
397
+ this.ancestorScrollRafId = requestAnimationFrame(() => {
398
+ this.ancestorScrollRafId = null;
399
+ this.positionDropdown();
400
+ });
383
401
  };
384
402
  this.onNativeInvalid = (event) => {
385
403
  event.preventDefault();
@@ -498,6 +516,19 @@ var ForgeSelect = class {
498
516
  document.removeEventListener("mousedown", this.onDocumentMouseDown);
499
517
  window.removeEventListener("resize", this.onWindowResize);
500
518
  document.removeEventListener("scroll", this.onAncestorScroll, true);
519
+ if (this.ancestorScrollRafId != null) {
520
+ cancelAnimationFrame(this.ancestorScrollRafId);
521
+ this.ancestorScrollRafId = null;
522
+ }
523
+ if (this.scrollRafId != null) {
524
+ cancelAnimationFrame(this.scrollRafId);
525
+ this.scrollRafId = null;
526
+ }
527
+ if (this.typeaheadTimer) {
528
+ clearTimeout(this.typeaheadTimer);
529
+ this.typeaheadTimer = null;
530
+ }
531
+ this.typeaheadBuffer = "";
501
532
  this.highlightedIndex = -1;
502
533
  if (this.searchInput) {
503
534
  this.searchInput.value = "";
@@ -530,6 +561,8 @@ var ForgeSelect = class {
530
561
  this.destroyed = true;
531
562
  if (this.ajaxTimer) clearTimeout(this.ajaxTimer);
532
563
  this.ajaxController?.abort();
564
+ if (this.scrollRafId != null) cancelAnimationFrame(this.scrollRafId);
565
+ if (this.typeaheadTimer) clearTimeout(this.typeaheadTimer);
533
566
  this.nativeSelect?.removeEventListener("change", this.onNativeChange);
534
567
  this.nativeSelect?.removeEventListener("invalid", this.onNativeInvalid);
535
568
  this.nativeForm?.removeEventListener("reset", this.onFormReset);
@@ -614,6 +647,7 @@ var ForgeSelect = class {
614
647
  this.root.classList.toggle("forge-select--sortable", this.opts.sortable && this.opts.multiple);
615
648
  this.updateSearchVisibility();
616
649
  this.rowContentCache.clear();
650
+ this.rowHeightCache.clear();
617
651
  this.searchIndex.clear();
618
652
  this.renderValue();
619
653
  if (this.isOpen) this.renderList();
@@ -678,6 +712,7 @@ var ForgeSelect = class {
678
712
  this.opts.data = data;
679
713
  this.updateSearchVisibility();
680
714
  this.rowContentCache.clear();
715
+ this.rowHeightCache.clear();
681
716
  this.searchIndex.clear();
682
717
  this.highlightedIndex = -1;
683
718
  if (this.isOpen) this.renderList();
@@ -899,8 +934,12 @@ var ForgeSelect = class {
899
934
  this.activateNavItem(navIndex);
900
935
  });
901
936
  this.list.addEventListener("scroll", () => {
902
- if (this.usesVirtualScroll()) this.renderRows();
903
- this.maybeLoadNextPage();
937
+ if (this.scrollRafId != null) return;
938
+ this.scrollRafId = requestAnimationFrame(() => {
939
+ this.scrollRafId = null;
940
+ if (this.usesVirtualScroll()) this.renderRows();
941
+ this.maybeLoadNextPage();
942
+ });
904
943
  });
905
944
  }
906
945
  applySearchQuery(query, emitSearch) {
@@ -908,7 +947,6 @@ var ForgeSelect = class {
908
947
  if (this.searchInput && this.searchInput.value !== query) this.searchInput.value = query;
909
948
  this.highlightedIndex = -1;
910
949
  this.list.scrollTop = 0;
911
- this.rowContentCache.clear();
912
950
  if (emitSearch) this.emitter.emit("search", query);
913
951
  const trimmed = query.trim();
914
952
  const belowMinLength = trimmed !== "" && trimmed.length < this.opts.minSearchLength;
@@ -962,9 +1000,63 @@ var ForgeSelect = class {
962
1000
  case "ArrowLeft":
963
1001
  if (this.isOpen && this.navigateTree("left")) event.preventDefault();
964
1002
  break;
1003
+ case "Home":
1004
+ if (this.isOpen) {
1005
+ event.preventDefault();
1006
+ this.focusNavIndex(0);
1007
+ }
1008
+ break;
1009
+ case "End":
1010
+ if (this.isOpen) {
1011
+ event.preventDefault();
1012
+ this.focusNavIndex(this.navItems.length - 1);
1013
+ }
1014
+ break;
1015
+ case "PageDown":
1016
+ if (this.isOpen) {
1017
+ event.preventDefault();
1018
+ this.focusNavIndex(
1019
+ Math.min(this.navItems.length - 1, (this.highlightedIndex === -1 ? 0 : this.highlightedIndex) + PAGE_SIZE)
1020
+ );
1021
+ }
1022
+ break;
1023
+ case "PageUp":
1024
+ if (this.isOpen) {
1025
+ event.preventDefault();
1026
+ this.focusNavIndex(Math.max(0, (this.highlightedIndex === -1 ? 0 : this.highlightedIndex) - PAGE_SIZE));
1027
+ }
1028
+ break;
965
1029
  case "Tab":
966
1030
  this.close();
967
1031
  break;
1032
+ default:
1033
+ if (this.isOpen && event.target === this.control && event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
1034
+ this.handleTypeahead(event.key);
1035
+ }
1036
+ break;
1037
+ }
1038
+ }
1039
+ /**
1040
+ * Jumps the highlight to the next nav item (wrapping) whose label starts
1041
+ * with the accumulated buffer, matching native <select> typeahead: rapid
1042
+ * distinct keystrokes narrow the prefix, a pause resets it.
1043
+ */
1044
+ handleTypeahead(char) {
1045
+ if (this.typeaheadTimer) clearTimeout(this.typeaheadTimer);
1046
+ this.typeaheadBuffer += normalizeSearchText(char, this.opts.accentInsensitive);
1047
+ this.typeaheadTimer = setTimeout(() => {
1048
+ this.typeaheadBuffer = "";
1049
+ this.typeaheadTimer = null;
1050
+ }, TYPEAHEAD_RESET_MS);
1051
+ const prefix = [...this.typeaheadBuffer].every((value) => value === this.typeaheadBuffer[0]) ? this.typeaheadBuffer[0] : this.typeaheadBuffer;
1052
+ const count = this.navItems.length;
1053
+ for (let step = 1; step <= count; step += 1) {
1054
+ const index = (this.highlightedIndex + step + count) % count;
1055
+ const item = this.navItems[index];
1056
+ if (item.kind === "option" && normalizeSearchText(item.option.label, this.opts.accentInsensitive).startsWith(prefix)) {
1057
+ this.focusNavIndex(index);
1058
+ return;
1059
+ }
968
1060
  }
969
1061
  }
970
1062
  // ---------------------------------------------------------------- selection
@@ -1055,7 +1147,10 @@ var ForgeSelect = class {
1055
1147
  this.control.classList.remove("forge-select__control--invalid");
1056
1148
  this.control.removeAttribute("aria-invalid");
1057
1149
  }
1058
- if (this.isOpen) this.renderList();
1150
+ if (this.isOpen) {
1151
+ if (this.opts.maxSelections != null) this.renderList();
1152
+ else this.renderRows();
1153
+ }
1059
1154
  if (emitChange) this.emitter.emit("change", this.getValue());
1060
1155
  }
1061
1156
  syncNativeSelect(dispatchChange = true) {
@@ -1142,6 +1237,7 @@ var ForgeSelect = class {
1142
1237
  if (result.created) this.emitter.emit("create", result.option);
1143
1238
  this.emitter.emit("select", result.option);
1144
1239
  if (!this.opts.multiple || this.opts.closeOnSelect) this.close();
1240
+ else if (this.isOpen) this.renderList();
1145
1241
  }
1146
1242
  activateNavItem(navIndex) {
1147
1243
  const item = this.navItems[navIndex];
@@ -1316,6 +1412,7 @@ var ForgeSelect = class {
1316
1412
  buildRows() {
1317
1413
  this.rows = [];
1318
1414
  this.navItems = [];
1415
+ this.rowOffsetsCache = null;
1319
1416
  const trimmedQuery = this.query.trim();
1320
1417
  const query = normalizeSearchText(trimmedQuery, this.opts.accentInsensitive);
1321
1418
  const matches = (option) => query === "" || (this.opts.filterOption ? this.opts.filterOption(option, trimmedQuery) : this.searchIndex.score(option, trimmedQuery, {
@@ -1394,8 +1491,10 @@ var ForgeSelect = class {
1394
1491
  return offset;
1395
1492
  }
1396
1493
  rowOffsets() {
1494
+ if (this.rowOffsetsCache) return this.rowOffsetsCache;
1397
1495
  const offsets = [0];
1398
1496
  for (let i = 0; i < this.rows.length; i += 1) offsets.push(offsets[i] + this.measuredRowHeight(i));
1497
+ this.rowOffsetsCache = offsets;
1399
1498
  return offsets;
1400
1499
  }
1401
1500
  renderList() {
@@ -1435,12 +1534,21 @@ var ForgeSelect = class {
1435
1534
  topSpacer.style.height = `${offsets?.[start] ?? this.rowOffset(start)}px`;
1436
1535
  this.list.append(topSpacer);
1437
1536
  }
1537
+ const appended = [];
1438
1538
  for (let i = start; i < end; i++) {
1439
1539
  const element = this.renderRow(this.rows[i]);
1440
1540
  this.list.append(element);
1441
- if (this.opts.variableItemHeight) {
1541
+ appended.push(element);
1542
+ }
1543
+ if (this.opts.variableItemHeight) {
1544
+ for (let i = start; i < end; i++) {
1545
+ const element = appended[i - start];
1442
1546
  const measured = element.getBoundingClientRect().height || element.offsetHeight;
1443
- if (measured > 0) this.rowHeightCache.set(this.rowKey(this.rows[i], i), measured);
1547
+ if (measured > 0) {
1548
+ const key = this.rowKey(this.rows[i], i);
1549
+ if (this.rowHeightCache.get(key) !== measured) this.rowOffsetsCache = null;
1550
+ this.rowHeightCache.set(key, measured);
1551
+ }
1444
1552
  }
1445
1553
  }
1446
1554
  if (virtual) {
@@ -1592,6 +1700,7 @@ var ForgeSelect = class {
1592
1700
  this.focusNavIndex(next);
1593
1701
  }
1594
1702
  focusNavIndex(next) {
1703
+ if (this.navItems.length === 0) return;
1595
1704
  this.highlightedIndex = next;
1596
1705
  if (this.usesVirtualScroll()) {
1597
1706
  const rowIndex = this.rows.findIndex(
@@ -1761,6 +1870,7 @@ var ForgeSelect = class {
1761
1870
  } else {
1762
1871
  this.data = options;
1763
1872
  this.rowContentCache.clear();
1873
+ this.rowHeightCache.clear();
1764
1874
  }
1765
1875
  this.page = page;
1766
1876
  this.hasMore = hasMore;
@@ -1772,6 +1882,7 @@ var ForgeSelect = class {
1772
1882
  if (!append) {
1773
1883
  this.data = [];
1774
1884
  this.rowContentCache.clear();
1885
+ this.rowHeightCache.clear();
1775
1886
  }
1776
1887
  this.hasMore = false;
1777
1888
  this.loadError = error;