diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 346084b..dead9b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,8 +81,13 @@ jobs: - name: Validate generated site run: node scripts/check-site.mjs + # The build steps above already produced dist/ and both workspace builds, + # so this runs the checks themselves rather than `check:package`, which + # would repeat those builds. The checks live in one script either way — + # inlining the pack commands here is what let this job drift out of step + # with `check:package` and skip the peer-range guard entirely. - name: Validate npm package contents - run: npm pack --dry-run --ignore-scripts && npm pack ./packages/react --dry-run --ignore-scripts && npm pack ./packages/vue --dry-run --ignore-scripts + run: npm run check:package:built - name: Audit dependencies run: npm audit --audit-level=moderate diff --git a/CHANGELOG.md b/CHANGELOG.md index 860889b..7c3dc75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,14 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Fixed - -- `npm run check:package` now verifies that each framework wrapper's `forge-select` peer range actually admits the core version being released, and that the core has not slipped from `peerDependencies` into `dependencies`. The wrappers asked for `^0.8.0`, which on a 0.x version means `>=0.8.0 <0.9.0`, so from 0.9.0 onward `npm install forge-select forge-select-react` failed outright with `ERESOLVE`. Nothing checked the two numbers against each other, so nothing caught it; `scripts/check-peer-range.mjs` does, from the same bounds npm resolves with. The wrappers themselves are fixed in their own 0.7.1 releases. +## [0.9.2] - 2026-09-05 ### Changed +- Selecting or deselecting a tree parent no longer scans the selection once per descendant. The cascade checked each descendant with `selected.includes()` before pushing it, and deselection did an `indexOf` plus a `splice` per descendant, so both grew with the product of the subtree and the selection. Membership now goes through one set built for the cascade, and deselection removes the whole subtree in a single filtered pass. Measured over a parent of 8,000 children with the tag list capped so rendering does not dominate: selecting it drops from 58.4 ms to 8.4 ms, and the cost is now linear in the subtree rather than quadratic (1,000 -> 8,000 children scales 2.3x, not 14x). With tags uncapped the same select goes from 231 ms to 150 ms at 4,000 children -- the remainder is the control rendering one tag per selection, which is what `maxVisibleTags` exists for. +- `buildRows()` builds one set for the `maxSelections` check instead of scanning the selection per option, and only when the cap is actually reached. Nothing changes for the common small cap; a large one no longer costs a full selection scan per option on every keystroke. +- The document-wide capture-phase scroll listener is only registered when the dropdown is portalled. Its handler already returned immediately without a portal host, so an inline dropdown was paying a function call for every scroll event anywhere on the page to reach that guard. `portalHost` is built in the constructor, so whether it exists is known before any `open()`. - The README's bundle-size figures are re-measured against the current build rather than 0.7.5: 13.8 KB gzipped for minimal usage, 14.2 KB with every feature touched, 2.6 KB for `styles.css`, and ~14.0 KB for the CDN IIFE bundle. No code changed — the published numbers had simply not moved since they were written. +### Fixed + +- `npm run check:package` now verifies that each framework wrapper's `forge-select` peer range actually admits the core version being released, and that the core has not slipped from `peerDependencies` into `dependencies`. The wrappers asked for `^0.8.0`, which on a 0.x version means `>=0.8.0 <0.9.0`, so from 0.9.0 onward `npm install forge-select forge-select-react` failed outright with `ERESOLVE`. Nothing checked the two numbers against each other, so nothing caught it; `scripts/check-peer-range.mjs` does, from the same bounds npm resolves with. The wrappers themselves are fixed in their own 0.7.1 releases. +- CI now runs the package checks from the same script the `check:package` npm script uses, rather than an inlined copy of its `npm pack --dry-run` commands. The copy had drifted: the peer-range guard added alongside it ran in neither `verify` nor CI, so the check meant to stop a wrapper peer range from excluding the core it ships with was not actually running anywhere automatic. `CONTRIBUTING.md` already claimed CI ran `check:package`; the checks are now shared rather than duplicated. `check:package` still builds and then checks, for a working tree that may not be built; CI, which has already built by that point, calls `check:package:built` so the builds are not repeated. +- Deselecting a tree parent had no test covering the cascade at all. With the descendant removal disabled the whole suite still passed, because the children stayed selected, `syncTreeAncestors()` then saw every child selected and put the parent back, and the click read as a no-op. Added a regression test that fails with exactly that symptom (`['apple','banana','fruits']` where `[]` is expected). + ## [0.9.1] - 2026-09-04 ### Changed @@ -303,7 +310,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Website**: landing page, rendered documentation, interactive playground, and feature demo at . - **Documentation**: API reference, examples, playground guide, Select2 migration guide, benchmarks methodology, and plugin development guide under `docs/`. -[Unreleased]: https://github.com/cmm-cmm/ForgeSelect/compare/v0.9.1...HEAD +[Unreleased]: https://github.com/cmm-cmm/ForgeSelect/compare/v0.9.2...HEAD +[0.9.2]: https://github.com/cmm-cmm/ForgeSelect/compare/v0.9.1...v0.9.2 [0.9.1]: https://github.com/cmm-cmm/ForgeSelect/compare/v0.9.0...v0.9.1 [0.9.0]: https://github.com/cmm-cmm/ForgeSelect/compare/v0.8.0...v0.9.0 [0.8.0]: https://github.com/cmm-cmm/ForgeSelect/compare/v0.7.6...v0.8.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cd598d6..406c6d2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,20 +12,21 @@ cd ForgeSelect npm install ``` -| Command | What it does | -| ----------------------- | ------------------------------------------------------- | -| `npm test` | Run the vitest + jsdom test suite | -| `npm run test:watch` | Tests in watch mode | -| `npm run typecheck` | Strict TypeScript check (`tsc --noEmit`) | -| `npm run build` | Build ESM + CJS + IIFE bundles and `.d.ts` into `dist/` | -| `npm run build:site` | Build the library plus the full website into `_site/` | -| `npm run lint` | Check TypeScript and JavaScript with ESLint | -| `npm run format:check` | Check repository formatting with Prettier | -| `npm run test:coverage` | Run tests and enforce 80% coverage thresholds | -| `npm run test:browser` | Run Playwright in Chromium, Firefox, and WebKit | -| `npm run bench` | Measure bundle, initialization, search, and scrolling | -| `npm run check:site` | Reject broken generated local links and assets | -| `npm run check:package` | Build and inspect all npm tarballs without publishing | +| Command | What it does | +| ----------------------------- | ----------------------------------------------------------------- | +| `npm test` | Run the vitest + jsdom test suite | +| `npm run test:watch` | Tests in watch mode | +| `npm run typecheck` | Strict TypeScript check (`tsc --noEmit`) | +| `npm run build` | Build ESM + CJS + IIFE bundles and `.d.ts` into `dist/` | +| `npm run build:site` | Build the library plus the full website into `_site/` | +| `npm run lint` | Check TypeScript and JavaScript with ESLint | +| `npm run format:check` | Check repository formatting with Prettier | +| `npm run test:coverage` | Run tests and enforce 80% coverage thresholds | +| `npm run test:browser` | Run Playwright in Chromium, Firefox, and WebKit | +| `npm run bench` | Measure bundle, initialization, search, and scrolling | +| `npm run check:site` | Reject broken generated local links and assets | +| `npm run check:package` | Build and inspect all npm tarballs without publishing | +| `npm run check:package:built` | The same checks against an already-built tree, without rebuilding | To preview the website/demo/playground locally: @@ -47,7 +48,7 @@ cd _site && python3 -m http.server 8080 # or: npx serve -l 8080 ## Pull request guidelines 1. Branch from `main` and keep PRs focused on one change. -2. CI (`.github/workflows/ci.yml`) must pass: lint, format check, typecheck, test with coverage, build (core and the React/Vue workspaces), `check:site`, `check:package`, `npm audit`, and the Playwright browser suite. Run `npm run verify` locally to cover the core-package subset (lint, format:check, typecheck, test:coverage, build) before pushing. +2. CI (`.github/workflows/ci.yml`) must pass: lint, format check, typecheck, test with coverage, build (core and the React/Vue workspaces), `check:site`, the `check:package` checks (as `check:package:built`, since the job has already built), `npm audit`, and the Playwright browser suite. Run `npm run verify` locally to cover the core-package subset (lint, format:check, typecheck, test:coverage, build) before pushing. 3. Add or update tests for behavior changes; jsdom cannot model layout (e.g. `scrollTop` clamping), so verify scroll/visual behavior in a real browser too. 4. Update the relevant docs page under `docs/`, and add an entry to `CHANGELOG.md` under **Unreleased** for anything that reaches the published packages — behavior, API, types, or packaging. A change confined to contributor-facing files (this file, CI workflows, issue templates) doesn't need one; `CHANGELOG.md` tracks what a consumer of the package would notice. 5. Keep the zero-dependency promise: no new runtime dependencies. Dev dependencies are fine when justified. diff --git a/README.md b/README.md index 6ed01b3..a67af95 100644 --- a/README.md +++ b/README.md @@ -78,15 +78,15 @@ that defines the global `ForgeSelectBundle`. Pin the version — the unversioned URL follows the latest release and will change under you: ```html - - + + ``` -`unpkg.com/forge-select@0.9.1` serves the same file. The bundle is also -reachable by its explicit path (`.../forge-select@0.9.1/dist/index.global.js`) +`unpkg.com/forge-select@0.9.2` serves the same file. The bundle is also +reachable by its explicit path (`.../forge-select@0.9.2/dist/index.global.js`) if you prefer not to rely on the package's `unpkg`/`jsdelivr` fields. ## Quick Start diff --git a/package-lock.json b/package-lock.json index 027654f..0b25ce3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "forge-select", - "version": "0.9.1", + "version": "0.9.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "forge-select", - "version": "0.9.1", + "version": "0.9.2", "license": "MIT", "workspaces": [ "packages/*" diff --git a/package.json b/package.json index 45507f2..abc1cf3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "forge-select", - "version": "0.9.1", + "version": "0.9.2", "description": "A modern, lightweight, highly customizable replacement for Select2.", "keywords": [ "select", @@ -61,7 +61,8 @@ "bench": "npm run build && node scripts/benchmark.mjs", "bench:check": "npm run bench", "build:site": "npm run build && node scripts/build-site.mjs", - "check:package": "node scripts/check-peer-range.mjs && npm run build && npm run build --workspaces --if-present && npm pack --dry-run --ignore-scripts && npm pack ./packages/react --dry-run --ignore-scripts && npm pack ./packages/vue --dry-run --ignore-scripts", + "check:package": "npm run build && npm run build --workspaces --if-present && npm run check:package:built", + "check:package:built": "node scripts/check-peer-range.mjs && npm pack --dry-run --ignore-scripts && npm pack ./packages/react --dry-run --ignore-scripts && npm pack ./packages/vue --dry-run --ignore-scripts", "check:site": "npm run build:site && node scripts/check-site.mjs", "format": "prettier --write \"**/*.{ts,tsx,js,mjs,json,jsonc,md,css,yml,yaml}\"", "format:check": "prettier --check \"**/*.{ts,tsx,js,mjs,json,jsonc,md,css,yml,yaml}\"", diff --git a/src/ForgeSelect.ts b/src/ForgeSelect.ts index 47d382e..9456373 100644 --- a/src/ForgeSelect.ts +++ b/src/ForgeSelect.ts @@ -353,7 +353,12 @@ export default class ForgeSelect { window.addEventListener("resize", this.onWindowResize); window.visualViewport?.addEventListener("resize", this.onWindowResize); window.visualViewport?.addEventListener("scroll", this.onWindowResize); - document.addEventListener("scroll", this.onAncestorScroll, true); + // Capture-phase and document-wide, so it fires for every scrollable element + // on the page while the dropdown is open. Only a portalled dropdown needs + // it — an inline one is positioned inside the root and tracks the control + // on its own — and the handler's first line already says so, so skip + // registering it at all rather than paying a call per scroll event. + if (this.portalHost) document.addEventListener("scroll", this.onAncestorScroll, true); if (this.searchInput && !this.searchInput.hidden) this.searchInput.focus(); this.emitter.emit("open"); @@ -1185,11 +1190,18 @@ export default class ForgeSelect { const option = this.findOption(value) ?? this.selectedOptions.get(value) ?? { value, label: value }; this.selectedOptions.set(value, option); if (this.opts.multiple) { + // Membership goes through one set built for this cascade rather than a + // scan of `selected` per descendant: selecting a tree node with many + // children otherwise costs the product of the two. + const present = new Set(this.selected); this.selected.push(value); + present.add(value); // Selecting a tree node cascades to its descendants too; for a plain // option (no children) this is a no-op. for (const v of collectDescendantValues(option, this.isOptionDisabled)) { - if (!this.selected.includes(v)) this.selected.push(v); + if (present.has(v)) continue; + this.selected.push(v); + present.add(v); } this.syncTreeAncestors(); } else { @@ -1208,10 +1220,10 @@ export default class ForgeSelect { this.selected.splice(index, 1); if (this.opts.multiple) { if (option) { - for (const v of collectDescendantValues(option, this.isOptionDisabled)) { - const i = this.selected.indexOf(v); - if (i !== -1) this.selected.splice(i, 1); - } + // One filtered pass rather than an indexOf plus a splice per + // descendant, each of which is itself linear in the selection. + const removing = new Set(collectDescendantValues(option, this.isOptionDisabled)); + if (removing.size > 0) this.selected = this.selected.filter((v) => !removing.has(v)); } this.syncTreeAncestors(); } @@ -1669,9 +1681,13 @@ export default class ForgeSelect { // Constant for the whole pass: `selected` cannot change while rows build. const atMaximum = this.hasReachedMaximum(); + // Only built when the cap is actually reached, which is the only case that + // asks whether each option is already selected. A large maxSelections would + // otherwise scan the whole selection once per option, on every keystroke. + const selectedAtMaximum = atMaximum ? new Set(this.selected) : null; const pushOption = (option: Option, depth: number, parentValue?: string): void => { let navIndex = -1; - const interactionDisabled = this.isOptionDisabled(option) || (atMaximum && !this.selected.includes(option.value)); + const interactionDisabled = this.isOptionDisabled(option) || (atMaximum && !selectedAtMaximum!.has(option.value)); if (!interactionDisabled) { navIndex = this.navItems.length; this.navItems.push({ kind: "option", option, parentValue }); diff --git a/tests/forge-select.test.ts b/tests/forge-select.test.ts index a94bd3e..6600d98 100644 --- a/tests/forge-select.test.ts +++ b/tests/forge-select.test.ts @@ -1399,6 +1399,26 @@ describe("tree select", () => { expect(select.getValue()).toEqual(["fruits", "apple", "banana"]); }); + it("cascades deselecting a parent to every descendant it selected", () => { + mountSelect(""); + const select = new ForgeSelect("#country", { multiple: true, data: treeData() }); + select.open(); + + const fruitsLi = optionEls().find((li) => li.textContent?.includes("Fruits"))!; + fruitsLi.click(); + expect(select.getValue()).toEqual(["fruits", "apple", "banana"]); + + // Clicking the parent again must take its children with it. Leaving them + // behind does not merely strand two values: syncTreeAncestors() then sees + // every child selected and puts the parent straight back, so the click + // reads as doing nothing at all. + optionEls() + .find((li) => li.textContent?.includes("Fruits"))! + .click(); + + expect(select.getValue()).toEqual([]); + }); + it("shows the indeterminate class when only some descendants are selected", () => { mountSelect(""); const select = new ForgeSelect("#country", { multiple: true, data: treeData() });