From 64929f8a22b985b1a7293d6f42fc79813722fc01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Alcaraz=20Mart=C3=ADnez?= Date: Sun, 28 Jun 2026 15:02:54 +0200 Subject: [PATCH 01/21] ci: enhance Coveralls integration and add code change detection for sonar run --- .github/workflows/ci.yml | 62 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 014da50..9a4898a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,7 @@ jobs: with: flag-name: coverage-report-${{ matrix.node-version }} parallel: true + continue-on-error: true coverage-finished: name: Publish coverage report to Coveralls @@ -55,11 +56,51 @@ jobs: runs-on: ubuntu-latest steps: - name: Coveralls Finished + id: coveralls #v2.3.7 uses: coverallsapp/github-action@5cbfd81b66ca5d10c19b062c04de0199c215fb6e with: parallel-finished: true carryforward: "coverage-report-22.x,coverage-report-24.x" + continue-on-error: true + + - name: Check Coveralls status + id: check-status + if: always() + run: | + if [ "${{ steps.coveralls.outcome }}" == "success" ]; then + echo "✅ Coveralls report submitted successfully" + echo "status=success" >> $GITHUB_OUTPUT + elif [ "${{ steps.coveralls.outcome }}" == "failure" ]; then + echo "⚠️ Coveralls service is temporarily unavailable" + echo "status=neutral" >> $GITHUB_OUTPUT + else + echo "❓ Coveralls status unknown: ${{ steps.coveralls.outcome }}" + echo "status=neutral" >> $GITHUB_OUTPUT + fi + + - name: Set neutral status (if needed) + if: steps.check-status.outputs.status == 'neutral' + uses: actions/github-script@v7 + with: + script: | + const { context } = require('@actions/github'); + const owner = context.repo.owner; + const repo = context.repo.repo; + const sha = context.sha; + + await github.rest.checks.create({ + owner, + repo, + name: 'Coveralls / Coverage Report', + head_sha: sha, + status: 'completed', + conclusion: 'neutral', + output: { + title: 'Coveralls service temporarily unavailable', + summary: '⚠️ Coveralls is currently experiencing issues. The coverage report will be submitted when the service is available again.\n\nThis check is marked as neutral and does not block the PR.' + } + }); cypress: name: Run e2e tests with Cypress @@ -111,13 +152,34 @@ jobs: with: fetch-depth: 0 + - name: Check if only dependencies changed + id: check-changes + run: | + if [ "${{ github.event_name }}" == "pull_request" ]; then + BASE_SHA="${{ github.event.pull_request.base.sha }}" + HEAD_SHA="${{ github.event.pull_request.head.sha }}" + CHANGED_FILES=$(git diff --name-only $BASE_SHA $HEAD_SHA) + else + CHANGED_FILES=$(git diff --name-only HEAD^ HEAD) + fi + + if echo "$CHANGED_FILES" | grep -q -v -E '^(package.json|*.md|*.yml)'; then + echo "has_code_changes=true" >> $GITHUB_OUTPUT + echo "📝 Code changes detected" + else + echo "has_code_changes=false" >> $GITHUB_OUTPUT + echo "📦 Only dependency files changed" + fi + - name: Download coverage artifact + if: steps.check-changes.outputs.has_code_changes == 'true' uses: actions/download-artifact@v8 with: name: coverage-report-22.x path: coverage - name: SonarQube Scan + if: steps.check-changes.outputs.has_code_changes == 'true' # v7.0.0 uses: SonarSource/sonarqube-scan-action@59db25f34e16620e48ab4bb9e4a5dce155cb5432 env: From ad3e5f9abeae2db222d5f2bc23c5044304bbcdfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Alcaraz=20Mart=C3=ADnez?= Date: Sun, 28 Jun 2026 15:20:14 +0200 Subject: [PATCH 02/21] ci: update code change detection regex and remove pull request trigger from deploy workflow --- .github/workflows/ci.yml | 2 +- .github/workflows/deploy-docs.yml | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a4898a..3e36489 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -163,7 +163,7 @@ jobs: CHANGED_FILES=$(git diff --name-only HEAD^ HEAD) fi - if echo "$CHANGED_FILES" | grep -q -v -E '^(package.json|*.md|*.yml)'; then + if echo "$CHANGED_FILES" | grep -q -v -E '^(package.json|./**.md|./**.yml)'; then echo "has_code_changes=true" >> $GITHUB_OUTPUT echo "📝 Code changes detected" else diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 528d426..a9b9c7e 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -3,8 +3,6 @@ name: Deploy documentation on: push: branches: [ "v5" ] - pull_request: - branches: [ "v5" ] workflow_dispatch: permissions: From c87251323de18c3fa8f4ae87590cd3513aaa2f46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Alcaraz=20Mart=C3=ADnez?= Date: Sun, 28 Jun 2026 15:24:16 +0200 Subject: [PATCH 03/21] ci: improve code change detection logic in CI workflow --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e36489..c8ba306 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -162,13 +162,16 @@ jobs: else CHANGED_FILES=$(git diff --name-only HEAD^ HEAD) fi + + echo "Changed files:" + echo "$CHANGED_FILES" | grep -q -v -E '^(package.json|./**.md|./**.yml)' if echo "$CHANGED_FILES" | grep -q -v -E '^(package.json|./**.md|./**.yml)'; then echo "has_code_changes=true" >> $GITHUB_OUTPUT echo "📝 Code changes detected" else echo "has_code_changes=false" >> $GITHUB_OUTPUT - echo "📦 Only dependency files changed" + echo "📦 Only dependency, documentation or ci/cd files changed" fi - name: Download coverage artifact From 123538d541d9956f441290efcd7a28e9b78ef3e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Alcaraz=20Mart=C3=ADnez?= Date: Sun, 28 Jun 2026 15:32:44 +0200 Subject: [PATCH 04/21] ci: enhance code change detection by using changed-files action --- .github/workflows/ci.yml | 34 +++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8ba306..506764a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -152,26 +152,30 @@ jobs: with: fetch-depth: 0 - - name: Check if only dependencies changed + - name: Get changed files + id: changed-files + #47.0.6 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 + with: + files: | + ** + files_ignore: | + package.json + .npmrc + **/*.md + .github/** + .gitignore + + - name: Check if code changes exist id: check-changes run: | - if [ "${{ github.event_name }}" == "pull_request" ]; then - BASE_SHA="${{ github.event.pull_request.base.sha }}" - HEAD_SHA="${{ github.event.pull_request.head.sha }}" - CHANGED_FILES=$(git diff --name-only $BASE_SHA $HEAD_SHA) - else - CHANGED_FILES=$(git diff --name-only HEAD^ HEAD) - fi - - echo "Changed files:" - echo "$CHANGED_FILES" | grep -q -v -E '^(package.json|./**.md|./**.yml)' - - if echo "$CHANGED_FILES" | grep -q -v -E '^(package.json|./**.md|./**.yml)'; then + if [ "${{ steps.changed-files.outputs.any_changed }}" == "true" ]; then echo "has_code_changes=true" >> $GITHUB_OUTPUT - echo "📝 Code changes detected" + echo "📝 Code changes detected in:" + echo "${{ steps.changed-files.outputs.all_changed_files }}" else echo "has_code_changes=false" >> $GITHUB_OUTPUT - echo "📦 Only dependency, documentation or ci/cd files changed" + echo "📦 Only dependency, documentation or CI/CD files changed" fi - name: Download coverage artifact From ccdf1f3b5fa3a20340eb70789f266002f9025770 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Alcaraz=20Mart=C3=ADnez?= Date: Sun, 28 Jun 2026 15:57:29 +0200 Subject: [PATCH 05/21] ci: add workflow for publishing package to npmJS on release --- .github/workflows/publish.yml | 44 +++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..49f4739 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,44 @@ +name: Publish Package to npmJS + +on: + release: + types: [published] + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: '24.x' + + - name: Install dependencies + run: npm install + + - name: Determine npm tag + id: npm-tag + run: | + RELEASE_NAME="${{ github.event.release.name }}" + RELEASE_TAG="${{ github.event.release.tag_name }}" + + echo "📦 Release name: $RELEASE_NAME" + echo "🏷️ Release tag: $RELEASE_TAG" + + if [ "${{ github.event.release.prerelease }}" == "true" ]; then + echo "🔬 Prerelease detected - using 'next' tag" + echo "tag=next" >> $GITHUB_OUTPUT + else + echo "🚀 Full release detected - using 'latest' tag" + echo "tag=latest" >> $GITHUB_OUTPUT + fi + + - name: Publish to npm + run: | + npm publish --provenance --access public --tag ${{ steps.npm-tag.outputs.tag }} + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} \ No newline at end of file From f4ea3c7a010b0858e7fe75559cdd420b50db64cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Alcaraz=20Mart=C3=ADnez?= Date: Sun, 28 Jun 2026 15:57:52 +0200 Subject: [PATCH 06/21] ci: update deploy workflow to trigger on release instead of push --- .github/workflows/deploy-docs.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index a9b9c7e..302ed48 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -1,8 +1,8 @@ name: Deploy documentation on: - push: - branches: [ "v5" ] + release: + types: [published] workflow_dispatch: permissions: From 8ace681f1089621b87d658c03c241b150f32a516 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 06:43:48 +0000 Subject: [PATCH 07/21] ci(deps): bump the minor-and-patch-updates group with 2 updates Bumps the minor-and-patch-updates group with 2 updates: [cypress-io/github-action](https://github.com/cypress-io/github-action) and [SonarSource/sonarqube-scan-action](https://github.com/sonarsource/sonarqube-scan-action). Updates `cypress-io/github-action` from 7.4.0 to 7.4.1 - [Release notes](https://github.com/cypress-io/github-action/releases) - [Changelog](https://github.com/cypress-io/github-action/blob/master/CHANGELOG.md) - [Commits](https://github.com/cypress-io/github-action/compare/948d67d3074f1bbb6379c8bdbb04e95d2f8e593f...fa4a118725a8f001170d49631ea89e5d66fee626) Updates `SonarSource/sonarqube-scan-action` from 8.0.0 to 8.2.0 - [Release notes](https://github.com/sonarsource/sonarqube-scan-action/releases) - [Commits](https://github.com/sonarsource/sonarqube-scan-action/compare/59db25f34e16620e48ab4bb9e4a5dce155cb5432...713881670b6b3676cda39549040e2d88c70d582e) --- updated-dependencies: - dependency-name: cypress-io/github-action dependency-version: 7.4.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: minor-and-patch-updates - dependency-name: SonarSource/sonarqube-scan-action dependency-version: 8.2.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: minor-and-patch-updates ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 506764a..883480d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -118,7 +118,7 @@ jobs: run: npm run build - name: Cypress run #v6.10.8 - uses: cypress-io/github-action@948d67d3074f1bbb6379c8bdbb04e95d2f8e593f + uses: cypress-io/github-action@fa4a118725a8f001170d49631ea89e5d66fee626 with: browser: chrome @@ -188,6 +188,6 @@ jobs: - name: SonarQube Scan if: steps.check-changes.outputs.has_code_changes == 'true' # v7.0.0 - uses: SonarSource/sonarqube-scan-action@59db25f34e16620e48ab4bb9e4a5dce155cb5432 + uses: SonarSource/sonarqube-scan-action@713881670b6b3676cda39549040e2d88c70d582e env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} \ No newline at end of file From a3f935a5f3517202b984c2348d94b87b143b98cd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 06:43:52 +0000 Subject: [PATCH 08/21] ci(deps): bump actions/checkout from 6 to 7 Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 8 ++++---- .github/workflows/deploy-docs.yml | 2 +- .github/workflows/publish.yml | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 506764a..57b6b1b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: node-version: [22.x, 24.x] steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up Node.js uses: actions/setup-node@v6 with: @@ -111,7 +111,7 @@ jobs: options: --user 1001 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Install dependencies run: npm install - name: Build project @@ -132,7 +132,7 @@ jobs: security-events: write steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Initialize CodeQL uses: github/codeql-action/init@v4 with: @@ -148,7 +148,7 @@ jobs: needs: build-and-test steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 302ed48..53e5961 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -22,7 +22,7 @@ jobs: working-directory: docs steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Use Node.js uses: actions/setup-node@v6 with: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 49f4739..51364bc 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -11,7 +11,7 @@ jobs: contents: read id-token: write steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions/setup-node@v6 with: From 8c2df84e6ae0960c56c49d4752572ba437ee6138 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 06:43:55 +0000 Subject: [PATCH 09/21] chore(docs): bump the version-updates group in /docs with 2 updates Bumps the version-updates group in /docs with 2 updates: [@fortawesome/fontawesome-free](https://github.com/FortAwesome/Font-Awesome) and [bs-darkmode-toggle](https://github.com/palcarazm/bs-darkmode-toggle). Updates `@fortawesome/fontawesome-free` from 7.2.0 to 7.3.0 - [Release notes](https://github.com/FortAwesome/Font-Awesome/releases) - [Changelog](https://github.com/FortAwesome/Font-Awesome/blob/7.x/CHANGELOG.md) - [Commits](https://github.com/FortAwesome/Font-Awesome/compare/7.2.0...7.3.0) Updates `bs-darkmode-toggle` from 1.0.0 to 1.1.0 - [Release notes](https://github.com/palcarazm/bs-darkmode-toggle/releases) - [Commits](https://github.com/palcarazm/bs-darkmode-toggle/compare/v1.0.0...v1.1.0) --- updated-dependencies: - dependency-name: "@fortawesome/fontawesome-free" dependency-version: 7.3.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: version-updates - dependency-name: bs-darkmode-toggle dependency-version: 1.1.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: version-updates ... Signed-off-by: dependabot[bot] --- docs/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/package.json b/docs/package.json index 66c2722..884d3e0 100644 --- a/docs/package.json +++ b/docs/package.json @@ -29,11 +29,11 @@ } }, "dependencies": { - "@fortawesome/fontawesome-free": "7.2.0", + "@fortawesome/fontawesome-free": "7.3.0", "@highlightjs/cdn-assets": "11.11.1", "bootstrap": "5.3.8", "bootstrap5-toggle": "5.4.1", - "bs-darkmode-toggle": "1.0.0", + "bs-darkmode-toggle": "1.1.0", "highlightjs-badge": "0.1.9", "jquery": "4.0.0" }, From 68e7d6c521a8f3c818d60aa41e67b8ba14dc9981 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 06:43:58 +0000 Subject: [PATCH 10/21] ci(deps): bump actions/github-script from 7 to 9 Bumps [actions/github-script](https://github.com/actions/github-script) from 7 to 9. - [Release notes](https://github.com/actions/github-script/releases) - [Commits](https://github.com/actions/github-script/compare/v7...v9) --- updated-dependencies: - dependency-name: actions/github-script dependency-version: '9' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 506764a..26d30e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,7 +81,7 @@ jobs: - name: Set neutral status (if needed) if: steps.check-status.outputs.status == 'neutral' - uses: actions/github-script@v7 + uses: actions/github-script@v9 with: script: | const { context } = require('@actions/github'); From 6128aaface07db414dcc7c6a3ef2d41816b0a0fb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 06:44:09 +0000 Subject: [PATCH 11/21] chore(docs): bump @fortawesome/fontawesome-free Bumps the version-updates group in /docs with 1 update: [@fortawesome/fontawesome-free](https://github.com/FortAwesome/Font-Awesome). Updates `@fortawesome/fontawesome-free` from 7.3.0 to 7.3.1 - [Release notes](https://github.com/FortAwesome/Font-Awesome/releases) - [Changelog](https://github.com/FortAwesome/Font-Awesome/blob/7.x/CHANGELOG.md) - [Commits](https://github.com/FortAwesome/Font-Awesome/compare/7.3.0...7.3.1) --- updated-dependencies: - dependency-name: "@fortawesome/fontawesome-free" dependency-version: 7.3.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: version-updates ... Signed-off-by: dependabot[bot] --- docs/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/package.json b/docs/package.json index 884d3e0..4ab1963 100644 --- a/docs/package.json +++ b/docs/package.json @@ -29,7 +29,7 @@ } }, "dependencies": { - "@fortawesome/fontawesome-free": "7.3.0", + "@fortawesome/fontawesome-free": "7.3.1", "@highlightjs/cdn-assets": "11.11.1", "bootstrap": "5.3.8", "bootstrap5-toggle": "5.4.1", From e5d1c81716ff23b02de3a064b695a171bd151abd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 06:44:15 +0000 Subject: [PATCH 12/21] ci(deps): bump actions/setup-node from 6 to 7 Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6 to 7. - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- .github/workflows/deploy-docs.yml | 2 +- .github/workflows/publish.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b9b7541..2642849 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v7 - name: Set up Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: ${{ matrix.node-version }} - name: Install dependencies diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 53e5961..18fb4a0 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -24,7 +24,7 @@ jobs: - name: Checkout uses: actions/checkout@v7 - name: Use Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: 24.x - name: Install dependencies diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 51364bc..9b1fd77 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -13,7 +13,7 @@ jobs: steps: - uses: actions/checkout@v7 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@v7 with: node-version: '24.x' From 27641c8248698e42566ae889afd2e8d79686a376 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 06:45:24 +0000 Subject: [PATCH 13/21] ci(deps): bump the minor-and-patch-updates group across 1 directory with 2 updates Bumps the minor-and-patch-updates group with 2 updates in the / directory: [coverallsapp/github-action](https://github.com/coverallsapp/github-action) and [SonarSource/sonarqube-scan-action](https://github.com/sonarsource/sonarqube-scan-action). Updates `coverallsapp/github-action` from 2.3.7 to 2.3.8 - [Release notes](https://github.com/coverallsapp/github-action/releases) - [Commits](https://github.com/coverallsapp/github-action/compare/5cbfd81b66ca5d10c19b062c04de0199c215fb6e...8d6379e14d29928660c4ba802d8e85393440b329) Updates `SonarSource/sonarqube-scan-action` from 8.2.0 to 8.2.1 - [Release notes](https://github.com/sonarsource/sonarqube-scan-action/releases) - [Commits](https://github.com/sonarsource/sonarqube-scan-action/compare/713881670b6b3676cda39549040e2d88c70d582e...22918119ff8e1ca75a623e15c8296b6ea4fbe28f) --- updated-dependencies: - dependency-name: coverallsapp/github-action dependency-version: 2.3.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: minor-and-patch-updates - dependency-name: SonarSource/sonarqube-scan-action dependency-version: 8.2.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: minor-and-patch-updates ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b9b7541..f144bfa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,7 @@ jobs: path: coverage - name: Upload coverage report to Coveralls #v2.3.7 - uses: coverallsapp/github-action@5cbfd81b66ca5d10c19b062c04de0199c215fb6e + uses: coverallsapp/github-action@8d6379e14d29928660c4ba802d8e85393440b329 with: flag-name: coverage-report-${{ matrix.node-version }} parallel: true @@ -58,7 +58,7 @@ jobs: - name: Coveralls Finished id: coveralls #v2.3.7 - uses: coverallsapp/github-action@5cbfd81b66ca5d10c19b062c04de0199c215fb6e + uses: coverallsapp/github-action@8d6379e14d29928660c4ba802d8e85393440b329 with: parallel-finished: true carryforward: "coverage-report-22.x,coverage-report-24.x" @@ -188,6 +188,6 @@ jobs: - name: SonarQube Scan if: steps.check-changes.outputs.has_code_changes == 'true' # v7.0.0 - uses: SonarSource/sonarqube-scan-action@713881670b6b3676cda39549040e2d88c70d582e + uses: SonarSource/sonarqube-scan-action@22918119ff8e1ca75a623e15c8296b6ea4fbe28f env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} \ No newline at end of file From 0f66b62ec053a0facbbd91f5a539b512dd238572 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 06:43:58 +0000 Subject: [PATCH 14/21] chore(docs): bump @highlightjs/cdn-assets Bumps the version-updates group in /docs with 1 update: [@highlightjs/cdn-assets](https://github.com/highlightjs/highlight.js). Updates `@highlightjs/cdn-assets` from 11.11.1 to 11.12.0 - [Release notes](https://github.com/highlightjs/highlight.js/releases) - [Changelog](https://github.com/highlightjs/highlight.js/blob/main/CHANGES.md) - [Commits](https://github.com/highlightjs/highlight.js/compare/11.11.1...11.12.0) --- updated-dependencies: - dependency-name: "@highlightjs/cdn-assets" dependency-version: 11.12.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: version-updates ... Signed-off-by: dependabot[bot] --- docs/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/package.json b/docs/package.json index 4ab1963..70a56d7 100644 --- a/docs/package.json +++ b/docs/package.json @@ -30,7 +30,7 @@ }, "dependencies": { "@fortawesome/fontawesome-free": "7.3.1", - "@highlightjs/cdn-assets": "11.11.1", + "@highlightjs/cdn-assets": "11.12.0", "bootstrap": "5.3.8", "bootstrap5-toggle": "5.4.1", "bs-darkmode-toggle": "1.1.0", From c029c5fde747a37ceb518411b666941b10de36ab Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 06:45:16 +0000 Subject: [PATCH 15/21] ci(deps): bump cypress-io/github-action Bumps the minor-and-patch-updates group with 1 update: [cypress-io/github-action](https://github.com/cypress-io/github-action). Updates `cypress-io/github-action` from 7.4.1 to 7.4.2 - [Release notes](https://github.com/cypress-io/github-action/releases) - [Changelog](https://github.com/cypress-io/github-action/blob/master/CHANGELOG.md) - [Commits](https://github.com/cypress-io/github-action/compare/fa4a118725a8f001170d49631ea89e5d66fee626...c32f12761482a282d24ca0fd7466d8ae86f54ba8) --- updated-dependencies: - dependency-name: cypress-io/github-action dependency-version: 7.4.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: minor-and-patch-updates ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23a4ef2..12938ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -118,7 +118,7 @@ jobs: run: npm run build - name: Cypress run #v6.10.8 - uses: cypress-io/github-action@fa4a118725a8f001170d49631ea89e5d66fee626 + uses: cypress-io/github-action@c32f12761482a282d24ca0fd7466d8ae86f54ba8 with: browser: chrome From 3e26d3cb560a9701b82e5fe80c356a0a6c3c86de Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 06:44:54 +0000 Subject: [PATCH 16/21] ci(deps): bump cypress-io/github-action Bumps the minor-and-patch-updates group with 1 update: [cypress-io/github-action](https://github.com/cypress-io/github-action). Updates `cypress-io/github-action` from 7.4.2 to 7.4.3 - [Release notes](https://github.com/cypress-io/github-action/releases) - [Changelog](https://github.com/cypress-io/github-action/blob/master/CHANGELOG.md) - [Commits](https://github.com/cypress-io/github-action/compare/c32f12761482a282d24ca0fd7466d8ae86f54ba8...09090944bd8aaa2a517cefb6e38bf1aae336b42b) --- updated-dependencies: - dependency-name: cypress-io/github-action dependency-version: 7.4.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: minor-and-patch-updates ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 12938ea..f7547f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -118,7 +118,7 @@ jobs: run: npm run build - name: Cypress run #v6.10.8 - uses: cypress-io/github-action@c32f12761482a282d24ca0fd7466d8ae86f54ba8 + uses: cypress-io/github-action@09090944bd8aaa2a517cefb6e38bf1aae336b42b with: browser: chrome From fd01929f46d4d0d5143cfcf7fd229f080d9adb87 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 06:44:24 +0000 Subject: [PATCH 17/21] chore(deps-dev): bump postcss-cli from 11.0.1 to 12.0.0 Bumps [postcss-cli](https://github.com/postcss/postcss-cli) from 11.0.1 to 12.0.0. - [Release notes](https://github.com/postcss/postcss-cli/releases) - [Changelog](https://github.com/postcss/postcss-cli/blob/master/CHANGELOG.md) - [Commits](https://github.com/postcss/postcss-cli/compare/11.0.1...12.0.0) --- updated-dependencies: - dependency-name: postcss-cli dependency-version: 12.0.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2c3146c..8edd5a6 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,7 @@ "jest-environment-jsdom": "^30.2.0", "postcss": "^8.5.6", "postcss-banner": "^4.0.1", - "postcss-cli": "^11.0.1", + "postcss-cli": "^12.0.0", "rimraf": "^6.1.3", "rollup": "^4.54.0", "rollup-plugin-dts": "^6.4.1", From ed7625461d6bfeee4fb544f3976bd47f9aaf4c77 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 06:44:31 +0000 Subject: [PATCH 18/21] chore(docs): bump the version-updates group across 1 directory with 2 updates Bumps the version-updates group with 2 updates in the /docs directory: [cssnano](https://github.com/cssnano/cssnano) and [postcss-cli](https://github.com/postcss/postcss-cli). Updates `cssnano` from 8.0.10 to 9.0.3 - [Release notes](https://github.com/cssnano/cssnano/releases) - [Commits](https://github.com/cssnano/cssnano/compare/cssnano@8.0.10...cssnano@9.0.3) Updates `postcss-cli` from 11.0.1 to 12.0.0 - [Release notes](https://github.com/postcss/postcss-cli/releases) - [Changelog](https://github.com/postcss/postcss-cli/blob/master/CHANGELOG.md) - [Commits](https://github.com/postcss/postcss-cli/compare/11.0.1...12.0.0) --- updated-dependencies: - dependency-name: cssnano dependency-version: 9.0.3 dependency-type: direct:development update-type: version-update:semver-major dependency-group: version-updates - dependency-name: postcss-cli dependency-version: 12.0.0 dependency-type: direct:development update-type: version-update:semver-major dependency-group: version-updates ... Signed-off-by: dependabot[bot] --- docs/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/package.json b/docs/package.json index 70a56d7..89a6d4b 100644 --- a/docs/package.json +++ b/docs/package.json @@ -39,14 +39,14 @@ }, "devDependencies": { "@rollup/plugin-terser": "^1.0.0", - "cssnano": "^8.0.2", + "cssnano": "^9.0.3", "grunt": "^1.6.1", "grunt-contrib-clean": "^2.0.1", "grunt-contrib-copy": "^1.0.0", "grunt-contrib-watch": "^1.1.0", "grunt-exec": "^3.0.0", "postcss": "^8.5.6", - "postcss-cli": "^11.0.1", + "postcss-cli": "^12.0.0", "rollup": "^4.59.0" } } From f33a2c4684da8b41a3f9ff8354f038a527d6beaf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 06:46:02 +0000 Subject: [PATCH 19/21] chore(deps-dev): bump cypress from 15.21.1 to 16.0.0 Bumps [cypress](https://github.com/cypress-io/cypress) from 15.21.1 to 16.0.0. - [Release notes](https://github.com/cypress-io/cypress/releases) - [Changelog](https://github.com/cypress-io/cypress/blob/develop/CHANGELOG.md) - [Commits](https://github.com/cypress-io/cypress/compare/v15.21.1...v16.0.0) --- updated-dependencies: - dependency-name: cypress dependency-version: 16.0.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2c3146c..65d0dc5 100644 --- a/package.json +++ b/package.json @@ -94,7 +94,7 @@ "commitlint": "^21.0.2", "concurrently": "^10.0.3", "cssnano": "^8.0.2", - "cypress": "^15.8.1", + "cypress": "^16.0.0", "cypress-plugin-tab": "^2.0.0", "doctoc": "^2.2.0", "dompurify": "^3.0.0", From 07670ceacd528208323e51a1ae49cf2edabb880a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:26:39 +0000 Subject: [PATCH 20/21] chore(deps-dev): bump cssnano from 8.0.10 to 9.0.4 Bumps [cssnano](https://github.com/cssnano/cssnano) from 8.0.10 to 9.0.4. - [Release notes](https://github.com/cssnano/cssnano/releases) - [Commits](https://github.com/cssnano/cssnano/compare/cssnano@8.0.10...cssnano@9.0.4) --- updated-dependencies: - dependency-name: cssnano dependency-version: 9.0.3 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 65d0dc5..316902d 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,7 @@ "@typescript-eslint/parser": "^8.58.0", "commitlint": "^21.0.2", "concurrently": "^10.0.3", - "cssnano": "^8.0.2", + "cssnano": "^9.0.4", "cypress": "^16.0.0", "cypress-plugin-tab": "^2.0.0", "doctoc": "^2.2.0", From af7c850091de08f2e9a578e596b31d7c44c850e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Alcaraz=20Mart=C3=ADnez?= Date: Sun, 18 Oct 2026 18:42:46 +0200 Subject: [PATCH 21/21] feat: integrate component-lifecycle library into Toggle component (#371) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: integrate component-lifecycle library into Toggle component Refactor Toggle to extend Component<"toggle"> from component-lifecycle, replacing the manual eventsBound flag and destroy() method with the library's deterministic state machine (idle → initialized → attached ↔ disposed → destroyed). This adds automatic lifecycle event emission (toggle:initialized, toggle:attached, toggle:disposed, toggle:destroyed), state introspection (isAttached, isDisposed, isDestroyed, isInitialized), and typed domain events via ExtendableEventMap. Lifecycle hooks (doInit, doAttach, doDispose, doDestroy) replace the inline constructor setup. The lifecycle is kicked off by the ECMAS and jQuery entry points with init().then(attach) to preserve the auto-initialization path via [data-toggle="toggle"]. rerender() now routes through dispose() then attach(), preserving instance identity while emitting lifecycle events. component-lifecycle is bundled into the UMD builds and externalized as a runtime dependency for the ESM/CJS dist builds. Implements #317 BREAKING CHANGE: on() and off() instance methods renamed to toggleOn() and toggleOff() to avoid collision with Component's event subscription API. The public element.bootstrapToggle("on") / ("off") wrapper API is unchanged. BREAKING CHANGE: destroy() and rerender() now return Promise. Callers must await the result to ensure teardown or rebuild completion before reinitializing. BREAKING CHANGE: constructing a Toggle no longer starts the lifecycle. Consumers using the class directly must call await toggle.init() then await toggle.attach(). Auto-initialization via element.bootstrapToggle() or [data-toggle="toggle"] continues to work via the fire-and-forget chain in the entry points. BREAKING CHANGE: interceptInputProperties() runs asynchronously during attach(). External input property changes (checked, disabled, readOnly, indeterminate) made synchronously after construction are not observed until the next microtask. Change the property after awaiting attach(), or trigger update() manually. * refactor: address sonar findings from component-lifecycle integration Two unrelated Sonar findings surfaced after the component-lifecycle integration (#317): 1. S1226 — doInit, doAttach, and doDispose only perform synchronous work and triggered "async functions should use await". Replace the async keyword with an explicit Promise.resolve({ cancelled: false }) return so the promise-returning signature required by the Component lifecycle contract is preserved without the lint violation. doDestroy keeps its async keyword because it conditionally awaits doDispose(). 2. Remove the redundant `const el = this` alias introduced in src/main/js/index.jquery.js; the alias was used twice and added no clarity. Use `this` directly, matching index.ecmas.ts. No behavioral change in either case. --- package.json | 9 + rollup.config.js | 5 + src/main/js/index.jquery.js | 13 +- src/main/ts/BootstrapToggle.ts | 416 ++++++++++++++-------------- src/main/ts/index.ecmas.ts | 14 +- src/main/ts/types/ToggleEvents.ts | 33 ++- src/test/ts/BootstrapToggle.test.ts | 250 +++++++++-------- 7 files changed, 409 insertions(+), 331 deletions(-) diff --git a/package.json b/package.json index 4e146d1..08ccb59 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,8 @@ "devDependencies": { "@commitlint/cli": "^21.0.2", "@commitlint/config-conventional": "^21.0.2", + "@rollup/plugin-commonjs": "^29.0.3", + "@rollup/plugin-node-resolve": "^16.0.3", "@rollup/plugin-terser": "^1.0.0", "@types/bootstrap": "^5.2.10", "@types/jest": "^30.0.0", @@ -122,5 +124,12 @@ }, "optionalDependencies": { "jquery": "^4.0.0" + }, + "dependencies": { + "component-lifecycle": "^2.0.0" + }, + "allowScripts": { + "cypress@16.2.0": true, + "unrs-resolver@1.11.1": true } } diff --git a/rollup.config.js b/rollup.config.js index 27bb668..c1c566b 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -1,4 +1,6 @@ import terser from "@rollup/plugin-terser"; +import { nodeResolve } from "@rollup/plugin-node-resolve"; +import commonjs from "@rollup/plugin-commonjs"; import dts from "rollup-plugin-dts"; import { bannerContent } from "./scripts/package-banner.js"; @@ -23,6 +25,7 @@ const umdECMAS = { banner, }, ], + plugins: [nodeResolve(), commonjs()], }; const umdJquery = { @@ -49,10 +52,12 @@ const umdJquery = { banner, }, ], + plugins: [nodeResolve(), commonjs()], }; const mainConfig = { input: "src/main/js/index.js", + external: ["component-lifecycle"], output: [ { file: "dist/bootstrap5-toggle.cjs", diff --git a/src/main/js/index.jquery.js b/src/main/js/index.jquery.js index d5b5815..7d67032 100644 --- a/src/main/js/index.jquery.js +++ b/src/main/js/index.jquery.js @@ -15,8 +15,13 @@ import { ToggleMethods as Methods } from "./types/ToggleMethods"; const optArg = Array.prototype.slice.call(arguments, 1)[0]; return ( this ).each(function () { - const $this = $(this); - let _bsToggle = this.bsToggle || new Toggle(this, (options && typeof options !== "string") ? options : {}); + let _bsToggle = this.bsToggle; + + if (!_bsToggle) { + _bsToggle = new Toggle(this, (options && typeof options !== "string") ? options : {}); + void _bsToggle.init().then(() => _bsToggle.attach()); + return; + } if (options && typeof options === "string") { switch (options.toLowerCase()) { @@ -24,10 +29,10 @@ import { ToggleMethods as Methods } from "./types/ToggleMethods"; _bsToggle.toggle(silent); break; case Methods.ON: - _bsToggle.on(silent); + _bsToggle.toggleOn(silent); break; case Methods.OFF: - _bsToggle.off(silent); + _bsToggle.toggleOff(silent); break; case Methods.INDETERMINATE: _bsToggle.indeterminate(silent); diff --git a/src/main/ts/BootstrapToggle.ts b/src/main/ts/BootstrapToggle.ts index 4aaf8fc..2b8d706 100644 --- a/src/main/ts/BootstrapToggle.ts +++ b/src/main/ts/BootstrapToggle.ts @@ -1,45 +1,97 @@ +import { Component } from "component-lifecycle"; import { DOMBuilder } from "./core/DOMBuilder"; import { OptionResolver } from "./core/OptionResolver"; import { ToggleOptions, UserOptions } from "./core/OptionResolver.types"; import { StateReducer } from "./core/StateReducer"; import { ToggleActionType, ToggleState, ToggleStateValue } from "./core/StateReducer.types"; -import ToggleEvents, { ToggleEventDetail } from "./types/ToggleEvents"; +import ToggleEvents, { ToggleEventMap } from "./types/ToggleEvents"; -export class Toggle { - private readonly element: HTMLInputElement & { bsToggle?: Toggle }; +export class Toggle extends Component<"toggle", ToggleEventMap> { + declare readonly element: HTMLInputElement & { bsToggle?: Toggle }; + protected readonly PREFIX = "toggle" as const; private readonly userOptions: UserOptions; - private options: ToggleOptions; - private stateReducer: StateReducer; - private domBuilder: DOMBuilder; + private toggleOptions: ToggleOptions; + private stateReducer!: StateReducer; + private domBuilder!: DOMBuilder; private pointer: { x: number; y: number } | null = null; private readonly SCROLL_THRESHOLD = 10; - private eventsBound = false; private suppressExternalSync = false; private readonly originalDescriptors = new Map(); /** - * Initializes a new instance of the BootstrapToggle class. - * @param element The HTMLInputElement element which represents the toggle. - * @param options The options for the toggle. - * @returns The constructed BootstrapToggle instance. - */ + * Initializes a new instance of the Toggle component. + * + * @param element The checkbox element backing the toggle. + * @param options User-provided options. + */ constructor(element: HTMLInputElement, options: UserOptions) { - this.element = element; + super(element, {bubbleEvents: true}); + this.userOptions = options; - this.options = OptionResolver.resolve(element, options); - this.stateReducer = new StateReducer(element, this.options.tristate); + this.toggleOptions = OptionResolver.resolve(element, options); + + this.element.bsToggle = this; + } + + /** + * Initializes internal state. No DOM access, no side effects. + * @returns {Promise<{ cancelled: boolean }>} transition result + */ + protected doInit(): Promise<{ cancelled: boolean }> { + this.stateReducer = new StateReducer( + this.element, + this.toggleOptions.tristate + ); + return Promise.resolve({ cancelled: false }); + } + + /** + * Binds the component to the DOM: builds the toggle DOM, binds listeners, + * intercepts input properties, and registers the instance on the element. + * @returns {Promise<{ cancelled: boolean }>} transition result + */ + protected doAttach(): Promise<{ cancelled: boolean }> { this.domBuilder = new DOMBuilder( - element, - this.options, + this.element, + this.toggleOptions, this.stateReducer.get() ); this.bindEventListeners(); this.interceptInputProperties(); - this.element.bsToggle = this; + return Promise.resolve({ cancelled: false }); + } + + /** + * Tears down everything created by doAttach(), including the toggle DOM. + * The original checkbox is restored to the DOM. + * @returns {Promise<{ cancelled: boolean }>} transition result + */ + protected doDispose(): Promise<{ cancelled: boolean }> { + this.unbindEventListeners(); + this.restoreInputProperties(); + this.domBuilder.destroy(); + return Promise.resolve({ cancelled: false }); + } + + /** + * Final teardown. Defensively disposes if still attached (see decision 5b). + * @returns {Promise<{ cancelled: boolean; reason?: string }>} transition result + */ + protected async doDestroy(): Promise<{ cancelled: boolean, reason?: string }> { + + if (this.isAttached()) { + try { + await this.doDispose(); + } catch (e) { + return { cancelled: true, reason: (e as Error).message }; + } + } + delete this.element.bsToggle; + return { cancelled: false }; } /** @@ -103,43 +155,28 @@ export class Toggle { }; /** - * Binds event listeners to the toggle element. - * This method is called by the constructor and is responsible for - * binding the following event listeners: - * - Pointer events (click, touchstart, touchend) - * - Keyboard events (keydown, keyup) - * - Label events (click) - * If the event listeners are already bound (i.e. this.eventsBound is true), - * this method does nothing. - * @returns void - */ + * Binds all event listeners for the toggle component, including form reset, pointer, keyboard, and label events. + * This method is called during the attachment phase of the component lifecycle. + * It ensures that the toggle component responds to user interactions and external changes. + */ private bindEventListeners() { - if (this.eventsBound) return; this.bindFormResetListener(); this.bindPointerEventListener(); this.bindKeyboardEventListener(); this.bindLabelEventListener(); - this.eventsBound = true; } + /** - * Unbinds all event listeners from the toggle element. - * This method is called by the destructor and is responsible for - * unbinding the following event listeners: - * - Pointer events (click, touchstart, touchend) - * - Keyboard events (keydown, keyup) - * - Label events (click) - * If the event listeners are not bound (i.e. this.eventsBound is false), - * this method does nothing. - * @returns void - */ + * Unbinds all event listeners for the toggle component, including form reset, pointer, keyboard, and label events. + * This method is called during the disposal phase of the component lifecycle. + * It ensures that the toggle component no longer responds to user interactions and external changes. + */ private unbindEventListeners() { - if (!this.eventsBound) return; this.unbindFormResetListener(); this.unbindPointerEventListener(); this.unbindKeyboardEventListener(); this.unbindLabelEventListener(); - this.eventsBound = false; } private bindFormResetListener() { @@ -159,12 +196,12 @@ export class Toggle { }; /** - * Binds a pointerdown event listener to the root element of the toggle. - * The event listener is responsible for handling pointer events (e.g. mouse clicks, touch events) - * and triggering the toggle's state change when a pointer event occurs. - * The event listener is bound with the passive option, which means that it will not block - * other event listeners from being triggered. - */ + * Binds a pointerdown event listener to the root element of the toggle. + * The event listener is responsible for handling pointer events (e.g. mouse clicks, touch events) + * and triggering the toggle's state change when a pointer event occurs. + * The event listener is bound with the passive option, which means that it will not block + * other event listeners from being triggered. + */ private bindPointerEventListener() { this.domBuilder.root.addEventListener("pointerdown", this.onPointerDown, { passive: true, @@ -172,25 +209,25 @@ export class Toggle { } /** - * Unbinds the pointerdown event listener from the root element of the toggle. - * This method is responsible for unbinding the pointerdown event listener that was - * previously bound by the bindPointerEventListener method. - * If the event listener is not bound (i.e. this.eventsBound is false), this method does nothing. - * @returns void - */ + * Unbinds the pointerdown event listener from the root element of the toggle. + * This method is responsible for unbinding the pointerdown event listener that was + * previously bound by the bindPointerEventListener method. + * If the event listener is not bound (i.e. this.eventsBound is false), this method does nothing. + * @returns void + */ private unbindPointerEventListener() { this.domBuilder.root.removeEventListener("pointerdown", this.onPointerDown); } /** - * Handles pointer down events by initiating the toggle action and setting up - * listeners for pointer movement, release, and cancellation. - * - * The method early exits if: - * - the pointer event is not a primary mouse button click - * - the toggle cannot be interacted with (`disabled` or `readonly`) - * @param e The PointerEvent object representing the pointer down event. - */ + * Handles pointer down events by initiating the toggle action and setting up + * listeners for pointer movement, release, and cancellation. + * + * The method early exits if: + * - the pointer event is not a primary mouse button click + * - the toggle cannot be interacted with (`disabled` or `readonly`) + * @param e The PointerEvent object representing the pointer down event. + */ private readonly onPointerDown = (e: PointerEvent) => { if (e.pointerType === "mouse" && e.button !== 0) return; if (!this.stateReducer.canInteract()) return; @@ -210,12 +247,12 @@ export class Toggle { }; /** - * Handles pointer move events by checking the distance moved from the initial pointer down position. - * If the pointer has moved beyond a certain threshold, the pointer interaction is cancelled. - * - * Allows dragging within the width of the toggle but cancels if vertical movement exceeds the scroll threshold. - * @param e The PointerEvent object representing the pointer move event. - */ + * Handles pointer move events by checking the distance moved from the initial pointer down position. + * If the pointer has moved beyond a certain threshold, the pointer interaction is cancelled. + * + * Allows dragging within the width of the toggle but cancels if vertical movement exceeds the scroll threshold. + * @param e The PointerEvent object representing the pointer move event. + */ private readonly onPointerMove = (e: PointerEvent) => { const dx = Math.abs(e.clientX - this.pointer!.x); const dy = Math.abs(e.clientY - this.pointer!.y); @@ -226,16 +263,16 @@ export class Toggle { }; /** - * Handles pointer up events by determining if the pointer interaction - * should trigger a toggle action based on the distance moved. - * - * If the pointer has moved beyond a certain threshold, the pointer interaction is cancelled. - * Allows dragging within the width of the toggle but cancels if vertical movement exceeds the scroll threshold. - * Finally, it cleans up by calling the pointer cancel handler. - * - * If the pointer event is not a primary mouse button click, the interaction is cancelled. - * @param e The PointerEvent object representing the pointer up event. - */ + * Handles pointer up events by determining if the pointer interaction + * should trigger a toggle action based on the distance moved. + * + * If the pointer has moved beyond a certain threshold, the pointer interaction is cancelled. + * Allows dragging within the width of the toggle but cancels if vertical movement exceeds the scroll threshold. + * Finally, it cleans up by calling the pointer cancel handler. + * + * If the pointer event is not a primary mouse button click, the interaction is cancelled. + * @param e The PointerEvent object representing the pointer up event. + */ private readonly onPointerUp = (e: PointerEvent) => { if (e.pointerType === "mouse" && e.button !== 0) { this.onPointerCancel(); @@ -252,12 +289,12 @@ export class Toggle { }; /** - * Cleans up pointer event listeners after a pointer interaction is completed or cancelled. - * - * This method removes the `pointermove`, `pointerup`, and `pointercancel` event listeners - * from the root element of the toggle. - * However, `pointerdown` listener remains active for future interactions. - */ + * Cleans up pointer event listeners after a pointer interaction is completed or cancelled. + * + * This method removes the `pointermove`, `pointerup`, and `pointercancel` event listeners + * from the root element of the toggle. + * However, `pointerdown` listener remains active for future interactions. + */ private readonly onPointerCancel = () => { this.domBuilder.root.removeEventListener("pointermove", this.onPointerMove); this.domBuilder.root.removeEventListener("pointerup", this.onPointerUp); @@ -268,10 +305,10 @@ export class Toggle { }; /** - * Binds a keydown event listener to the root element of the toggle. - * The event listener is responsible for handling keydown events - * and triggering the toggle's state change when a keydown event occurs. - */ + * Binds a keydown event listener to the root element of the toggle. + * The event listener is responsible for handling keydown events + * and triggering the toggle's state change when a keydown event occurs. + */ private bindKeyboardEventListener() { this.domBuilder.root.addEventListener( "keydown", @@ -281,12 +318,12 @@ export class Toggle { } /** - * Unbinds the keydown event listener from the root element of the toggle. - * This method is responsible for unbinding the keydown event listener that was - * previously bound by the bindKeyboardEventListener method. - * If the event listener is not bound (i.e. this.eventsBound is false), this method does nothing. - * @returns void - */ + * Unbinds the keydown event listener from the root element of the toggle. + * This method is responsible for unbinding the keydown event listener that was + * previously bound by the bindKeyboardEventListener method. + * If the event listener is not bound (i.e. this.eventsBound is false), this method does nothing. + * @returns void + */ private unbindKeyboardEventListener() { this.domBuilder.root.removeEventListener( "keydown", @@ -301,13 +338,13 @@ export class Toggle { }; /** - * Binds a click event listener to all labels that are associated with the toggle's input element. - * The event listener is responsible for handling click events and triggering the toggle's state change when a click event occurs. - * The event listener is bound with the passive option set to false, which means that it will block other event listeners from being triggered until it has finished its execution. - * This method is called by the constructor and is responsible for binding the event listener to the toggle's labels. - * If the toggle's input element does not have an id (i.e. this.element.id is null or undefined), this method does nothing. - * @returns void - */ + * Binds a click event listener to all labels that are associated with the toggle's input element. + * The event listener is responsible for handling click events and triggering the toggle's state change when a click event occurs. + * The event listener is bound with the passive option set to false, which means that it will block other event listeners from being triggered until it has finished its execution. + * This method is called by the constructor and is responsible for binding the event listener to the toggle's labels. + * If the toggle's input element does not have an id (i.e. this.element.id is null or undefined), this method does nothing. + * @returns void + */ private bindLabelEventListener() { if (this.element.id) { document @@ -321,11 +358,11 @@ export class Toggle { } /** - * Unbinds the click event listener from all labels that are associated with the toggle's input element. - * This method is responsible for unbinding the event listener that was previously bound by the bindLabelEventListener method. - * If the toggle's input element does not have an id (i.e. this.element.id is null or undefined), this method does nothing. - * @returns void - */ + * Unbinds the click event listener from all labels that are associated with the toggle's input element. + * This method is responsible for unbinding the event listener that was previously bound by the bindLabelEventListener method. + * If the toggle's input element does not have an id (i.e. this.element.id is null or undefined), this method does nothing. + * @returns void + */ private unbindLabelEventListener() { if (this.element.id) { document @@ -343,12 +380,12 @@ export class Toggle { }; /** - * Applies a toggle action to the toggle state and renders the toggle element. - * If the action is successful, this method will render the toggle element with the new state. - * If the silent parameter is false, this method will also trigger the change event. - * @param action The toggle action to apply. - * @param silent A boolean indicating whether to trigger the change event after applying the action. - */ + * Applies a toggle action to the toggle state and renders the toggle element. + * If the action is successful, this method will render the toggle element with the new state. + * If the silent parameter is false, this method will also trigger the change event. + * @param action The toggle action to apply. + * @param silent A boolean indicating whether to trigger the change event after applying the action. + */ private apply(action: ToggleActionType, silent = false) { if (!this.stateReducer.do(action)) return; this.suppressExternalSync = true; @@ -362,81 +399,81 @@ export class Toggle { } /** - * Toggles the state of the toggle. - * If the toggle is currently in the on state, it will be set to the off state. - * If the toggle is currently in the off state, it will be set to the on state. - * If the toggle is currently in the indeterminate state, it will be set to the on state. - * If the silent parameter is false, this method will also trigger the change event. - * @param silent A boolean indicating whether to trigger the change event after applying the action. - */ + * Toggles the state of the toggle. + * If the toggle is currently in the on state, it will be set to the off state. + * If the toggle is currently in the off state, it will be set to the on state. + * If the toggle is currently in the indeterminate state, it will be set to the on state. + * If the silent parameter is false, this method will also trigger the change event. + * @param silent A boolean indicating whether to trigger the change event after applying the action. + */ toggle(silent = false) { this.apply(ToggleActionType.TOGGLE, silent); } /** - * Sets the toggle state to on. - * If the silent parameter is false, this method will also trigger the change event. - * @param silent A boolean indicating whether to trigger the change event after applying the action. - */ - on(silent = false) { + * Sets the toggle state to on. + * If the silent parameter is false, this method will also trigger the change event. + * @param silent A boolean indicating whether to trigger the change event after applying the action. + */ + toggleOn(silent = false) { this.apply(ToggleActionType.ON, silent); } /** - * Sets the toggle state to off. - * If the silent parameter is false, this method will also trigger the change event. - * @param silent A boolean indicating whether to trigger the change event after applying the action. - */ - off(silent = false) { + * Sets the toggle state to off. + * If the silent parameter is false, this method will also trigger the change event. + * @param silent A boolean indicating whether to trigger the change event after applying the action. + */ + toggleOff(silent = false) { this.apply(ToggleActionType.OFF, silent); } /** - * Sets the toggle state to indeterminate. - * If the silent parameter is false, this method will also trigger the change event. - * @param {boolean} silent A boolean indicating whether to trigger the change event after applying the action. - */ + * Sets the toggle state to indeterminate. + * If the silent parameter is false, this method will also trigger the change event. + * @param {boolean} silent A boolean indicating whether to trigger the change event after applying the action. + */ indeterminate(silent = false) { this.apply(ToggleActionType.INDETERMINATE, silent); } /** - * Sets the toggle state to determinate. - * If the silent parameter is false, this method will also trigger the change event. - * @param {boolean} silent A boolean indicating whether to trigger the change event after applying the action. - */ + * Sets the toggle state to determinate. + * If the silent parameter is false, this method will also trigger the change event. + * @param {boolean} silent A boolean indicating whether to trigger the change event after applying the action. + */ determinate(silent = false) { this.apply(ToggleActionType.DETERMINATE, silent); } /** - * Enables the toggle. - * If the toggle is currently disabled, this method will set the toggle state to enabled. - * If the silent parameter is false, this method will also trigger the change event. - * @param {boolean} silent A boolean indicating whether to trigger the change event after applying the action. - * @returns void - */ + * Enables the toggle. + * If the toggle is currently disabled, this method will set the toggle state to enabled. + * If the silent parameter is false, this method will also trigger the change event. + * @param {boolean} silent A boolean indicating whether to trigger the change event after applying the action. + * @returns void + */ enable(silent = false) { this.apply(ToggleActionType.ENABLE, silent); } /** - * Disables the toggle. - * If the toggle is currently enabled, this method will set the toggle state to disabled. - * If the silent parameter is false, this method will also trigger the change event. - * @param {boolean} silent A boolean indicating whether to trigger the change event after applying the action. - */ + * Disables the toggle. + * If the toggle is currently enabled, this method will set the toggle state to disabled. + * If the silent parameter is false, this method will also trigger the change event. + * @param {boolean} silent A boolean indicating whether to trigger the change event after applying the action. + */ disable(silent = false) { this.apply(ToggleActionType.DISABLE, silent); } /** - * Sets the toggle state to readonly. - * If the toggle is currently disabled or enabled, this method will set the toggle state to readonly. - * If the silent parameter is false, this method will also trigger the change event. - * @param {boolean} silent A boolean indicating whether to trigger the change event after applying the action. - * @returns void - */ + * Sets the toggle state to readonly. + * If the toggle is currently disabled or enabled, this method will set the toggle state to readonly. + * If the silent parameter is false, this method will also trigger the change event. + * @param {boolean} silent A boolean indicating whether to trigger the change event after applying the action. + * @returns void + */ readonly(silent = false) { this.apply(ToggleActionType.READONLY, silent); } @@ -464,15 +501,10 @@ export class Toggle { this.element.dispatchEvent(new Event("change", { bubbles: true })); const eventName = this.getEventForAction(action, state); - const detail: ToggleEventDetail = { state: state }; - this.element.dispatchEvent( - new CustomEvent(eventName, { - bubbles: true, - detail: detail - }) - ); + this.emit(eventName, { state }); } + /** * Returns the corresponding toggle event for the given toggle action and state. @@ -519,54 +551,24 @@ export class Toggle { return ToggleEvents.MIXED; } } - - /** - * Destroys the toggle element and unbinds all event listeners. - *This method is useful when you need to remove the toggle element from the DOM. - *After calling this method, the toggle element will be removed from the DOM and all event listeners will be unbound. - */ - destroy() { - this.restoreInputProperties(); - this.unbindEventListeners(); - this.domBuilder.destroy(); - delete this.element.bsToggle; - } /** - * Rebuilds the toggle component in-place without destroying the current instance. - * - * This method is useful when you need to reinitialize the toggle element with the same options, - * for example after HTML attribute changes or when the DOM structure has been modified externally. - * - * The method follows this sequence: - * 1. Restores original input properties (checked, disabled, readOnly, indeterminate) - * 2. Unbinds all event listeners (pointer, keyboard, label, form reset) - * 3. Destroys the DOM builder (removes toggle DOM, restores original checkbox) - * 4. Re-resolves options from the element and user options - * 5. Creates a fresh StateReducer with the updated tristate value - * 6. Builds new toggle DOM structure - * 7. Re-binds all event listeners - * 8. Re-intercepts input properties for external change detection - * - * Important: The instance identity is preserved (this.element.bsToggle remains unchanged), - * making it safe for external code that holds references to this instance. - * - * @returns void + * Rebuilds the toggle in-place, preserving instance identity. + * Emits `toggle:disposed` and `toggle:attached` lifecycle events. + * + * Sequence: + * 1. dispose() → doDispose() unbinds listeners, restores props, tears down DOM + * 2. re-resolve options from element (may have changed) + * 3. attach() → doAttach() rebuilds DOM and re-binds listeners + * + * @returns {Promise} resolves when re-attached. */ - rerender() { - this.restoreInputProperties(); - this.unbindEventListeners(); - this.domBuilder.destroy(); - - this.options = OptionResolver.resolve(this.element, this.userOptions); - this.stateReducer = new StateReducer(this.element, this.options.tristate); - this.domBuilder = new DOMBuilder( - this.element, - this.options, - this.stateReducer.get() - ); - - this.bindEventListeners(); - this.interceptInputProperties(); + async rerender(): Promise { + await this.dispose(); + + this.toggleOptions = OptionResolver.resolve(this.element, this.userOptions); + this.stateReducer = new StateReducer(this.element, this.toggleOptions.tristate); + + await this.attach(); } } diff --git a/src/main/ts/index.ecmas.ts b/src/main/ts/index.ecmas.ts index 8aebceb..672ac60 100644 --- a/src/main/ts/index.ecmas.ts +++ b/src/main/ts/index.ecmas.ts @@ -18,17 +18,23 @@ import { BootstrapToggleElement } from "./types/BootstrapToggleElement"; * Enables execution when used with HTML - ex: `document.getElementById('toggle').bootstrapToggle('on')` */ HTMLInputElement.prototype.bootstrapToggle = function (options?: UserOptions | Methods, silent?: boolean) { - let _bsToggle = (this as BootstrapToggleElement).bsToggle || new BootstrapToggle(this, (options && typeof options !== "string") ? options : {}); + const el = this as BootstrapToggleElement; + let _bsToggle = el.bsToggle; - // Execute method calls + if (!_bsToggle) { + _bsToggle = new BootstrapToggle(this, (options && typeof options !== "string") ? options : {}); + void _bsToggle.init().then(() => _bsToggle!.attach()); + return; + } + if (options && typeof options === "string") { switch (options.toLowerCase()) { case Methods.TOGGLE: return _bsToggle.toggle(silent); case Methods.ON: - return _bsToggle.on(silent); + return _bsToggle.toggleOn(silent); case Methods.OFF: - return _bsToggle.off(silent); + return _bsToggle.toggleOff(silent); case Methods.INDETERMINATE: return _bsToggle.indeterminate(silent); case Methods.DETERMINATE: diff --git a/src/main/ts/types/ToggleEvents.ts b/src/main/ts/types/ToggleEvents.ts index 849b2df..b920967 100644 --- a/src/main/ts/types/ToggleEvents.ts +++ b/src/main/ts/types/ToggleEvents.ts @@ -1,16 +1,37 @@ +import { ExtendableEventMap } from "component-lifecycle"; import { ToggleState } from "../core/StateReducer.types"; enum ToggleEvents{ - ON = "toggle:on", - OFF = "toggle:off", - MIXED = "toggle:mixed", - ENABLED = "toggle:enabled", - DISABLED = "toggle:disabled", - READONLY = "toggle:readonly" + ON = "on", + OFF = "off", + MIXED = "mixed", + ENABLED = "enabled", + DISABLED = "disabled", + READONLY = "readonly" } export interface ToggleEventDetail { state: ToggleState; } +/** + * Domain events emitted by the Toggle component. + * Keys are the un-prefixed event names; the prefix "toggle:" is added by the base class. + */ +export type ToggleDomainEvents = { + "on": ToggleEventDetail; + "off": ToggleEventDetail; + "mixed": ToggleEventDetail; + "enabled": ToggleEventDetail; + "disabled": ToggleEventDetail; + "readonly": ToggleEventDetail; +}; + +/** + * Full event map of the Toggle component. + * Includes all lifecycle events (initialized, attached, disposed, destroyed, transition-*) + * plus the domain events on/off/mixed/enabled/disabled/readonly. + */ +export type ToggleEventMap = ExtendableEventMap<"toggle", ToggleDomainEvents>; + export default ToggleEvents; \ No newline at end of file diff --git a/src/test/ts/BootstrapToggle.test.ts b/src/test/ts/BootstrapToggle.test.ts index c46d59f..fce0794 100644 --- a/src/test/ts/BootstrapToggle.test.ts +++ b/src/test/ts/BootstrapToggle.test.ts @@ -14,7 +14,7 @@ import ToggleEvents from "../../main/ts/types/ToggleEvents"; const renderMock = jest.fn(); const destroyMock = jest.fn(); const doMock = jest.fn(() => true); -const getMock = jest.fn(() => ({})); +const getMock = jest.fn(() => ({ value: "on" })); const syncMock = jest.fn(); const canInteractMock = jest.fn(() => true); @@ -61,6 +61,13 @@ jest.mock("../../main/ts/core/OptionResolver", () => ({ }, })); +async function attachToggleToInput(input: HTMLInputElement, options: Record = {}) { + const toggle = new Toggle(input, options); + await toggle.init(); + await toggle.attach(); + return toggle; +} + /* ========================= Tests ========================= */ @@ -87,49 +94,69 @@ describe("Toggle", () => { expect((input as any).bsToggle).toBe(toggle); }); - it("bind events listeners", () => { - input.id = "test-toggle"; - const label = document.createElement("label"); - label.setAttribute("for", "test-toggle"); - document.body.appendChild(label); + it("does NOT auto-start lifecycle", async () => { + const initSpy = jest.spyOn(Toggle.prototype, "init"); + const attachSpy = jest.spyOn(Toggle.prototype, "attach"); + const _ = new Toggle(input, {}); - const addEventListenerSpyDiv = jest.spyOn( - HTMLDivElement.prototype, - "addEventListener" - ); + await Promise.resolve(); - const addEventListenerSpyLabel = jest.spyOn( - HTMLLabelElement.prototype, - "addEventListener" - ); + expect(initSpy).not.toHaveBeenCalled(); + expect(attachSpy).not.toHaveBeenCalled(); + }); + }); - const _ = new Toggle(input, {}); + describe("lifecycle", () => { + it("init() → initialized state, doInit() creates stateReducer", async () => { + const toggle = new Toggle(input, {}); + await toggle.init(); + expect(toggle.isInitialized()).toBe(true); + expect((toggle as any).stateReducer).toBeDefined(); + }); - expect(addEventListenerSpyDiv).toHaveBeenCalledWith( - "pointerdown", - expect.any(Function), - expect.any(Object) - ); - expect(addEventListenerSpyDiv).toHaveBeenCalledWith( - "keydown", - expect.any(Function), - expect.any(Object) - ); + it("attach() → attached state, doAttach() builds DOM and binds listeners", async () => { + const toggle = new Toggle(input, {}); + const addDiv = jest.spyOn(HTMLDivElement.prototype, "addEventListener"); + await toggle.init(); + await toggle.attach(); + expect(toggle.isAttached()).toBe(true); + expect(addDiv).toHaveBeenCalledWith("pointerdown", expect.any(Function), expect.any(Object)); + expect(addDiv).toHaveBeenCalledWith("keydown", expect.any(Function), expect.any(Object)); + addDiv.mockRestore(); + }); - expect(addEventListenerSpyLabel).toHaveBeenCalledWith( - "click", - expect.any(Function), - expect.objectContaining({ passive: false }) - ); + it("dispose() → disposed state, doDispose() unbinds and destroys DOM", async () => { + const toggle = new Toggle(input, {}); + await toggle.init(); + await toggle.attach(); + await toggle.dispose(); + expect(toggle.isDisposed()).toBe(true); + expect(document.querySelector(".toggle")).toBeNull(); + }); - addEventListenerSpyDiv.mockRestore(); - addEventListenerSpyLabel.mockRestore(); + it("destroy() → destroyed state, doDestroy() deletes bsToggle", async () => { + const toggle = new Toggle(input, {}); + await toggle.init(); + await toggle.attach(); + await toggle.destroy(); + expect(toggle.isDestroyed()).toBe(true); + expect((input as any).bsToggle).toBeUndefined(); + }); + + it("destroy() from attached runs dispose() first then destroy()", async () => { + const toggle = new Toggle(input, {}); + await toggle.init(); + await toggle.attach(); + const disposeSpy = jest.spyOn(toggle as any, "doDispose"); + await toggle.destroy(); + expect(disposeSpy).toHaveBeenCalled(); + expect(toggle.isDestroyed()).toBe(true); }); }); describe("interceptInputProperties()", () => { - it("reacts to checked property change", () => { - const toggle = new Toggle(input, {}); + it("reacts to checked property change", async () => { + const toggle = await attachToggleToInput(input); const spy = jest.spyOn(toggle as any, "onExternalChange"); input.checked = true; @@ -137,8 +164,8 @@ describe("Toggle", () => { expect(spy).toHaveBeenCalled(); }); - it("reacts to disabled property change", () => { - const toggle = new Toggle(input, {}); + it("reacts to disabled property change", async () => { + const toggle = await attachToggleToInput(input); const spy = jest.spyOn(toggle as any, "onExternalChange"); input.disabled = true; @@ -146,8 +173,8 @@ describe("Toggle", () => { expect(spy).toHaveBeenCalled(); }); - it("does not throw when setting same value", () => { - const _ = new Toggle(input, {}); + it("does not throw when setting same value", async () => { + const _ = await attachToggleToInput(input); expect(() => { input.checked = false; @@ -159,8 +186,8 @@ describe("Toggle", () => { describe("Pointer interactions", () => { let root: HTMLElement; - beforeEach(() => { - const toggle = new Toggle(input, {}); + beforeEach(async () => { + const toggle = await attachToggleToInput(input); root = (DOMBuilder as any).mock.results[0].value.root; jest.spyOn(toggle as any, "apply"); }); @@ -301,8 +328,8 @@ describe("Toggle", () => { describe("Keyboard interactions", () => { let root: HTMLElement; - beforeEach(() => { - const _ = new Toggle(input, {}); + beforeEach(async () => { + const _ = await attachToggleToInput(input); root = (DOMBuilder as any).mock.results[0].value.root; }); @@ -332,14 +359,14 @@ describe("Toggle", () => { }); describe("Label interaction", () => { - it("clicking label toggles and focuses root", () => { + it("clicking label toggles and focuses root", async () => { input.id = "toggle-id"; const label = document.createElement("label"); label.setAttribute("for", "toggle-id"); document.body.appendChild(label); - const _ = new Toggle(input, {}); + const _ = await attachToggleToInput(input); const root = (DOMBuilder as any).mock.results[0].value.root; const focusSpy = jest.spyOn(root, "focus"); @@ -351,14 +378,14 @@ describe("Toggle", () => { }); describe("form reset handling", () => { - it("syncs after form reset", () => { + it("syncs after form reset", async () => { jest.useFakeTimers(); const form = document.createElement("form"); form.appendChild(input); document.body.appendChild(form); - const toggle = new Toggle(input, {}); + const toggle = await attachToggleToInput(input); const spy = jest.spyOn(toggle as any, "onExternalChange"); form.reset(); @@ -373,11 +400,9 @@ describe("Toggle", () => { }); }); - - describe("apply(action: ToggleActionType, silent = false)", () => { - it("calls StateReducer.do and DOMBuilder.render", () => { - const toggle = new Toggle(input, {}); + it("calls StateReducer.do and DOMBuilder.render", async () => { + const toggle = await attachToggleToInput(input); (toggle as any).apply(ToggleActionType.TOGGLE); @@ -385,8 +410,8 @@ describe("Toggle", () => { expect(renderMock).toHaveBeenCalled(); }); - it("dispatches change event when not silent", () => { - const toggle = new Toggle(input, {}); + it("dispatches change event when not silent", async () => { + const toggle = await attachToggleToInput(input); const spy = jest.fn(); input.addEventListener("change", spy); @@ -396,8 +421,8 @@ describe("Toggle", () => { expect(spy).toHaveBeenCalled(); }); - it("does not dispatch change event when silent", () => { - const toggle = new Toggle(input, {}); + it("does not dispatch change event when silent", async () => { + const toggle = await attachToggleToInput(input); const spy = jest.fn(); input.addEventListener("change", spy); @@ -407,8 +432,8 @@ describe("Toggle", () => { expect(spy).not.toHaveBeenCalled(); }); - it("guard against sync loops:does not react to internal apply-triggered input change", () => { - const toggle = new Toggle(input, {}); + it("guard against sync loops:does not react to internal apply-triggered input change", async () => { + const toggle = await attachToggleToInput(input); const spy = jest.spyOn(toggle as any, "onExternalChange"); (toggle as any).apply(ToggleActionType.TOGGLE); @@ -471,17 +496,24 @@ describe("Toggle", () => { action: ToggleActionType.READONLY, }]; - it.each(customEventsCases)("dispatches $eventName event for $action action", ({ eventName, action}) => { + it.each(customEventsCases)("dispatches $eventName event for $action action", async ({ eventName, action}) => { input.disabled = action === ToggleActionType.ENABLE; input.checked = action !== ToggleActionType.ON; - const toggle = new Toggle(input, {}); + const toggle = await attachToggleToInput(input); (toggle as any).apply(action, false); customEventsCases.forEach((evtSpy)=>{ if(evtSpy.eventName === eventName){ expect(evtSpy.eventSpy()).toHaveBeenCalled(); + const eventDetail = evtSpy.eventSpy().mock.calls[0][0].detail; + expect(eventDetail).toEqual({ + state: expect.objectContaining({ + value: expect.any(String), + }), + }); + expect(eventDetail.state).toEqual({ value: "on" }); } else { expect(evtSpy.eventSpy()).not.toHaveBeenCalled(); } @@ -514,14 +546,14 @@ describe("Toggle", () => { mockStateValue: ToggleStateValue.MIXED, }]; - it.each(valueBasedEventCases)("dispatches $eventName event for $action action based on state value $mockStateValue", ({ eventName, action, mockStateValue}) => { + it.each(valueBasedEventCases)("dispatches $eventName event for $action action based on state value $mockStateValue", async ({ eventName, action, mockStateValue}) => { const mockState = { value: mockStateValue }; getMock.mockReturnValue(mockState); input.disabled = action === ToggleActionType.ENABLE; input.checked = action !== ToggleActionType.ON; - const toggle = new Toggle(input, {}); + const toggle = await attachToggleToInput(input); (toggle as any).apply(action, false); @@ -547,8 +579,8 @@ describe("Toggle", () => { apply.mockRestore(); }); - it("toggle(silent=false)", () => { - const toggle = new Toggle(input, {}); + it("toggle(silent=false)", async () => { + const toggle = await attachToggleToInput(input); toggle.toggle(); expect(apply).toHaveBeenCalledWith(ToggleActionType.TOGGLE, false); @@ -557,28 +589,28 @@ describe("Toggle", () => { expect(apply).toHaveBeenCalledWith(ToggleActionType.TOGGLE, true); }); - it("on(silent=false)", () => { - const toggle = new Toggle(input, {}); + it("toggleOn(silent=false)", async () => { + const toggle = await attachToggleToInput(input); - toggle.on(); + toggle.toggleOn(); expect(apply).toHaveBeenCalledWith(ToggleActionType.ON, false); - toggle.on(true); + toggle.toggleOn(true); expect(apply).toHaveBeenCalledWith(ToggleActionType.ON, true); }); - it("off(silent=false)", () => { - const toggle = new Toggle(input, {}); + it("toggleOff(silent=false)", async () => { + const toggle = await attachToggleToInput(input); - toggle.off(); + toggle.toggleOff(); expect(apply).toHaveBeenCalledWith(ToggleActionType.OFF, false); - toggle.off(true); + toggle.toggleOff(true); expect(apply).toHaveBeenCalledWith(ToggleActionType.OFF, true); }); - it("determinate(silent=false)", () => { - const toggle = new Toggle(input, {}); + it("determinate(silent=false)", async () => { + const toggle = await attachToggleToInput(input); toggle.determinate(); expect(apply).toHaveBeenCalledWith(ToggleActionType.DETERMINATE, false); @@ -587,8 +619,8 @@ describe("Toggle", () => { expect(apply).toHaveBeenCalledWith(ToggleActionType.DETERMINATE, true); }); - it("indeterminate(silent=false)", () => { - const toggle = new Toggle(input, {}); + it("indeterminate(silent=false)", async () => { + const toggle = await attachToggleToInput(input); toggle.indeterminate(); expect(apply).toHaveBeenCalledWith(ToggleActionType.INDETERMINATE, false); @@ -597,8 +629,8 @@ describe("Toggle", () => { expect(apply).toHaveBeenCalledWith(ToggleActionType.INDETERMINATE, true); }); - it("enable(silent=false)", () => { - const toggle = new Toggle(input, {}); + it("enable(silent=false)", async () => { + const toggle = await attachToggleToInput(input); toggle.enable(); expect(apply).toHaveBeenCalledWith(ToggleActionType.ENABLE, false); @@ -607,8 +639,8 @@ describe("Toggle", () => { expect(apply).toHaveBeenCalledWith(ToggleActionType.ENABLE, true); }); - it("disable(silent=false)", () => { - const toggle = new Toggle(input, {}); + it("disable(silent=false)", async () => { + const toggle = await attachToggleToInput(input); toggle.disable(); expect(apply).toHaveBeenCalledWith(ToggleActionType.DISABLE, false); @@ -617,8 +649,8 @@ describe("Toggle", () => { expect(apply).toHaveBeenCalledWith(ToggleActionType.DISABLE, true); }); - it("readonly(silent=false)", () => { - const toggle = new Toggle(input, {}); + it("readonly(silent=false)", async () => { + const toggle = await attachToggleToInput(input); toggle.readonly(); expect(apply).toHaveBeenCalledWith(ToggleActionType.READONLY, false); @@ -629,10 +661,10 @@ describe("Toggle", () => { }); describe("update()", () => { - it("syncs state and renders on update()", () => { - const toggle = new Toggle(input, {}); + it("syncs state and renders on update()", async () => { + const toggle = await attachToggleToInput(input); - toggle.update(); + await toggle.update(); expect(syncMock).toHaveBeenCalledWith(input); expect(renderMock).toHaveBeenCalled(); @@ -640,23 +672,23 @@ describe("Toggle", () => { }); describe("destroy()", () => { - it("destroys DOM and unlink instance on destroy()", () => { - const toggle = new Toggle(input, {}); + it("destroys DOM and unlink instance on destroy()", async () => { + const toggle = await attachToggleToInput(input); - toggle.destroy(); + await toggle.destroy(); expect(destroyMock).toHaveBeenCalled(); expect((input as any).bsToggle).toBeUndefined(); }); - it("restores native input descriptors allowing reinitialization", () => { - const toggle1 = new Toggle(input, {}); + it("restores native input descriptors allowing reinitialization", async () => { + const toggle1 = await attachToggleToInput(input); toggle1.destroy(); - expect(() => { - const _ = new Toggle(input, {}); + expect(async () => { + const _ = await attachToggleToInput(input); }).not.toThrow(); }); - it("unbind events listeners", () => { + it("unbind events listeners", async () => { input.id = "test-toggle"; const label = document.createElement("label"); @@ -682,7 +714,7 @@ describe("Toggle", () => { "removeEventListener" ); - const toggle = new Toggle(input, {}); + const toggle = await attachToggleToInput(input); toggle.destroy(); expect(removeEventListenerSpyDiv).toHaveBeenCalledWith( @@ -710,31 +742,29 @@ describe("Toggle", () => { }); }); - describe("rerender()", () =>{ - it("rebuilds toggle in-place while preserving instance identity", () => { - const toggle = new Toggle(input, {}); - const instanceRef = toggle; - - const originalBsToggle = (input as any).bsToggle; - - toggle.rerender(); - - expect(toggle).toBe(instanceRef); - expect((input as any).bsToggle).toBe(originalBsToggle); - expect((input as any).bsToggle).toBe(toggle); - - expect(destroyMock).toHaveBeenCalled(); - expect(DOMBuilder).toHaveBeenCalledTimes(2); + describe("rerender()", () => { + it("routes through dispose() → attach() (emits lifecycle events)", async () => { + const toggle = await attachToggleToInput(input); + + const disposed = jest.fn(); + const attached = jest.fn(); + toggle.on("toggle:disposed", disposed).on("toggle:attached", attached); + + await toggle.rerender(); + + expect(disposed).toHaveBeenCalledTimes(1); + expect(attached).toHaveBeenCalledTimes(1); + expect(toggle.isAttached()).toBe(true); }); - it("re-resolves options from HTML attributes after change", () => { + it("re-resolves options from HTML attributes after change", async () => { - const toggle = new Toggle(input, { onlabel: "Old" }); + const toggle = await attachToggleToInput(input, { onlabel: "Old" }); const resolveSpy = jest.spyOn(OptionResolver, "resolve"); input.dataset.onlabel = "New"; - toggle.rerender(); + await toggle.rerender(); expect(resolveSpy).toHaveBeenCalledWith(input, toggle["userOptions"]); resolveSpy.mockRestore();