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 +2 -6
- package/dist/index.cjs +107 -54
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +22 -1
- package/dist/index.d.ts +22 -1
- package/dist/index.global.js +1 -1
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +107 -54
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
|
193
|
-
- Svelte — mount manually
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
1651
|
-
const
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
2032
|
-
this.rowHeightCache.clear();
|
|
2085
|
+
this.clearRowCaches();
|
|
2033
2086
|
}
|
|
2034
2087
|
this.hasMore = false;
|
|
2035
2088
|
this.loadError = error;
|