forge-select 0.7.0 → 0.7.2

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
@@ -138,7 +138,7 @@ function renderOptionContent(container, option, template, variant = "row", sanit
138
138
  // src/remote.ts
139
139
  function buildUrl(ajax, query, page, cursor) {
140
140
  if (!ajax.url) throw new Error("ForgeSelect: ajax requires either url or request.");
141
- if (typeof ajax.url === "function") return ajax.url(query, page);
141
+ if (typeof ajax.url === "function") return ajax.url(query, page, cursor);
142
142
  if (!ajax.params) return ajax.url;
143
143
  const params = new URLSearchParams();
144
144
  for (const [key, value] of Object.entries(ajax.params(query, page, cursor))) params.set(key, String(value));
@@ -324,6 +324,12 @@ var ForgeSelect = class {
324
324
  this.highlightedIndex = -1;
325
325
  this.typeaheadBuffer = "";
326
326
  this.typeaheadTimer = null;
327
+ // Keyed by the option object, not its value: duplicateValuePolicy only warns
328
+ // by default, so two options can share a value while carrying different
329
+ // labels, and a value-keyed cache would render the first one's content for
330
+ // both. Still explicitly bounded — a WeakMap would not collect anything while
331
+ // `data` holds every key, so scrolling a long list would retain a detached
332
+ // node per option visited.
327
333
  this.rowContentCache = /* @__PURE__ */ new Map();
328
334
  this.rowElementCache = /* @__PURE__ */ new Map();
329
335
  this.rowHeightCache = /* @__PURE__ */ new Map();
@@ -511,8 +517,21 @@ var ForgeSelect = class {
511
517
  this.typeaheadBuffer = "";
512
518
  this.highlightedIndex = -1;
513
519
  if (this.searchInput) {
520
+ const hadQuery = this.query !== "";
514
521
  this.searchInput.value = "";
515
522
  this.query = "";
523
+ if (hadQuery && this.opts.ajax) {
524
+ this.ajaxRequestId += 1;
525
+ if (this.ajaxTimer) {
526
+ clearTimeout(this.ajaxTimer);
527
+ this.ajaxTimer = null;
528
+ }
529
+ this.ajaxController?.abort();
530
+ this.ajaxController = null;
531
+ this.setLoading(false);
532
+ this.loadingMore = false;
533
+ this.remoteLoaded = false;
534
+ }
516
535
  }
517
536
  this.emitter.emit("close");
518
537
  for (const plugin of this.plugins) plugin.onClose?.(this);
@@ -554,9 +573,7 @@ var ForgeSelect = class {
554
573
  this.nativeSelect?.removeEventListener("change", this.onNativeChange);
555
574
  this.nativeSelect?.removeEventListener("invalid", this.onNativeInvalid);
556
575
  this.nativeForm?.removeEventListener("reset", this.onFormReset);
557
- this.rowContentCache.clear();
558
- this.rowElementCache.clear();
559
- this.rowHeightCache.clear();
576
+ this.clearRowCaches();
560
577
  this.searchIndex.clear();
561
578
  this.portalHost?.remove();
562
579
  this.root.remove();
@@ -648,9 +665,7 @@ var ForgeSelect = class {
648
665
  }
649
666
  this.root.classList.toggle("forge-select--sortable", this.opts.sortable && this.opts.multiple);
650
667
  this.updateSearchVisibility();
651
- this.rowContentCache.clear();
652
- this.rowElementCache.clear();
653
- this.rowHeightCache.clear();
668
+ this.clearRowCaches();
654
669
  this.searchIndex.clear();
655
670
  this.renderValue();
656
671
  if (this.isOpen) this.renderList();
@@ -734,9 +749,7 @@ var ForgeSelect = class {
734
749
  this.afterSelectionChange();
735
750
  }
736
751
  this.updateSearchVisibility();
737
- this.rowContentCache.clear();
738
- this.rowElementCache.clear();
739
- this.rowHeightCache.clear();
752
+ this.clearRowCaches();
740
753
  this.searchIndex.clear();
741
754
  this.highlightedIndex = -1;
742
755
  if (this.isOpen) this.renderList();
@@ -877,6 +890,7 @@ var ForgeSelect = class {
877
890
  if (portalParent) {
878
891
  this.portalHost = document.createElement("div");
879
892
  this.portalHost.className = "forge-select forge-select--portal-host";
893
+ this.portalHost.style.direction = getComputedStyle(this.root).direction;
880
894
  this.portalHost.dataset.theme = this.opts.theme;
881
895
  this.portalHost.style.setProperty("--fs-item-height", `${this.opts.itemHeight}px`);
882
896
  this.portalHost.append(this.dropdown);
@@ -1255,6 +1269,7 @@ var ForgeSelect = class {
1255
1269
  this.announceMaximum(existing);
1256
1270
  return void 0;
1257
1271
  }
1272
+ if (this.opts.beforeSelect?.(existing) === false) return void 0;
1258
1273
  this.selectValue(existing.value, false);
1259
1274
  return { option: existing, created: false };
1260
1275
  }
@@ -1273,6 +1288,7 @@ var ForgeSelect = class {
1273
1288
  const duplicate = this.findOption(option.value);
1274
1289
  if (duplicate) {
1275
1290
  if (this.selected.includes(duplicate.value)) return void 0;
1291
+ if (this.opts.beforeSelect?.(duplicate) === false) return void 0;
1276
1292
  this.selectValue(duplicate.value, false);
1277
1293
  return { option: duplicate, created: false };
1278
1294
  }
@@ -1553,11 +1569,40 @@ var ForgeSelect = class {
1553
1569
  usesVirtualScroll() {
1554
1570
  return this.opts.virtualScroll !== false && this.rows.length > VIRTUAL_THRESHOLD;
1555
1571
  }
1572
+ /**
1573
+ * Drops every per-row cache at once. Rendered content, recycled <li>
1574
+ * elements, and measured heights are all keyed off the current `data`, so
1575
+ * they must be invalidated together whenever `data` is replaced — clearing
1576
+ * only some of them leaves recycled rows carrying state from the old list.
1577
+ */
1578
+ clearRowCaches() {
1579
+ this.rowContentCache.clear();
1580
+ this.rowElementCache.clear();
1581
+ this.rowHeightCache.clear();
1582
+ this.rowOffsetsCache = null;
1583
+ }
1584
+ /** Identifies a row *position*, which is what measured heights are tied to. */
1556
1585
  rowKey(row, index) {
1557
1586
  if (row.kind === "option") return `option:${row.option.value}:${index}`;
1558
1587
  if (row.kind === "group") return `group:${row.label}:${index}`;
1559
1588
  return `${row.kind}:${index}`;
1560
1589
  }
1590
+ /**
1591
+ * Identifies a row's *content* for `<li>` recycling, deliberately without the
1592
+ * row index: filtering shifts every index below the first change, so an
1593
+ * index-keyed element cache misses on every keystroke — exactly when the list
1594
+ * re-renders most. renderRow() rewrites the element completely, so reusing it
1595
+ * at a new position is safe.
1596
+ *
1597
+ * Keys are not guaranteed unique within a render — duplicateValuePolicy
1598
+ * defaults to warning rather than rejecting duplicate values — so callers
1599
+ * must not hand the same element to two rows of one render.
1600
+ */
1601
+ rowElementKey(row) {
1602
+ if (row.kind === "option") return `option:${row.option.value}`;
1603
+ if (row.kind === "group") return `group:${row.label}`;
1604
+ return row.kind;
1605
+ }
1561
1606
  measuredRowHeight(index) {
1562
1607
  return this.opts.variableItemHeight ? this.rowHeightCache.get(this.rowKey(this.rows[index], index)) ?? this.opts.itemHeight : this.opts.itemHeight;
1563
1608
  }
@@ -1619,9 +1664,12 @@ var ForgeSelect = class {
1619
1664
  this.list.append(topSpacer);
1620
1665
  }
1621
1666
  const appended = [];
1667
+ const claimed = /* @__PURE__ */ new Set();
1622
1668
  for (let i = start; i < end; i++) {
1623
- const key = this.rowKey(this.rows[i], i);
1624
- const element = this.renderRow(this.rows[i], this.rowElementCache.get(key));
1669
+ const key = this.rowElementKey(this.rows[i]);
1670
+ const cached = this.rowElementCache.get(key);
1671
+ const element = this.renderRow(this.rows[i], cached && !claimed.has(cached) ? cached : void 0);
1672
+ claimed.add(element);
1625
1673
  this.rowElementCache.set(key, element);
1626
1674
  if (this.rowElementCache.size > ROW_CACHE_LIMIT) {
1627
1675
  const oldest = this.rowElementCache.keys().next().value;
@@ -1666,7 +1714,12 @@ var ForgeSelect = class {
1666
1714
  "aria-expanded",
1667
1715
  "aria-level",
1668
1716
  "data-nav-index",
1669
- "data-option-value"
1717
+ "data-option-value",
1718
+ "data-selection-state",
1719
+ // Tree rows set an inline padding-left indent; clearing the whole
1720
+ // attribute keeps recycling self-contained, so a row reused at a
1721
+ // shallower depth can't inherit the previous row's indent.
1722
+ "style"
1670
1723
  ])
1671
1724
  li.removeAttribute(attribute);
1672
1725
  switch (row.kind) {
@@ -1716,45 +1769,47 @@ var ForgeSelect = class {
1716
1769
  li.textContent = format(this.strings.createOption, { query: this.query.trim() });
1717
1770
  if (row.navIndex === this.highlightedIndex) li.classList.add("forge-select__option--highlighted");
1718
1771
  break;
1719
- case "option": {
1720
- li.className = "forge-select__option";
1721
- li.dataset.optionValue = row.option.value;
1722
- if (row.option.className) li.classList.add(...row.option.className.trim().split(/\s+/).filter(Boolean));
1723
- li.setAttribute("role", "option");
1724
- const isSelected = this.selected.includes(row.option.value);
1725
- li.setAttribute("aria-selected", String(isSelected));
1726
- if (isSelected) li.classList.add("forge-select__option--selected");
1727
- if (this.opts.multiple && row.hasChildren && computeCheckState(row.option, this.selected, this.isOptionDisabled) === "some") {
1728
- li.classList.add("forge-select__option--indeterminate");
1729
- li.dataset.selectionState = "mixed";
1730
- }
1731
- if (row.depth > 0) {
1732
- li.style.paddingLeft = `calc(12px + ${row.depth} * var(--fs-tree-indent, 18px))`;
1733
- }
1734
- if (this.isOptionDisabled(row.option) || this.hasReachedMaximum() && !this.selected.includes(row.option.value)) {
1735
- li.classList.add("forge-select__option--disabled");
1736
- li.setAttribute("aria-disabled", "true");
1737
- } else {
1738
- li.id = `${this.uid}-nav-${row.navIndex}`;
1739
- li.dataset.navIndex = String(row.navIndex);
1740
- if (row.navIndex === this.highlightedIndex) li.classList.add("forge-select__option--highlighted");
1741
- }
1742
- if (row.hasChildren) {
1743
- const expanded = this.query !== "" || this.expandedValues.has(row.option.value);
1744
- li.setAttribute("aria-expanded", String(expanded));
1745
- const twisty = document.createElement("span");
1746
- twisty.className = "forge-select__twisty";
1747
- twisty.dataset.twisty = row.option.value;
1748
- twisty.setAttribute("aria-hidden", "true");
1749
- twisty.textContent = expanded ? "\u25BC" : "\u25B6";
1750
- li.append(twisty);
1751
- }
1752
- li.append(this.optionContent(row.option));
1772
+ case "option":
1773
+ this.renderOptionRow(li, row);
1753
1774
  break;
1754
- }
1755
1775
  }
1756
1776
  return li;
1757
1777
  }
1778
+ renderOptionRow(li, row) {
1779
+ li.className = "forge-select__option";
1780
+ li.dataset.optionValue = row.option.value;
1781
+ if (row.option.className) li.classList.add(...row.option.className.trim().split(/\s+/).filter(Boolean));
1782
+ li.setAttribute("role", "option");
1783
+ const isSelected = this.selected.includes(row.option.value);
1784
+ li.setAttribute("aria-selected", String(isSelected));
1785
+ if (isSelected) li.classList.add("forge-select__option--selected");
1786
+ if (this.opts.multiple && row.hasChildren && computeCheckState(row.option, this.selected, this.isOptionDisabled) === "some") {
1787
+ li.classList.add("forge-select__option--indeterminate");
1788
+ li.dataset.selectionState = "mixed";
1789
+ }
1790
+ if (row.depth > 0) {
1791
+ li.style.paddingLeft = `calc(12px + ${row.depth} * var(--fs-tree-indent, 18px))`;
1792
+ }
1793
+ if (this.isOptionDisabled(row.option) || this.hasReachedMaximum() && !this.selected.includes(row.option.value)) {
1794
+ li.classList.add("forge-select__option--disabled");
1795
+ li.setAttribute("aria-disabled", "true");
1796
+ } else {
1797
+ li.id = `${this.uid}-nav-${row.navIndex}`;
1798
+ li.dataset.navIndex = String(row.navIndex);
1799
+ if (row.navIndex === this.highlightedIndex) li.classList.add("forge-select__option--highlighted");
1800
+ }
1801
+ if (row.hasChildren) {
1802
+ const expanded = this.query !== "" || this.expandedValues.has(row.option.value);
1803
+ li.setAttribute("aria-expanded", String(expanded));
1804
+ const twisty = document.createElement("span");
1805
+ twisty.className = "forge-select__twisty";
1806
+ twisty.dataset.twisty = row.option.value;
1807
+ twisty.setAttribute("aria-hidden", "true");
1808
+ twisty.textContent = expanded ? "\u25BC" : "\u25B6";
1809
+ li.append(twisty);
1810
+ }
1811
+ li.append(this.optionContent(row.option));
1812
+ }
1758
1813
  /**
1759
1814
  * Rendered row content is cached per option value and cloned on each render,
1760
1815
  * so templates run once per option instead of once per scroll frame.
@@ -1784,7 +1839,7 @@ var ForgeSelect = class {
1784
1839
  }
1785
1840
  return holder;
1786
1841
  }
1787
- let cached = this.rowContentCache.get(option.value);
1842
+ let cached = this.rowContentCache.get(option);
1788
1843
  if (!cached) {
1789
1844
  const holder = document.createElement("span");
1790
1845
  holder.className = "forge-select__option-content";
@@ -1793,7 +1848,7 @@ var ForgeSelect = class {
1793
1848
  const oldest = this.rowContentCache.keys().next().value;
1794
1849
  this.rowContentCache.delete(oldest);
1795
1850
  }
1796
- this.rowContentCache.set(option.value, holder);
1851
+ this.rowContentCache.set(option, holder);
1797
1852
  cached = holder;
1798
1853
  }
1799
1854
  return cached.cloneNode(true);
@@ -1987,8 +2042,7 @@ var ForgeSelect = class {
1987
2042
  this.data = [...this.data, ...options.filter((o) => !existing.has(o.value))];
1988
2043
  } else {
1989
2044
  this.data = options;
1990
- this.rowContentCache.clear();
1991
- this.rowHeightCache.clear();
2045
+ this.clearRowCaches();
1992
2046
  }
1993
2047
  this.page = page;
1994
2048
  this.hasMore = hasMore;
@@ -2001,8 +2055,7 @@ var ForgeSelect = class {
2001
2055
  const error = cause instanceof Error ? cause : new Error(String(cause));
2002
2056
  if (!append) {
2003
2057
  this.data = [];
2004
- this.rowContentCache.clear();
2005
- this.rowHeightCache.clear();
2058
+ this.clearRowCaches();
2006
2059
  }
2007
2060
  this.hasMore = false;
2008
2061
  this.loadError = error;