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.js CHANGED
@@ -157,6 +157,7 @@ function normalizeRemoteResult(ajax, response) {
157
157
  }
158
158
 
159
159
  // src/remote-cache.ts
160
+ var REMOTE_CACHE_LIMIT = 50;
160
161
  var RemoteCache = class {
161
162
  constructor() {
162
163
  this.entries = /* @__PURE__ */ new Map();
@@ -171,7 +172,12 @@ var RemoteCache = class {
171
172
  return entry.value;
172
173
  }
173
174
  set(key, value, ttl, now = Date.now()) {
174
- if (ttl > 0) this.entries.set(key, { value, expiresAt: now + ttl });
175
+ if (ttl <= 0) return;
176
+ if (this.entries.size >= REMOTE_CACHE_LIMIT && !this.entries.has(key)) {
177
+ const oldest = this.entries.keys().next().value;
178
+ this.entries.delete(oldest);
179
+ }
180
+ this.entries.set(key, { value, expiresAt: now + ttl });
175
181
  }
176
182
  clear() {
177
183
  this.entries.clear();
@@ -305,6 +311,8 @@ var DEFAULT_ITEM_HEIGHT = 36;
305
311
  var VIRTUAL_BUFFER = 5;
306
312
  var VIRTUAL_THRESHOLD = 100;
307
313
  var ROW_CACHE_LIMIT = 2e3;
314
+ var PAGE_SIZE = 10;
315
+ var TYPEAHEAD_RESET_MS = 500;
308
316
  var uidCounter = 0;
309
317
  var ForgeSelect = class {
310
318
  constructor(target, options = {}) {
@@ -322,8 +330,13 @@ var ForgeSelect = class {
322
330
  this.rows = [];
323
331
  this.navItems = [];
324
332
  this.highlightedIndex = -1;
333
+ this.typeaheadBuffer = "";
334
+ this.typeaheadTimer = null;
325
335
  this.rowContentCache = /* @__PURE__ */ new Map();
326
336
  this.rowHeightCache = /* @__PURE__ */ new Map();
337
+ this.rowOffsetsCache = null;
338
+ this.scrollRafId = null;
339
+ this.ancestorScrollRafId = null;
327
340
  this.searchIndex = new SearchIndex();
328
341
  this.expandedValues = /* @__PURE__ */ new Set();
329
342
  this.loading = false;
@@ -352,7 +365,12 @@ var ForgeSelect = class {
352
365
  this.positionDropdown();
353
366
  };
354
367
  this.onAncestorScroll = () => {
355
- if (this.portalHost) this.positionDropdown();
368
+ if (!this.portalHost) return;
369
+ if (this.ancestorScrollRafId != null) return;
370
+ this.ancestorScrollRafId = requestAnimationFrame(() => {
371
+ this.ancestorScrollRafId = null;
372
+ this.positionDropdown();
373
+ });
356
374
  };
357
375
  this.onNativeInvalid = (event) => {
358
376
  event.preventDefault();
@@ -471,6 +489,19 @@ var ForgeSelect = class {
471
489
  document.removeEventListener("mousedown", this.onDocumentMouseDown);
472
490
  window.removeEventListener("resize", this.onWindowResize);
473
491
  document.removeEventListener("scroll", this.onAncestorScroll, true);
492
+ if (this.ancestorScrollRafId != null) {
493
+ cancelAnimationFrame(this.ancestorScrollRafId);
494
+ this.ancestorScrollRafId = null;
495
+ }
496
+ if (this.scrollRafId != null) {
497
+ cancelAnimationFrame(this.scrollRafId);
498
+ this.scrollRafId = null;
499
+ }
500
+ if (this.typeaheadTimer) {
501
+ clearTimeout(this.typeaheadTimer);
502
+ this.typeaheadTimer = null;
503
+ }
504
+ this.typeaheadBuffer = "";
474
505
  this.highlightedIndex = -1;
475
506
  if (this.searchInput) {
476
507
  this.searchInput.value = "";
@@ -503,6 +534,8 @@ var ForgeSelect = class {
503
534
  this.destroyed = true;
504
535
  if (this.ajaxTimer) clearTimeout(this.ajaxTimer);
505
536
  this.ajaxController?.abort();
537
+ if (this.scrollRafId != null) cancelAnimationFrame(this.scrollRafId);
538
+ if (this.typeaheadTimer) clearTimeout(this.typeaheadTimer);
506
539
  this.nativeSelect?.removeEventListener("change", this.onNativeChange);
507
540
  this.nativeSelect?.removeEventListener("invalid", this.onNativeInvalid);
508
541
  this.nativeForm?.removeEventListener("reset", this.onFormReset);
@@ -587,6 +620,7 @@ var ForgeSelect = class {
587
620
  this.root.classList.toggle("forge-select--sortable", this.opts.sortable && this.opts.multiple);
588
621
  this.updateSearchVisibility();
589
622
  this.rowContentCache.clear();
623
+ this.rowHeightCache.clear();
590
624
  this.searchIndex.clear();
591
625
  this.renderValue();
592
626
  if (this.isOpen) this.renderList();
@@ -651,6 +685,7 @@ var ForgeSelect = class {
651
685
  this.opts.data = data;
652
686
  this.updateSearchVisibility();
653
687
  this.rowContentCache.clear();
688
+ this.rowHeightCache.clear();
654
689
  this.searchIndex.clear();
655
690
  this.highlightedIndex = -1;
656
691
  if (this.isOpen) this.renderList();
@@ -872,8 +907,12 @@ var ForgeSelect = class {
872
907
  this.activateNavItem(navIndex);
873
908
  });
874
909
  this.list.addEventListener("scroll", () => {
875
- if (this.usesVirtualScroll()) this.renderRows();
876
- this.maybeLoadNextPage();
910
+ if (this.scrollRafId != null) return;
911
+ this.scrollRafId = requestAnimationFrame(() => {
912
+ this.scrollRafId = null;
913
+ if (this.usesVirtualScroll()) this.renderRows();
914
+ this.maybeLoadNextPage();
915
+ });
877
916
  });
878
917
  }
879
918
  applySearchQuery(query, emitSearch) {
@@ -881,7 +920,6 @@ var ForgeSelect = class {
881
920
  if (this.searchInput && this.searchInput.value !== query) this.searchInput.value = query;
882
921
  this.highlightedIndex = -1;
883
922
  this.list.scrollTop = 0;
884
- this.rowContentCache.clear();
885
923
  if (emitSearch) this.emitter.emit("search", query);
886
924
  const trimmed = query.trim();
887
925
  const belowMinLength = trimmed !== "" && trimmed.length < this.opts.minSearchLength;
@@ -935,9 +973,63 @@ var ForgeSelect = class {
935
973
  case "ArrowLeft":
936
974
  if (this.isOpen && this.navigateTree("left")) event.preventDefault();
937
975
  break;
976
+ case "Home":
977
+ if (this.isOpen) {
978
+ event.preventDefault();
979
+ this.focusNavIndex(0);
980
+ }
981
+ break;
982
+ case "End":
983
+ if (this.isOpen) {
984
+ event.preventDefault();
985
+ this.focusNavIndex(this.navItems.length - 1);
986
+ }
987
+ break;
988
+ case "PageDown":
989
+ if (this.isOpen) {
990
+ event.preventDefault();
991
+ this.focusNavIndex(
992
+ Math.min(this.navItems.length - 1, (this.highlightedIndex === -1 ? 0 : this.highlightedIndex) + PAGE_SIZE)
993
+ );
994
+ }
995
+ break;
996
+ case "PageUp":
997
+ if (this.isOpen) {
998
+ event.preventDefault();
999
+ this.focusNavIndex(Math.max(0, (this.highlightedIndex === -1 ? 0 : this.highlightedIndex) - PAGE_SIZE));
1000
+ }
1001
+ break;
938
1002
  case "Tab":
939
1003
  this.close();
940
1004
  break;
1005
+ default:
1006
+ if (this.isOpen && event.target === this.control && event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
1007
+ this.handleTypeahead(event.key);
1008
+ }
1009
+ break;
1010
+ }
1011
+ }
1012
+ /**
1013
+ * Jumps the highlight to the next nav item (wrapping) whose label starts
1014
+ * with the accumulated buffer, matching native <select> typeahead: rapid
1015
+ * distinct keystrokes narrow the prefix, a pause resets it.
1016
+ */
1017
+ handleTypeahead(char) {
1018
+ if (this.typeaheadTimer) clearTimeout(this.typeaheadTimer);
1019
+ this.typeaheadBuffer += normalizeSearchText(char, this.opts.accentInsensitive);
1020
+ this.typeaheadTimer = setTimeout(() => {
1021
+ this.typeaheadBuffer = "";
1022
+ this.typeaheadTimer = null;
1023
+ }, TYPEAHEAD_RESET_MS);
1024
+ const prefix = [...this.typeaheadBuffer].every((value) => value === this.typeaheadBuffer[0]) ? this.typeaheadBuffer[0] : this.typeaheadBuffer;
1025
+ const count = this.navItems.length;
1026
+ for (let step = 1; step <= count; step += 1) {
1027
+ const index = (this.highlightedIndex + step + count) % count;
1028
+ const item = this.navItems[index];
1029
+ if (item.kind === "option" && normalizeSearchText(item.option.label, this.opts.accentInsensitive).startsWith(prefix)) {
1030
+ this.focusNavIndex(index);
1031
+ return;
1032
+ }
941
1033
  }
942
1034
  }
943
1035
  // ---------------------------------------------------------------- selection
@@ -1028,7 +1120,10 @@ var ForgeSelect = class {
1028
1120
  this.control.classList.remove("forge-select__control--invalid");
1029
1121
  this.control.removeAttribute("aria-invalid");
1030
1122
  }
1031
- if (this.isOpen) this.renderList();
1123
+ if (this.isOpen) {
1124
+ if (this.opts.maxSelections != null) this.renderList();
1125
+ else this.renderRows();
1126
+ }
1032
1127
  if (emitChange) this.emitter.emit("change", this.getValue());
1033
1128
  }
1034
1129
  syncNativeSelect(dispatchChange = true) {
@@ -1115,6 +1210,7 @@ var ForgeSelect = class {
1115
1210
  if (result.created) this.emitter.emit("create", result.option);
1116
1211
  this.emitter.emit("select", result.option);
1117
1212
  if (!this.opts.multiple || this.opts.closeOnSelect) this.close();
1213
+ else if (this.isOpen) this.renderList();
1118
1214
  }
1119
1215
  activateNavItem(navIndex) {
1120
1216
  const item = this.navItems[navIndex];
@@ -1289,6 +1385,7 @@ var ForgeSelect = class {
1289
1385
  buildRows() {
1290
1386
  this.rows = [];
1291
1387
  this.navItems = [];
1388
+ this.rowOffsetsCache = null;
1292
1389
  const trimmedQuery = this.query.trim();
1293
1390
  const query = normalizeSearchText(trimmedQuery, this.opts.accentInsensitive);
1294
1391
  const matches = (option) => query === "" || (this.opts.filterOption ? this.opts.filterOption(option, trimmedQuery) : this.searchIndex.score(option, trimmedQuery, {
@@ -1367,8 +1464,10 @@ var ForgeSelect = class {
1367
1464
  return offset;
1368
1465
  }
1369
1466
  rowOffsets() {
1467
+ if (this.rowOffsetsCache) return this.rowOffsetsCache;
1370
1468
  const offsets = [0];
1371
1469
  for (let i = 0; i < this.rows.length; i += 1) offsets.push(offsets[i] + this.measuredRowHeight(i));
1470
+ this.rowOffsetsCache = offsets;
1372
1471
  return offsets;
1373
1472
  }
1374
1473
  renderList() {
@@ -1408,12 +1507,21 @@ var ForgeSelect = class {
1408
1507
  topSpacer.style.height = `${offsets?.[start] ?? this.rowOffset(start)}px`;
1409
1508
  this.list.append(topSpacer);
1410
1509
  }
1510
+ const appended = [];
1411
1511
  for (let i = start; i < end; i++) {
1412
1512
  const element = this.renderRow(this.rows[i]);
1413
1513
  this.list.append(element);
1414
- if (this.opts.variableItemHeight) {
1514
+ appended.push(element);
1515
+ }
1516
+ if (this.opts.variableItemHeight) {
1517
+ for (let i = start; i < end; i++) {
1518
+ const element = appended[i - start];
1415
1519
  const measured = element.getBoundingClientRect().height || element.offsetHeight;
1416
- if (measured > 0) this.rowHeightCache.set(this.rowKey(this.rows[i], i), measured);
1520
+ if (measured > 0) {
1521
+ const key = this.rowKey(this.rows[i], i);
1522
+ if (this.rowHeightCache.get(key) !== measured) this.rowOffsetsCache = null;
1523
+ this.rowHeightCache.set(key, measured);
1524
+ }
1417
1525
  }
1418
1526
  }
1419
1527
  if (virtual) {
@@ -1565,6 +1673,7 @@ var ForgeSelect = class {
1565
1673
  this.focusNavIndex(next);
1566
1674
  }
1567
1675
  focusNavIndex(next) {
1676
+ if (this.navItems.length === 0) return;
1568
1677
  this.highlightedIndex = next;
1569
1678
  if (this.usesVirtualScroll()) {
1570
1679
  const rowIndex = this.rows.findIndex(
@@ -1734,6 +1843,7 @@ var ForgeSelect = class {
1734
1843
  } else {
1735
1844
  this.data = options;
1736
1845
  this.rowContentCache.clear();
1846
+ this.rowHeightCache.clear();
1737
1847
  }
1738
1848
  this.page = page;
1739
1849
  this.hasMore = hasMore;
@@ -1745,6 +1855,7 @@ var ForgeSelect = class {
1745
1855
  if (!append) {
1746
1856
  this.data = [];
1747
1857
  this.rowContentCache.clear();
1858
+ this.rowHeightCache.clear();
1748
1859
  }
1749
1860
  this.hasMore = false;
1750
1861
  this.loadError = error;