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/README.md CHANGED
@@ -61,8 +61,6 @@ Browse the documentation website at **<https://forgeselect.konexforge.com/docs/>
61
61
  - Internationalization (en/vi built in, custom string tables)
62
62
  - TypeScript Support (written in strict TypeScript, ships `.d.ts`)
63
63
 
64
- > Planned/in-progress capabilities — Angular/Svelte wrappers — are tracked in the [Roadmap](#roadmap) below and intentionally not listed above as shipped features.
65
-
66
64
  ## Installation
67
65
 
68
66
  ```bash
@@ -189,8 +187,8 @@ Forge Select is vanilla TypeScript/JavaScript, so it can be mounted inside any f
189
187
  - Vanilla JavaScript
190
188
  - React — via [`forge-select-react`](./packages/react/README.md) (`ForgeSelectReact` component, controlled `value`/`onChange`)
191
189
  - Vue — via [`forge-select-vue`](./packages/vue/README.md) (`ForgeSelectVue` component, `v-model` support)
192
- - Angular — mount manually for now; a dedicated wrapper is on the [Roadmap](#roadmap)
193
- - Svelte — mount manually for now; a dedicated wrapper is on the [Roadmap](#roadmap)
190
+ - Angular — mount manually (see [`docs/examples.md`](./docs/examples.md))
191
+ - Svelte — mount manually (see [`docs/examples.md`](./docs/examples.md))
194
192
  - Next.js
195
193
  - Nuxt
196
194
  - Astro
@@ -221,8 +219,6 @@ Run `npm run bench` for a reproducible JSON baseline covering bundle size, initi
221
219
  - [x] CSS Variables
222
220
  - [x] React Component
223
221
  - [x] Vue Component
224
- - [ ] Angular Component
225
- - [ ] Svelte Component
226
222
 
227
223
  ## Plugin Development Guide
228
224
 
package/dist/index.cjs CHANGED
@@ -165,7 +165,7 @@ function renderOptionContent(container, option, template, variant = "row", sanit
165
165
  // src/remote.ts
166
166
  function buildUrl(ajax, query, page, cursor) {
167
167
  if (!ajax.url) throw new Error("ForgeSelect: ajax requires either url or request.");
168
- if (typeof ajax.url === "function") return ajax.url(query, page);
168
+ if (typeof ajax.url === "function") return ajax.url(query, page, cursor);
169
169
  if (!ajax.params) return ajax.url;
170
170
  const params = new URLSearchParams();
171
171
  for (const [key, value] of Object.entries(ajax.params(query, page, cursor))) params.set(key, String(value));
@@ -351,6 +351,12 @@ var ForgeSelect = class {
351
351
  this.highlightedIndex = -1;
352
352
  this.typeaheadBuffer = "";
353
353
  this.typeaheadTimer = null;
354
+ // Keyed by the option object, not its value: duplicateValuePolicy only warns
355
+ // by default, so two options can share a value while carrying different
356
+ // labels, and a value-keyed cache would render the first one's content for
357
+ // both. Still explicitly bounded — a WeakMap would not collect anything while
358
+ // `data` holds every key, so scrolling a long list would retain a detached
359
+ // node per option visited.
354
360
  this.rowContentCache = /* @__PURE__ */ new Map();
355
361
  this.rowElementCache = /* @__PURE__ */ new Map();
356
362
  this.rowHeightCache = /* @__PURE__ */ new Map();
@@ -538,8 +544,21 @@ var ForgeSelect = class {
538
544
  this.typeaheadBuffer = "";
539
545
  this.highlightedIndex = -1;
540
546
  if (this.searchInput) {
547
+ const hadQuery = this.query !== "";
541
548
  this.searchInput.value = "";
542
549
  this.query = "";
550
+ if (hadQuery && this.opts.ajax) {
551
+ this.ajaxRequestId += 1;
552
+ if (this.ajaxTimer) {
553
+ clearTimeout(this.ajaxTimer);
554
+ this.ajaxTimer = null;
555
+ }
556
+ this.ajaxController?.abort();
557
+ this.ajaxController = null;
558
+ this.setLoading(false);
559
+ this.loadingMore = false;
560
+ this.remoteLoaded = false;
561
+ }
543
562
  }
544
563
  this.emitter.emit("close");
545
564
  for (const plugin of this.plugins) plugin.onClose?.(this);
@@ -581,9 +600,7 @@ var ForgeSelect = class {
581
600
  this.nativeSelect?.removeEventListener("change", this.onNativeChange);
582
601
  this.nativeSelect?.removeEventListener("invalid", this.onNativeInvalid);
583
602
  this.nativeForm?.removeEventListener("reset", this.onFormReset);
584
- this.rowContentCache.clear();
585
- this.rowElementCache.clear();
586
- this.rowHeightCache.clear();
603
+ this.clearRowCaches();
587
604
  this.searchIndex.clear();
588
605
  this.portalHost?.remove();
589
606
  this.root.remove();
@@ -675,9 +692,7 @@ var ForgeSelect = class {
675
692
  }
676
693
  this.root.classList.toggle("forge-select--sortable", this.opts.sortable && this.opts.multiple);
677
694
  this.updateSearchVisibility();
678
- this.rowContentCache.clear();
679
- this.rowElementCache.clear();
680
- this.rowHeightCache.clear();
695
+ this.clearRowCaches();
681
696
  this.searchIndex.clear();
682
697
  this.renderValue();
683
698
  if (this.isOpen) this.renderList();
@@ -761,9 +776,7 @@ var ForgeSelect = class {
761
776
  this.afterSelectionChange();
762
777
  }
763
778
  this.updateSearchVisibility();
764
- this.rowContentCache.clear();
765
- this.rowElementCache.clear();
766
- this.rowHeightCache.clear();
779
+ this.clearRowCaches();
767
780
  this.searchIndex.clear();
768
781
  this.highlightedIndex = -1;
769
782
  if (this.isOpen) this.renderList();
@@ -904,6 +917,7 @@ var ForgeSelect = class {
904
917
  if (portalParent) {
905
918
  this.portalHost = document.createElement("div");
906
919
  this.portalHost.className = "forge-select forge-select--portal-host";
920
+ this.portalHost.style.direction = getComputedStyle(this.root).direction;
907
921
  this.portalHost.dataset.theme = this.opts.theme;
908
922
  this.portalHost.style.setProperty("--fs-item-height", `${this.opts.itemHeight}px`);
909
923
  this.portalHost.append(this.dropdown);
@@ -1282,6 +1296,7 @@ var ForgeSelect = class {
1282
1296
  this.announceMaximum(existing);
1283
1297
  return void 0;
1284
1298
  }
1299
+ if (this.opts.beforeSelect?.(existing) === false) return void 0;
1285
1300
  this.selectValue(existing.value, false);
1286
1301
  return { option: existing, created: false };
1287
1302
  }
@@ -1300,6 +1315,7 @@ var ForgeSelect = class {
1300
1315
  const duplicate = this.findOption(option.value);
1301
1316
  if (duplicate) {
1302
1317
  if (this.selected.includes(duplicate.value)) return void 0;
1318
+ if (this.opts.beforeSelect?.(duplicate) === false) return void 0;
1303
1319
  this.selectValue(duplicate.value, false);
1304
1320
  return { option: duplicate, created: false };
1305
1321
  }
@@ -1580,11 +1596,40 @@ var ForgeSelect = class {
1580
1596
  usesVirtualScroll() {
1581
1597
  return this.opts.virtualScroll !== false && this.rows.length > VIRTUAL_THRESHOLD;
1582
1598
  }
1599
+ /**
1600
+ * Drops every per-row cache at once. Rendered content, recycled <li>
1601
+ * elements, and measured heights are all keyed off the current `data`, so
1602
+ * they must be invalidated together whenever `data` is replaced — clearing
1603
+ * only some of them leaves recycled rows carrying state from the old list.
1604
+ */
1605
+ clearRowCaches() {
1606
+ this.rowContentCache.clear();
1607
+ this.rowElementCache.clear();
1608
+ this.rowHeightCache.clear();
1609
+ this.rowOffsetsCache = null;
1610
+ }
1611
+ /** Identifies a row *position*, which is what measured heights are tied to. */
1583
1612
  rowKey(row, index) {
1584
1613
  if (row.kind === "option") return `option:${row.option.value}:${index}`;
1585
1614
  if (row.kind === "group") return `group:${row.label}:${index}`;
1586
1615
  return `${row.kind}:${index}`;
1587
1616
  }
1617
+ /**
1618
+ * Identifies a row's *content* for `<li>` recycling, deliberately without the
1619
+ * row index: filtering shifts every index below the first change, so an
1620
+ * index-keyed element cache misses on every keystroke — exactly when the list
1621
+ * re-renders most. renderRow() rewrites the element completely, so reusing it
1622
+ * at a new position is safe.
1623
+ *
1624
+ * Keys are not guaranteed unique within a render — duplicateValuePolicy
1625
+ * defaults to warning rather than rejecting duplicate values — so callers
1626
+ * must not hand the same element to two rows of one render.
1627
+ */
1628
+ rowElementKey(row) {
1629
+ if (row.kind === "option") return `option:${row.option.value}`;
1630
+ if (row.kind === "group") return `group:${row.label}`;
1631
+ return row.kind;
1632
+ }
1588
1633
  measuredRowHeight(index) {
1589
1634
  return this.opts.variableItemHeight ? this.rowHeightCache.get(this.rowKey(this.rows[index], index)) ?? this.opts.itemHeight : this.opts.itemHeight;
1590
1635
  }
@@ -1646,9 +1691,12 @@ var ForgeSelect = class {
1646
1691
  this.list.append(topSpacer);
1647
1692
  }
1648
1693
  const appended = [];
1694
+ const claimed = /* @__PURE__ */ new Set();
1649
1695
  for (let i = start; i < end; i++) {
1650
- const key = this.rowKey(this.rows[i], i);
1651
- const element = this.renderRow(this.rows[i], this.rowElementCache.get(key));
1696
+ const key = this.rowElementKey(this.rows[i]);
1697
+ const cached = this.rowElementCache.get(key);
1698
+ const element = this.renderRow(this.rows[i], cached && !claimed.has(cached) ? cached : void 0);
1699
+ claimed.add(element);
1652
1700
  this.rowElementCache.set(key, element);
1653
1701
  if (this.rowElementCache.size > ROW_CACHE_LIMIT) {
1654
1702
  const oldest = this.rowElementCache.keys().next().value;
@@ -1693,7 +1741,12 @@ var ForgeSelect = class {
1693
1741
  "aria-expanded",
1694
1742
  "aria-level",
1695
1743
  "data-nav-index",
1696
- "data-option-value"
1744
+ "data-option-value",
1745
+ "data-selection-state",
1746
+ // Tree rows set an inline padding-left indent; clearing the whole
1747
+ // attribute keeps recycling self-contained, so a row reused at a
1748
+ // shallower depth can't inherit the previous row's indent.
1749
+ "style"
1697
1750
  ])
1698
1751
  li.removeAttribute(attribute);
1699
1752
  switch (row.kind) {
@@ -1743,45 +1796,47 @@ var ForgeSelect = class {
1743
1796
  li.textContent = format(this.strings.createOption, { query: this.query.trim() });
1744
1797
  if (row.navIndex === this.highlightedIndex) li.classList.add("forge-select__option--highlighted");
1745
1798
  break;
1746
- case "option": {
1747
- li.className = "forge-select__option";
1748
- li.dataset.optionValue = row.option.value;
1749
- if (row.option.className) li.classList.add(...row.option.className.trim().split(/\s+/).filter(Boolean));
1750
- li.setAttribute("role", "option");
1751
- const isSelected = this.selected.includes(row.option.value);
1752
- li.setAttribute("aria-selected", String(isSelected));
1753
- if (isSelected) li.classList.add("forge-select__option--selected");
1754
- if (this.opts.multiple && row.hasChildren && computeCheckState(row.option, this.selected, this.isOptionDisabled) === "some") {
1755
- li.classList.add("forge-select__option--indeterminate");
1756
- li.dataset.selectionState = "mixed";
1757
- }
1758
- if (row.depth > 0) {
1759
- li.style.paddingLeft = `calc(12px + ${row.depth} * var(--fs-tree-indent, 18px))`;
1760
- }
1761
- if (this.isOptionDisabled(row.option) || this.hasReachedMaximum() && !this.selected.includes(row.option.value)) {
1762
- li.classList.add("forge-select__option--disabled");
1763
- li.setAttribute("aria-disabled", "true");
1764
- } else {
1765
- li.id = `${this.uid}-nav-${row.navIndex}`;
1766
- li.dataset.navIndex = String(row.navIndex);
1767
- if (row.navIndex === this.highlightedIndex) li.classList.add("forge-select__option--highlighted");
1768
- }
1769
- if (row.hasChildren) {
1770
- const expanded = this.query !== "" || this.expandedValues.has(row.option.value);
1771
- li.setAttribute("aria-expanded", String(expanded));
1772
- const twisty = document.createElement("span");
1773
- twisty.className = "forge-select__twisty";
1774
- twisty.dataset.twisty = row.option.value;
1775
- twisty.setAttribute("aria-hidden", "true");
1776
- twisty.textContent = expanded ? "\u25BC" : "\u25B6";
1777
- li.append(twisty);
1778
- }
1779
- li.append(this.optionContent(row.option));
1799
+ case "option":
1800
+ this.renderOptionRow(li, row);
1780
1801
  break;
1781
- }
1782
1802
  }
1783
1803
  return li;
1784
1804
  }
1805
+ renderOptionRow(li, row) {
1806
+ li.className = "forge-select__option";
1807
+ li.dataset.optionValue = row.option.value;
1808
+ if (row.option.className) li.classList.add(...row.option.className.trim().split(/\s+/).filter(Boolean));
1809
+ li.setAttribute("role", "option");
1810
+ const isSelected = this.selected.includes(row.option.value);
1811
+ li.setAttribute("aria-selected", String(isSelected));
1812
+ if (isSelected) li.classList.add("forge-select__option--selected");
1813
+ if (this.opts.multiple && row.hasChildren && computeCheckState(row.option, this.selected, this.isOptionDisabled) === "some") {
1814
+ li.classList.add("forge-select__option--indeterminate");
1815
+ li.dataset.selectionState = "mixed";
1816
+ }
1817
+ if (row.depth > 0) {
1818
+ li.style.paddingLeft = `calc(12px + ${row.depth} * var(--fs-tree-indent, 18px))`;
1819
+ }
1820
+ if (this.isOptionDisabled(row.option) || this.hasReachedMaximum() && !this.selected.includes(row.option.value)) {
1821
+ li.classList.add("forge-select__option--disabled");
1822
+ li.setAttribute("aria-disabled", "true");
1823
+ } else {
1824
+ li.id = `${this.uid}-nav-${row.navIndex}`;
1825
+ li.dataset.navIndex = String(row.navIndex);
1826
+ if (row.navIndex === this.highlightedIndex) li.classList.add("forge-select__option--highlighted");
1827
+ }
1828
+ if (row.hasChildren) {
1829
+ const expanded = this.query !== "" || this.expandedValues.has(row.option.value);
1830
+ li.setAttribute("aria-expanded", String(expanded));
1831
+ const twisty = document.createElement("span");
1832
+ twisty.className = "forge-select__twisty";
1833
+ twisty.dataset.twisty = row.option.value;
1834
+ twisty.setAttribute("aria-hidden", "true");
1835
+ twisty.textContent = expanded ? "\u25BC" : "\u25B6";
1836
+ li.append(twisty);
1837
+ }
1838
+ li.append(this.optionContent(row.option));
1839
+ }
1785
1840
  /**
1786
1841
  * Rendered row content is cached per option value and cloned on each render,
1787
1842
  * so templates run once per option instead of once per scroll frame.
@@ -1811,7 +1866,7 @@ var ForgeSelect = class {
1811
1866
  }
1812
1867
  return holder;
1813
1868
  }
1814
- let cached = this.rowContentCache.get(option.value);
1869
+ let cached = this.rowContentCache.get(option);
1815
1870
  if (!cached) {
1816
1871
  const holder = document.createElement("span");
1817
1872
  holder.className = "forge-select__option-content";
@@ -1820,7 +1875,7 @@ var ForgeSelect = class {
1820
1875
  const oldest = this.rowContentCache.keys().next().value;
1821
1876
  this.rowContentCache.delete(oldest);
1822
1877
  }
1823
- this.rowContentCache.set(option.value, holder);
1878
+ this.rowContentCache.set(option, holder);
1824
1879
  cached = holder;
1825
1880
  }
1826
1881
  return cached.cloneNode(true);
@@ -2014,8 +2069,7 @@ var ForgeSelect = class {
2014
2069
  this.data = [...this.data, ...options.filter((o) => !existing.has(o.value))];
2015
2070
  } else {
2016
2071
  this.data = options;
2017
- this.rowContentCache.clear();
2018
- this.rowHeightCache.clear();
2072
+ this.clearRowCaches();
2019
2073
  }
2020
2074
  this.page = page;
2021
2075
  this.hasMore = hasMore;
@@ -2028,8 +2082,7 @@ var ForgeSelect = class {
2028
2082
  const error = cause instanceof Error ? cause : new Error(String(cause));
2029
2083
  if (!append) {
2030
2084
  this.data = [];
2031
- this.rowContentCache.clear();
2032
- this.rowHeightCache.clear();
2085
+ this.clearRowCaches();
2033
2086
  }
2034
2087
  this.hasMore = false;
2035
2088
  this.loadError = error;