From 2d5a94dbf1e55af5d1a5f45f6222357990a2d1c8 Mon Sep 17 00:00:00 2001 From: Zaiid Moumni <141942826+Zaiidmo@users.noreply.github.com> Date: Tue, 24 Feb 2026 11:07:27 +0000 Subject: [PATCH 1/3] Develop (#1) * ops: updated workflows and unit tests coverage rules * chore: update test coverage rules * chore: setting up env * chore: fixed prettier format errors * ops: updated workflow trigger * 0.0.1 --- .github/workflows/ci-release-check.yml | 66 -- ...ci-pr-validation.yml => pr-validation.yml} | 2 - .../workflows/{cd-release.yml => publish.yml} | 15 +- .github/workflows/release-check.yml | 83 ++ .husky/_/.gitignore | 2 +- package-lock.json | 800 +++++++++++++++++- package.json | 18 +- src/__tests__/setup.ts | 9 + src/index.ts | 6 +- tsup.config.ts | 8 +- vitest.config.ts | 21 +- 11 files changed, 927 insertions(+), 103 deletions(-) delete mode 100644 .github/workflows/ci-release-check.yml rename .github/workflows/{ci-pr-validation.yml => pr-validation.yml} (95%) rename .github/workflows/{cd-release.yml => publish.yml} (71%) create mode 100644 .github/workflows/release-check.yml create mode 100644 src/__tests__/setup.ts diff --git a/.github/workflows/ci-release-check.yml b/.github/workflows/ci-release-check.yml deleted file mode 100644 index a49ce6b..0000000 --- a/.github/workflows/ci-release-check.yml +++ /dev/null @@ -1,66 +0,0 @@ -name: CI - Release Check - -on: - pull_request: - branches: [master] - -permissions: - contents: read - -env: - SONAR_ENABLED: 'false' # set to "true" in real repos - -jobs: - release-check: - name: CI - Release Check - # Only run when PR is from develop -> master - if: github.head_ref == 'develop' - runs-on: ubuntu-latest - - steps: - - name: Checkout (full history for Sonar) - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - - - name: Install - run: npm ci - - - name: Format (check) - run: npm run format - - - name: Lint - run: npm run lint - - - name: Typecheck - run: npm run typecheck - - - name: Test - run: npm test - - - name: Build - run: npm run build - - # --- SonarQube scan + Quality Gate --- - - name: SonarQube Scan - if: env.SONAR_ENABLED == 'true' && (github.event.pull_request.head.repo.fork == false) - uses: sonarsource/sonarqube-scan-action@v4 - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} - with: - args: > - -Dsonar.projectKey=${{ secrets.SONAR_PROJECT_KEY }} - - - name: SonarQube Quality Gate - if: env.SONAR_ENABLED == 'true' && (github.event.pull_request.head.repo.fork == false) - uses: sonarsource/sonarqube-quality-gate-action@v1.1.0 - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} diff --git a/.github/workflows/ci-pr-validation.yml b/.github/workflows/pr-validation.yml similarity index 95% rename from .github/workflows/ci-pr-validation.yml rename to .github/workflows/pr-validation.yml index a2b018a..fc872ed 100644 --- a/.github/workflows/ci-pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -3,8 +3,6 @@ name: CI - PR Validation on: pull_request: branches: [develop] - push: - branches: [develop] permissions: contents: read diff --git a/.github/workflows/cd-release.yml b/.github/workflows/publish.yml similarity index 71% rename from .github/workflows/cd-release.yml rename to .github/workflows/publish.yml index da718ce..0f62c1a 100644 --- a/.github/workflows/cd-release.yml +++ b/.github/workflows/publish.yml @@ -1,9 +1,9 @@ name: Publish to NPM on: - # push: - # branches: - # - master + push: + tags: + - 'v*.*.*' workflow_dispatch: jobs: @@ -18,6 +18,15 @@ jobs: - name: Checkout code uses: actions/checkout@v4 + - name: Check if tag is from master + id: check_tag + run: | + BRANCH=$(git branch -r --contains ${{ github.sha }} | grep 'origin/master' || true) + if [ -z "$BRANCH" ]; then + echo "Tag was not created from master. Skipping publish." + exit 1 + fi + - name: Setup Node.js uses: actions/setup-node@v4 with: diff --git a/.github/workflows/release-check.yml b/.github/workflows/release-check.yml new file mode 100644 index 0000000..1fb9ada --- /dev/null +++ b/.github/workflows/release-check.yml @@ -0,0 +1,83 @@ +name: CI - Release Check + +on: + pull_request: + branches: [master] + workflow_dispatch: + inputs: + sonar: + description: 'Run SonarCloud analysis' + required: true + default: 'false' + type: choice + options: + - 'false' + - 'true' + +concurrency: + group: ci-release-${{ github.ref }} + cancel-in-progress: true + +jobs: + ci: + name: release checks + runs-on: ubuntu-latest + timeout-minutes: 25 + + # Config stays in the workflow file (token stays in repo secrets) + env: + SONAR_HOST_URL: 'https://sonarcloud.io' + SONAR_ORGANIZATION: 'ciscode' + SONAR_PROJECT_KEY: 'CISCODE-MA_WidgetKit-UI' + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: Install + run: npm ci + + - name: Format + run: npm run format + + - name: Typecheck + run: npm run typecheck + + - name: Lint + run: npm run lint + + - name: Test (with coverage) + run: npm run test:cov + + - name: Build + run: npm run build + + - name: SonarCloud Scan + if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.sonar == 'true' }} + uses: SonarSource/sonarqube-scan-action@v6 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_HOST_URL: ${{ env.SONAR_HOST_URL }} + with: + args: > + -Dsonar.organization=${{ env.SONAR_ORGANIZATION }} \ + -Dsonar.projectKey=${{ env.SONAR_PROJECT_KEY }} \ + -Dsonar.sources=src \ + -Dsonar.tests=test \ + -Dsonar.javascript.lcov.reportPaths=coverage/lcov.info + + - name: SonarCloud Quality Gate + if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.sonar == 'true' }} + uses: SonarSource/sonarqube-quality-gate-action@v1 + timeout-minutes: 10 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_HOST_URL: ${{ env.SONAR_HOST_URL }} diff --git a/.husky/_/.gitignore b/.husky/_/.gitignore index 234d3f7..f59ec20 100644 --- a/.husky/_/.gitignore +++ b/.husky/_/.gitignore @@ -1 +1 @@ -# * \ No newline at end of file +* \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 8d50d62..c4a7fb9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,17 @@ { - "name": "@ciscode/reactts-developerkit", - "version": "0.0.0", + "name": "@ciscode/ui-notification-kit", + "version": "0.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@ciscode/reactts-developerkit", - "version": "0.0.0", + "name": "@ciscode/ui-notification-kit", + "version": "0.0.1", "license": "MIT", + "dependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2" + }, "devDependencies": { "@changesets/cli": "^2.27.8", "@eslint/js": "^9.39.2", @@ -18,6 +22,7 @@ "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", "husky": "^9.1.7", + "jsdom": "^28.1.0", "lint-staged": "^15.2.10", "prettier": "^3.4.2", "rimraf": "^6.0.1", @@ -34,11 +39,61 @@ "react-dom": ">=18" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", + "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", @@ -210,7 +265,6 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -260,7 +314,6 @@ "version": "7.28.4", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -314,6 +367,19 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@changesets/apply-release-plan": { "version": "7.0.14", "resolved": "https://registry.npmjs.org/@changesets/apply-release-plan/-/apply-release-plan-7.0.14.tgz", @@ -589,6 +655,138 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.28", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.28.tgz", + "integrity": "sha512-1NRf1CUBjnr3K7hu8BLxjQrKCxEe8FP/xmPTenAxCRZWVLbmGotkFvG9mfNpjA6k7Bw1bw4BilZq9cu19RA5pg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0" + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", @@ -1201,6 +1399,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.14.1.tgz", + "integrity": "sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1766,6 +1982,96 @@ "win32" ] }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/dom/node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "license": "MIT", + "peer": true + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "license": "MIT", + "peer": true + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1796,14 +2102,14 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.27", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -1814,7 +2120,7 @@ "version": "18.3.7", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^18.0.0" @@ -2226,6 +2532,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -2273,7 +2589,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -2306,6 +2621,15 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", @@ -2520,6 +2844,16 @@ "node": ">=4" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -2880,13 +3214,63 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.1.0.tgz", + "integrity": "sha512-Ml4fP2UT2K3CUBQnVlbdV/8aFDdlY69E+YnwJM+3VUWl08S3J8c8aRuJqCkD9Py8DHZ7zNNvsfKl8psocHZEFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.0.0", + "@csstools/css-syntax-patches-for-csstree": "^1.0.28", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -2959,6 +3343,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -3012,6 +3403,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, "node_modules/detect-indent": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz", @@ -3048,6 +3449,12 @@ "node": ">=0.10.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "license": "MIT" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -3091,6 +3498,19 @@ "node": ">=8.6" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/environment": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", @@ -4319,6 +4739,47 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/human-id": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/human-id/-/human-id-4.1.3.tgz", @@ -4419,6 +4880,15 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -4694,6 +5164,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -4921,7 +5398,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -4937,6 +5413,47 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "28.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", + "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.31", + "@asamuzakjp/dom-selector": "^6.8.1", + "@bramus/specificity": "^2.4.2", + "@exodus/bytes": "^1.11.0", + "cssstyle": "^6.0.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "undici": "^7.21.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -5240,15 +5757,25 @@ "license": "MIT" }, "node_modules/lru-cache": { - "version": "11.2.4", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", - "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -5269,6 +5796,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -5326,6 +5860,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "10.1.1", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", @@ -5715,6 +6258,19 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -5790,7 +6346,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -5959,6 +6514,41 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "license": "MIT", + "peer": true + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -6103,6 +6693,19 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -6147,6 +6750,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "2.0.0-next.5", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", @@ -6374,6 +6987,19 @@ "dev": true, "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -6846,6 +7472,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -6918,6 +7556,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/term-size": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/term-size/-/term-size-2.2.1.tgz", @@ -7046,6 +7691,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz", + "integrity": "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.23" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.23.tgz", + "integrity": "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -7059,6 +7724,32 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -7297,6 +7988,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici": { + "version": "7.22.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", + "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -7950,6 +8651,54 @@ "dev": true, "license": "MIT" }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -8129,6 +8878,23 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index b8f50be..7de374e 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,13 @@ { - "name": "@ciscode/reactts-developerkit", - "version": "0.0.0", - "description": "React TypeScript hybrid library template (components + hooks + utils).", + "name": "@ciscode/ui-notification-kit", + "version": "0.0.1", + "description": "A React component library for building notification systems.", "license": "MIT", + "author": "CISCODE", + "repository": { + "type": "git", + "url": "https://github.com/CISCODE-MA/NotificationKit-UI.git" + }, "private": false, "type": "module", "sideEffects": false, @@ -36,6 +41,7 @@ "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", "husky": "^9.1.7", + "jsdom": "^28.1.0", "lint-staged": "^15.2.10", "prettier": "^3.4.2", "rimraf": "^6.0.1", @@ -71,5 +77,9 @@ "engines": { "node": ">=18" }, - "packageManager": "pnpm@9.15.0" + "packageManager": "pnpm@9.15.0", + "dependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2" + } } diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts new file mode 100644 index 0000000..f27e3c6 --- /dev/null +++ b/src/__tests__/setup.ts @@ -0,0 +1,9 @@ +// Vitest setup: extend expect with jest-dom matchers +import '@testing-library/jest-dom/vitest'; +import { afterEach } from 'vitest'; +import { cleanup } from '@testing-library/react'; + +// Ensure DOM is cleaned between tests to avoid duplicate nodes +afterEach(() => { + cleanup(); +}); diff --git a/src/index.ts b/src/index.ts index fc82a00..c55977d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,3 @@ -export * from "./components"; -export * from "./hooks"; -export * from "./utils"; +export * from './components'; +export * from './hooks'; +export * from './utils'; diff --git a/tsup.config.ts b/tsup.config.ts index e3c6af9..808a0aa 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,11 +1,11 @@ -import { defineConfig } from "tsup"; +import { defineConfig } from 'tsup'; export default defineConfig({ - entry: ["src/index.ts"], - format: ["esm", "cjs"], + entry: ['src/index.ts'], + format: ['esm', 'cjs'], dts: true, sourcemap: true, clean: true, treeshake: true, - external: ["react", "react-dom"] + external: ['react', 'react-dom'], }); diff --git a/vitest.config.ts b/vitest.config.ts index 99f4bce..c331199 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,7 +1,22 @@ -import { defineConfig } from "vitest/config"; +import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { - environment: "node" - } + testDir: 'src/__tests__', + environment: 'jsdom', + setupFiles: ['src/__tests__/setup.ts'], + exclude: ['node_modules/**', 'tests/e2e/**', 'dist/**'], + coverage: { + provider: 'v8', + reporter: ['text', 'html', 'json'], + reportsDirectory: 'coverage', + exclude: ['src/components/Dashboard/**', 'src/layout/**', 'src/main/**'], + thresholds: { + lines: 75, + statements: 75, + branches: 60, + functions: 75, + }, + }, + }, }); From 0ed894f6ae4ae0ba6f8cc9f92b7add6b78df719f Mon Sep 17 00:00:00 2001 From: a-elkhiraooui-ciscode Date: Fri, 27 Mar 2026 09:27:41 +0100 Subject: [PATCH 2/3] first version --- README.md | 202 ++++++-- package-lock.json | 457 ++++++++++++++++++- package.json | 10 +- postcss.config.js | 6 + postcss.config.mjs | 6 + src/__tests__/NotificationItem.a11y.test.tsx | 36 ++ src/__tests__/NotificationProvider.test.tsx | 89 ++++ src/__tests__/notificationStore.test.ts | 65 +++ src/assets/styles/style.css | 23 + src/components/NoopButton.tsx | 9 - src/components/NotificationActionList.tsx | 18 + src/components/NotificationContainer.tsx | 77 ++++ src/components/NotificationIcon.tsx | 63 +++ src/components/NotificationIcons.css | 44 ++ src/components/NotificationItem.tsx | 151 ++++++ src/components/NotificationProgress.tsx | 18 + src/components/NotificationProvider.tsx | 117 +++++ src/components/NotificationViewport.tsx | 15 + src/components/index.css | 2 + src/components/index.ts | 11 +- src/context/NotificationContext.tsx | 26 ++ src/hooks/index.ts | 7 +- src/hooks/useAccessibility.ts | 87 ++++ src/hooks/useClearOnNavigate.ts | 12 + src/hooks/useNoop.ts | 6 - src/hooks/useNotification.ts | 11 + src/index.ts | 8 +- src/models/index.ts | 1 + src/models/notification.ts | 93 ++++ src/store/index.ts | 1 + src/store/notificationStore.ts | 209 +++++++++ src/utils/index.ts | 5 +- src/utils/noop.ts | 3 - tailwind.config.js | 16 + tailwind.config.mjs | 16 + 35 files changed, 1849 insertions(+), 71 deletions(-) create mode 100644 postcss.config.js create mode 100644 postcss.config.mjs create mode 100644 src/__tests__/NotificationItem.a11y.test.tsx create mode 100644 src/__tests__/NotificationProvider.test.tsx create mode 100644 src/__tests__/notificationStore.test.ts create mode 100644 src/assets/styles/style.css delete mode 100644 src/components/NoopButton.tsx create mode 100644 src/components/NotificationActionList.tsx create mode 100644 src/components/NotificationContainer.tsx create mode 100644 src/components/NotificationIcon.tsx create mode 100644 src/components/NotificationIcons.css create mode 100644 src/components/NotificationItem.tsx create mode 100644 src/components/NotificationProgress.tsx create mode 100644 src/components/NotificationProvider.tsx create mode 100644 src/components/NotificationViewport.tsx create mode 100644 src/components/index.css create mode 100644 src/context/NotificationContext.tsx create mode 100644 src/hooks/useAccessibility.ts create mode 100644 src/hooks/useClearOnNavigate.ts delete mode 100644 src/hooks/useNoop.ts create mode 100644 src/hooks/useNotification.ts create mode 100644 src/models/index.ts create mode 100644 src/models/notification.ts create mode 100644 src/store/index.ts create mode 100644 src/store/notificationStore.ts delete mode 100644 src/utils/noop.ts create mode 100644 tailwind.config.js create mode 100644 tailwind.config.mjs diff --git a/README.md b/README.md index 539fe85..b79104e 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,180 @@ -# React TypeScript DeveloperKit (Template) +# NotificationKit-UI -Template repository for building reusable React TypeScript **npm libraries** -(components + hooks + utilities). +NotificationKit-UI is a React + TypeScript notification library with multiple variants, configurable behavior, accessibility support, and route-aware clearing. -## What you get +## Features -- ESM + CJS + Types build (tsup) -- Vitest testing -- ESLint + Prettier (flat config) -- Changesets (manual release flow, no automation PR) -- Husky (pre-commit + pre-push) -- Enforced public API via `src/index.ts` -- Dependency-free styling (Tailwind-compatible by convention only) -- `react` and `react-dom` as peerDependencies +- Notification types: `success`, `error`, `warning`, `info`, `loading`, `default` +- Positions: `top-left`, `top-center`, `top-right`, `center`, `bottom-left`, `bottom-center`, `bottom-right` +- Configurable animation: `slide`, `fade`, `scale` +- Auto-dismiss with per-notification override +- Optional action buttons +- Optional close button +- Pause on hover and pause on focus +- Queue limit (FIFO) with history support +- Route-aware clearing with `clearOnNavigate` +- Dark mode compatible styles +- RTL-ready Tailwind setup +- Accessibility: ARIA roles, live-region announcements, keyboard escape-to-dismiss -## Package structure +## Installation -- `src/components` – reusable UI components -- `src/hooks` – reusable React hooks -- `src/utils` – framework-agnostic utilities -- `src/index.ts` – **only public API** (no deep imports allowed) +```bash +npm install @ciscode/ui-notification-kit +``` -Anything not exported from `src/index.ts` is considered private. +Also ensure host app peer dependencies are installed: -## Scripts +- `react` +- `react-dom` -- `npm run build` – build to `dist/` (tsup) -- `npm test` – run tests (vitest) -- `npm run typecheck` – TypeScript typecheck -- `npm run lint` – ESLint -- `npm run format` / `npm run format:write` – Prettier -- `npx changeset` – create a changeset +## Styling -## Release flow (summary) +Import package styles once in your app entry file: -- Work on a `feature` branch from `develop` -- Merge to `develop` -- Add a changeset for user-facing changes: `npx changeset` -- Promote `develop` → `master` -- Tag `vX.Y.Z` to publish (npm OIDC) +```ts +import '@ciscode/ui-notification-kit/style.css'; +``` -This repository is a **template**. Teams should clone it and focus only on -library logic, not tooling or release mechanics. +## Quick Start + +```tsx +import React from 'react'; +import { NotificationProvider, useNotification } from '@ciscode/ui-notification-kit'; +import '@ciscode/ui-notification-kit/style.css'; + +function Demo() { + const { success, error, loading, update } = useNotification(); + + const runTask = async () => { + const pending = loading({ + title: 'Please wait', + message: 'Processing request...', + autoDismiss: false, + }); + + try { + await new Promise((resolve) => setTimeout(resolve, 1200)); + update({ + id: pending.id, + type: 'success', + title: 'Done', + message: 'Operation completed', + autoDismiss: true, + }); + } catch { + error({ title: 'Failed', message: 'Something went wrong' }); + } + }; + + return ; +} + +export default function App() { + return ( + + + + ); +} +``` + +## Provider API + +`NotificationProvider` props: + +- `config?: NotificationProviderConfig` +- `navigationKey?: string | number` + +`navigationKey` can be tied to router location changes. When it changes, notifications with `clearOnNavigate: true` are removed while others remain visible. + +### Example with React Router + +```tsx +import { useLocation } from 'react-router-dom'; +import { NotificationProvider } from '@ciscode/ui-notification-kit'; + +function RootLayout({ children }: { children: React.ReactNode }) { + const location = useLocation(); + + return {children}; +} +``` + +## Hook API + +`useNotification()` returns: + +- `state` +- `config` +- `notify(config)` +- `success(config)` +- `error(config)` +- `warning(config)` +- `info(config)` +- `loading(config)` +- `defaultNotification(config)` +- `update({ id, ...patch })` +- `dismiss(id)` +- `clearAll()` +- `restore(id)` + +## Notification Config + +Main fields you can pass to `notify` and typed helper methods: + +- `title?: string` +- `message?: string` +- `body?: ReactNode` +- `type?: NotificationType` +- `position?: NotificationPosition` +- `animation?: { type: 'slide' | 'fade' | 'scale'; durationMs: number }` +- `autoDismiss?: boolean` +- `durationMs?: number` +- `pauseOnHover?: boolean` +- `pauseOnFocus?: boolean` +- `closeButton?: boolean` +- `clearOnNavigate?: boolean` +- `actions?: { label: string; onClick: () => void }[]` +- `icon?: ReactNode | null` +- `onClick?: () => void` +- `ariaRole?: 'status' | 'alert'` + +## Provider Defaults + +`NotificationProviderConfig` supports: + +- `maxVisible` (default: `5`) +- `defaultType` (default: `default`) +- `defaultPosition` (default: `top-right`) +- `defaultAnimation` (default: `{ type: 'slide', durationMs: 300 }`) +- `defaultAutoDismiss` (default: `true`) +- `defaultDurationMs` (default: `4000`) +- `defaultPauseOnHover` (default: `true`) +- `defaultPauseOnFocus` (default: `true`) +- `defaultCloseButton` (default: `true`) +- `defaultClearOnNavigate` (default: `false`) +- `defaultAriaRole` (default: `status`) +- `defaultIcon` (default: `null`) +- `historyLimit` (default: `20`) + +## Accessibility Notes + +- Uses ARIA role per notification (`status` or `alert`) +- Adds live-region announcements for screen readers +- Supports keyboard escape to dismiss +- Supports pause-on-focus for better readability + +## Compatibility Notes + +- Built with React and TypeScript for host apps using modern React (including environments like WidgetKit-UI/comptaleyes frontend) +- Tailwind-compatible output CSS is published as `@ciscode/ui-notification-kit/style.css` +- Distributed in ESM + CJS with type declarations + +## Development Scripts + +- `npm run build` +- `npm run typecheck` +- `npm test` +- `npm run lint` +- `npm run format` diff --git a/package-lock.json b/package-lock.json index c4a7fb9..1eb0e91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@eslint/js": "^9.39.2", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", + "autoprefixer": "^10.4.24", "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react": "^7.37.5", @@ -24,8 +25,11 @@ "husky": "^9.1.7", "jsdom": "^28.1.0", "lint-staged": "^15.2.10", + "postcss": "^8.5.6", "prettier": "^3.4.2", "rimraf": "^6.0.1", + "tailwindcss": "^3.4.19", + "tailwindcss-rtl": "^0.9.0", "tsup": "^8.3.5", "typescript": "^5.7.2", "typescript-eslint": "^8.50.1", @@ -52,6 +56,19 @@ "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", "license": "MIT" }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@asamuzakjp/css-color": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", @@ -2614,6 +2631,27 @@ "dev": true, "license": "MIT" }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -2798,6 +2836,43 @@ "node": ">= 0.4" } }, + "node_modules/autoprefixer": { + "version": "10.4.27", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", + "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001774", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -2854,6 +2929,19 @@ "require-from-string": "^2.0.2" } }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -2998,10 +3086,20 @@ "node": ">=6" } }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/caniuse-lite": { - "version": "1.0.30001761", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001761.tgz", - "integrity": "sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==", + "version": "1.0.30001777", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz", + "integrity": "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==", "dev": true, "funding": [ { @@ -3234,6 +3332,19 @@ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", "license": "MIT" }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/cssstyle": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.1.0.tgz", @@ -3423,6 +3534,13 @@ "node": ">=8" } }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -3436,6 +3554,13 @@ "node": ">=8" } }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -4352,6 +4477,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, "node_modules/fs-extra": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", @@ -4958,6 +5097,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-boolean-object": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", @@ -5384,6 +5536,16 @@ "node": ">= 0.4" } }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -5970,6 +6132,16 @@ "dev": true, "license": "MIT" }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/npm-run-path": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", @@ -6009,6 +6181,16 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -6445,6 +6627,71 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-import/node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, "node_modules/postcss-load-config": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", @@ -6488,6 +6735,53 @@ } } }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -6639,6 +6933,26 @@ "dev": true, "license": "MIT" }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/read-cache/node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/read-yaml-file": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/read-yaml-file/-/read-yaml-file-1.1.0.tgz", @@ -7563,6 +7877,136 @@ "dev": true, "license": "MIT" }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss-rtl": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/tailwindcss-rtl/-/tailwindcss-rtl-0.9.0.tgz", + "integrity": "sha512-y7yC8QXjluDBEFMSX33tV6xMYrf0B3sa+tOB5JSQb6/G6laBU313a+Z+qxu55M1Qyn8tDMttjomsA8IsJD+k+w==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwindcss/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/tailwindcss/node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/tailwindcss/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/tailwindcss/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/tailwindcss/node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/term-size": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/term-size/-/term-size-2.2.1.tgz", @@ -8058,6 +8502,13 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", diff --git a/package.json b/package.json index 7de374e..2f30877 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "require": "./dist/index.cjs" - } + }, + "./style.css": "./dist/style.css" }, "main": "./dist/index.cjs", "module": "./dist/index.mjs", @@ -36,6 +37,7 @@ "@eslint/js": "^9.39.2", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", + "autoprefixer": "^10.4.24", "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react": "^7.37.5", @@ -43,8 +45,11 @@ "husky": "^9.1.7", "jsdom": "^28.1.0", "lint-staged": "^15.2.10", + "postcss": "^8.5.6", "prettier": "^3.4.2", "rimraf": "^6.0.1", + "tailwindcss": "^3.4.19", + "tailwindcss-rtl": "^0.9.0", "tsup": "^8.3.5", "typescript": "^5.7.2", "typescript-eslint": "^8.50.1", @@ -52,7 +57,8 @@ }, "scripts": { "clean": "rimraf dist *.tsbuildinfo", - "build": "tsup", + "build": "tsup && npm run build:css", + "build:css": "tailwindcss -i ./src/assets/styles/style.css -o ./dist/style.css --minify", "dev": "tsup --watch", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "vitest run", diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/postcss.config.mjs @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/src/__tests__/NotificationItem.a11y.test.tsx b/src/__tests__/NotificationItem.a11y.test.tsx new file mode 100644 index 0000000..9610f65 --- /dev/null +++ b/src/__tests__/NotificationItem.a11y.test.tsx @@ -0,0 +1,36 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { NotificationItem } from '../components/NotificationItem.js'; +import React from 'react'; + +describe('NotificationItem accessibility', () => { + it('has proper ARIA attributes', () => { + render( + {}} + />, + ); + const notification = screen.getByRole('status'); + expect(notification).toHaveAttribute('aria-describedby'); + expect(screen.getByText('Accessible')).toBeInTheDocument(); + expect(screen.getByText('Notification')).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/NotificationProvider.test.tsx b/src/__tests__/NotificationProvider.test.tsx new file mode 100644 index 0000000..ae72d04 --- /dev/null +++ b/src/__tests__/NotificationProvider.test.tsx @@ -0,0 +1,89 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { NotificationProvider } from '../components/NotificationProvider.js'; +import { useNotification } from '../hooks/useNotification.js'; +import React from 'react'; + +describe('NotificationProvider', () => { + function TestComponent() { + const { notify, dismiss, state } = useNotification(); + return ( + <> + + + + ); + } + + it('renders notifications and allows dismiss', () => { + render( + + + , + ); + fireEvent.click(screen.getByText('Show')); + expect(screen.getByText('Test')).toBeInTheDocument(); + expect(screen.getByText('Hello')).toBeInTheDocument(); + fireEvent.click(screen.getByText('Dismiss')); + expect(screen.queryByText('Test')).not.toBeInTheDocument(); + }); + + it('clears only clearOnNavigate notifications when navigationKey changes', () => { + function Wrapper() { + const [route, setRoute] = React.useState(1); + + return ( + + setRoute((prev) => prev + 1)} /> + + ); + } + + function RouteAwareTest({ onNavigate }: { onNavigate: () => void }) { + const { notify } = useNotification(); + + return ( + <> + + + + + ); + } + + render(); + + fireEvent.click(screen.getByText('Add transient')); + fireEvent.click(screen.getByText('Add persistent')); + + expect(screen.getByText('Transient')).toBeInTheDocument(); + expect(screen.getByText('Persistent')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('Navigate')); + + expect(screen.queryByText('Transient')).not.toBeInTheDocument(); + expect(screen.getByText('Persistent')).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/notificationStore.test.ts b/src/__tests__/notificationStore.test.ts new file mode 100644 index 0000000..4409709 --- /dev/null +++ b/src/__tests__/notificationStore.test.ts @@ -0,0 +1,65 @@ +import { NotificationStore } from '../store/notificationStore.js'; +import { describe, expect, it } from 'vitest'; + +describe('NotificationStore', () => { + it('adds notifications and enforces maxVisible', () => { + const store = new NotificationStore({ maxVisible: 2 }); + store.add({ title: 'First' }); + store.add({ title: 'Second' }); + store.add({ title: 'Third' }); + const state = store.getState(); + expect(state.notifications.length).toBe(2); + expect(state.notifications[0].title).toBe('Second'); + expect(state.notifications[1].title).toBe('Third'); + }); + + it('moves overflowed notifications to history', () => { + const store = new NotificationStore({ maxVisible: 2 }); + store.add({ title: 'First' }); + store.add({ title: 'Second' }); + store.add({ title: 'Third' }); + + const state = store.getState(); + expect(state.history.length).toBe(1); + expect(state.history[0].title).toBe('First'); + }); + + it('dismisses notifications and adds to history', () => { + const store = new NotificationStore({ maxVisible: 2 }); + const n1 = store.add({ title: 'A' }); + store.dismiss(n1.id); + const state = store.getState(); + expect(state.notifications.length).toBe(0); + expect(state.history.length).toBe(1); + expect(state.history[0].title).toBe('A'); + }); + + it('clears all notifications and moves them to history', () => { + const store = new NotificationStore({ maxVisible: 2 }); + store.add({ title: 'A' }); + store.add({ title: 'B' }); + store.clearAll(); + const state = store.getState(); + expect(state.notifications.length).toBe(0); + expect(state.history.length).toBe(2); + }); + + it('restores notifications from history', () => { + const store = new NotificationStore({ maxVisible: 2 }); + const n1 = store.add({ title: 'A' }); + store.dismiss(n1.id); + store.restore(n1.id); + const state = store.getState(); + expect(state.notifications.length).toBe(1); + expect(state.notifications[0].title).toBe('A'); + expect(state.history.length).toBe(0); + }); + + it('updates notifications by id', () => { + const store = new NotificationStore(); + const n1 = store.add({ title: 'A', message: 'Old' }); + store.update({ id: n1.id, message: 'New' }); + const state = store.getState(); + expect(state.notifications[0].message).toBe('New'); + }); +}); diff --git a/src/assets/styles/style.css b/src/assets/styles/style.css new file mode 100644 index 0000000..67a97a6 --- /dev/null +++ b/src/assets/styles/style.css @@ -0,0 +1,23 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Base layer for NotificationKit-UI */ +:root { + color-scheme: light dark; +} + +/* Accessibility: Screen reader only class */ +@layer utilities { + .sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; + } +} diff --git a/src/components/NoopButton.tsx b/src/components/NoopButton.tsx deleted file mode 100644 index 4bee846..0000000 --- a/src/components/NoopButton.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import type { ButtonHTMLAttributes } from 'react'; -import { useNoop } from '../hooks'; - -export type NoopButtonProps = ButtonHTMLAttributes; - -export function NoopButton(props: NoopButtonProps) { - const onClick = useNoop(); - return + ))} + + ); +} diff --git a/src/components/NotificationContainer.tsx b/src/components/NotificationContainer.tsx new file mode 100644 index 0000000..69ff292 --- /dev/null +++ b/src/components/NotificationContainer.tsx @@ -0,0 +1,77 @@ +import type { NotificationPosition, NotificationRecord } from '../models/index.js'; +import { NotificationItem } from './NotificationItem.js'; + +export type NotificationContainerProps = { + position: NotificationPosition; + items: NotificationRecord[]; + onDismiss: (id: string) => void; +}; + +const positionClassMap: Record = { + 'top-left': 'top-4 left-4 items-start', + 'top-center': 'top-4 left-1/2 -translate-x-1/2 items-center', + 'top-right': 'top-4 right-4 items-end', + center: 'top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 items-center', + 'bottom-left': 'bottom-4 left-4 items-start', + 'bottom-center': 'bottom-4 left-1/2 -translate-x-1/2 items-center', + 'bottom-right': 'bottom-4 right-4 items-end', +}; + +export function NotificationContainer({ position, items, onDismiss }: NotificationContainerProps) { + if (items.length === 0) { + return null; + } + + return ( +
+ {items.map((item) => ( + + ))} +
+ ); +} + +export function NotificationContainerGroup({ + items, + onDismiss, +}: { + items: NotificationRecord[]; + onDismiss: (id: string) => void; +}) { + const groups = items.reduce>( + (acc, item) => { + const list = acc[item.position] ?? []; + list.push(item); + acc[item.position] = list; + return acc; + }, + { + 'top-left': [], + 'top-center': [], + 'top-right': [], + center: [], + 'bottom-left': [], + 'bottom-center': [], + 'bottom-right': [], + }, + ); + + return ( + <> + {Object.entries(groups).map(([position, group]) => ( + + ))} + + ); +} diff --git a/src/components/NotificationIcon.tsx b/src/components/NotificationIcon.tsx new file mode 100644 index 0000000..7902eca --- /dev/null +++ b/src/components/NotificationIcon.tsx @@ -0,0 +1,63 @@ +import type { NotificationRecord } from '../models/index.js'; + +const typeIconMap: Record = { + success: ( + + ), + error: ( + + ), + warning: ( + + ), + info: ( + + ), + loading: ( + + + + + ), + default: ( + + ), +}; + +export function NotificationIcon({ item }: { item: NotificationRecord }) { + if (item.icon === null) { + return null; + } + + if (item.icon) { + return
{item.icon}
; + } + + return
{typeIconMap[item.type]}
; +} diff --git a/src/components/NotificationIcons.css b/src/components/NotificationIcons.css new file mode 100644 index 0000000..8cd3418 --- /dev/null +++ b/src/components/NotificationIcons.css @@ -0,0 +1,44 @@ +@layer components { + .animate-notify-slide { + animation: notify-slide 0.3s ease-out; + } + + .animate-notify-fade { + animation: notify-fade 0.2s ease-out; + } + + .animate-notify-scale { + animation: notify-scale 0.2s ease-out; + } + + @keyframes notify-slide { + from { + opacity: 0; + transform: translateY(-6px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + @keyframes notify-fade { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + + @keyframes notify-scale { + from { + opacity: 0; + transform: scale(0.98); + } + to { + opacity: 1; + transform: scale(1); + } + } +} diff --git a/src/components/NotificationItem.tsx b/src/components/NotificationItem.tsx new file mode 100644 index 0000000..acc0c42 --- /dev/null +++ b/src/components/NotificationItem.tsx @@ -0,0 +1,151 @@ +import type { MouseEvent } from 'react'; +import { useEffect, useId, useMemo, useRef, useState } from 'react'; +import type { NotificationRecord } from '../models/index.js'; +import { useLiveRegion } from '../hooks/useAccessibility.js'; +import { NotificationActionList } from './NotificationActionList.js'; +import { NotificationIcon } from './NotificationIcon.js'; +import { NotificationProgress } from './NotificationProgress.js'; + +export type NotificationItemProps = { + item: NotificationRecord; + onDismiss: (id: string) => void; +}; + +const typeStyles: Record = { + success: + 'border-emerald-500/40 bg-emerald-50 text-emerald-950 dark:bg-emerald-900/40 dark:text-emerald-50', + error: 'border-rose-500/40 bg-rose-50 text-rose-950 dark:bg-rose-900/40 dark:text-rose-50', + warning: 'border-amber-500/40 bg-amber-50 text-amber-950 dark:bg-amber-900/40 dark:text-amber-50', + info: 'border-sky-500/40 bg-sky-50 text-sky-950 dark:bg-sky-900/40 dark:text-sky-50', + loading: 'border-slate-500/40 bg-slate-50 text-slate-950 dark:bg-slate-900/40 dark:text-slate-50', + default: + 'border-slate-200 bg-white text-slate-950 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-50', +}; + +export function NotificationItem({ item, onDismiss }: NotificationItemProps) { + const [isPaused, setIsPaused] = useState(false); + const [remaining, setRemaining] = useState(item.durationMs); + const timerRef = useRef(null); + const startRef = useRef(null); + const itemRef = useRef(null); + const descriptionId = useId(); + + const canDismiss = item.autoDismiss && item.durationMs > 0; + + // Announce notification to screen readers + const announcementText = [item.title, item.message].filter(Boolean).join(': ') || 'Notification'; + useLiveRegion(announcementText, item.ariaRole === 'alert' ? 'assertive' : 'polite'); + + useEffect(() => { + if (!canDismiss || isPaused) { + if (timerRef.current) { + window.clearTimeout(timerRef.current); + } + return; + } + + startRef.current = Date.now(); + timerRef.current = window.setTimeout(() => { + onDismiss(item.id); + }, remaining); + + return () => { + if (timerRef.current) { + window.clearTimeout(timerRef.current); + } + }; + }, [canDismiss, isPaused, item.id, onDismiss, remaining]); + + const handlePause = () => { + if (!canDismiss || !startRef.current) { + return; + } + const elapsed = Date.now() - startRef.current; + setRemaining((prev) => Math.max(prev - elapsed, 0)); + startRef.current = null; + setIsPaused(true); + }; + + const handleResume = () => { + if (!canDismiss) { + return; + } + setIsPaused(false); + }; + + const handleClose = (event: MouseEvent) => { + event.stopPropagation(); + onDismiss(item.id); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + // Escape key to dismiss + if (event.key === 'Escape') { + event.preventDefault(); + onDismiss(item.id); + } + }; + + const handleClick = () => { + item.onClick?.(); + }; + + const shouldPauseOnHover = item.pauseOnHover && canDismiss; + const shouldPauseOnFocus = item.pauseOnFocus && canDismiss; + + const animationClass = useMemo(() => { + switch (item.animation.type) { + case 'fade': + return 'animate-notify-fade'; + case 'scale': + return 'animate-notify-scale'; + default: + return 'animate-notify-slide'; + } + }, [item.animation.type]); + + return ( +
+
+ +
+ {item.title ?

{item.title}

: null} + {item.message ? ( +

{item.message}

+ ) : null} + {item.body ? ( +
{item.body}
+ ) : null} +
+ {item.closeButton ? ( + + ) : null} +
+ {item.actions && item.actions.length > 0 ? ( + + ) : null} + {canDismiss ? ( + + ) : null} +
+ ); +} diff --git a/src/components/NotificationProgress.tsx b/src/components/NotificationProgress.tsx new file mode 100644 index 0000000..e774786 --- /dev/null +++ b/src/components/NotificationProgress.tsx @@ -0,0 +1,18 @@ +export function NotificationProgress({ + remaining, + duration, +}: { + remaining: number; + duration: number; +}) { + const percentage = Math.max(0, Math.min(100, (remaining / duration) * 100)); + + return ( +
+
+
+ ); +} diff --git a/src/components/NotificationProvider.tsx b/src/components/NotificationProvider.tsx new file mode 100644 index 0000000..e690758 --- /dev/null +++ b/src/components/NotificationProvider.tsx @@ -0,0 +1,117 @@ +import type { ReactNode } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { + NotificationConfig, + NotificationProviderConfig, + NotificationRecord, + NotificationStoreState, + NotificationUpdate, +} from '../models/index.js'; +import { NotificationContext } from '../context/NotificationContext.js'; +import { NotificationStore } from '../store/index.js'; +import { NotificationViewport } from './NotificationViewport.js'; + +export type NotificationProviderProps = { + children: ReactNode; + config?: NotificationProviderConfig; + navigationKey?: string | number; +}; + +export function NotificationProvider({ + children, + config, + navigationKey, +}: NotificationProviderProps) { + const [store] = useState(() => new NotificationStore(config)); + const [state, setState] = useState(() => store.getState()); + const navigationKeyRef = useRef(navigationKey); + + // Subscribe to store mutation events — this is the only correct way to react to external store changes + useEffect(() => { + return store.subscribe(() => { + setState(store.getState()); + }); + }, [store]); + + // Propagate config changes to the store + useEffect(() => { + if (config) { + store.setProviderConfig(config); + } + }, [config, store]); + + // Clear route-scoped notifications when navigation key changes + useEffect(() => { + if (navigationKey === undefined || navigationKey === navigationKeyRef.current) { + navigationKeyRef.current = navigationKey; + return; + } + navigationKeyRef.current = navigationKey; + store.clearOnNavigate(); + }, [navigationKey, store]); + + const notify = useCallback( + (input: NotificationConfig): NotificationRecord => { + return store.add(input); + }, + [store], + ); + + const withType = useCallback( + (type: NotificationConfig['type']) => + (input: NotificationConfig): NotificationRecord => + notify({ ...input, type }), + [notify], + ); + + const update = useCallback( + (next: NotificationUpdate) => { + store.update(next); + }, + [store], + ); + + const dismiss = useCallback( + (id: string) => { + store.dismiss(id); + }, + [store], + ); + + const clearAll = useCallback(() => { + store.clearAll(); + }, [store]); + + const restore = useCallback( + (id: string) => { + store.restore(id); + }, + [store], + ); + + const value = useMemo( + () => ({ + state, + config: store.getProviderConfig(), + notify, + success: withType('success'), + error: withType('error'), + warning: withType('warning'), + info: withType('info'), + loading: withType('loading'), + defaultNotification: withType('default'), + update, + dismiss, + clearAll, + restore, + }), + [state, notify, update, dismiss, clearAll, restore, store, withType], + ); + + return ( + + {children} + + + ); +} diff --git a/src/components/NotificationViewport.tsx b/src/components/NotificationViewport.tsx new file mode 100644 index 0000000..284cca1 --- /dev/null +++ b/src/components/NotificationViewport.tsx @@ -0,0 +1,15 @@ +import { useMemo } from 'react'; +import type { NotificationRecord } from '../models/index.js'; +import { NotificationContainerGroup } from './NotificationContainer.js'; + +export function NotificationViewport({ + items, + onDismiss, +}: { + items: NotificationRecord[]; + onDismiss: (id: string) => void; +}) { + const ordered = useMemo(() => [...items].sort((a, b) => a.createdAt - b.createdAt), [items]); + + return ; +} diff --git a/src/components/index.css b/src/components/index.css new file mode 100644 index 0000000..e657961 --- /dev/null +++ b/src/components/index.css @@ -0,0 +1,2 @@ +@import '../assets/styles/style.css'; +@import './NotificationIcons.css'; diff --git a/src/components/index.ts b/src/components/index.ts index 52f8fa8..ade7987 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,4 +1,7 @@ -// Example placeholder export — replace with real components later. -export const __components_placeholder = true; - -export * from './NoopButton'; +export * from './NotificationProvider.js'; +export * from './NotificationContainer.js'; +export * from './NotificationItem.js'; +export * from './NotificationActionList.js'; +export * from './NotificationIcon.js'; +export * from './NotificationProgress.js'; +export * from './NotificationViewport.js'; diff --git a/src/context/NotificationContext.tsx b/src/context/NotificationContext.tsx new file mode 100644 index 0000000..39d79ad --- /dev/null +++ b/src/context/NotificationContext.tsx @@ -0,0 +1,26 @@ +import { createContext } from 'react'; +import type { + NotificationConfig, + NotificationProviderConfig, + NotificationRecord, + NotificationStoreState, + NotificationUpdate, +} from '../models/index.js'; + +export type NotificationContextValue = { + state: NotificationStoreState; + config: Required; + notify: (config: NotificationConfig) => NotificationRecord; + success: (config: NotificationConfig) => NotificationRecord; + error: (config: NotificationConfig) => NotificationRecord; + warning: (config: NotificationConfig) => NotificationRecord; + info: (config: NotificationConfig) => NotificationRecord; + loading: (config: NotificationConfig) => NotificationRecord; + defaultNotification: (config: NotificationConfig) => NotificationRecord; + update: (update: NotificationUpdate) => void; + dismiss: (id: string) => void; + clearAll: () => void; + restore: (id: string) => void; +}; + +export const NotificationContext = createContext(null); diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 6a94ddd..4c37517 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,4 +1,3 @@ -// Example placeholder export — replace with real hooks later. -export const __hooks_placeholder = true; - -export * from './useNoop'; +export * from './useNotification.js'; +export * from './useAccessibility.js'; +export * from './useClearOnNavigate.js'; diff --git a/src/hooks/useAccessibility.ts b/src/hooks/useAccessibility.ts new file mode 100644 index 0000000..647e1aa --- /dev/null +++ b/src/hooks/useAccessibility.ts @@ -0,0 +1,87 @@ +import { useCallback, useEffect } from 'react'; + +/** + * Hook for managing focus trap behavior within a container. + * Keeps focus within the container when tabbing. + */ +export function useFocusTrap(containerRef: React.RefObject) { + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key !== 'Tab' || !containerRef.current) { + return; + } + + const focusableElements = containerRef.current.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ); + + if (focusableElements.length === 0) { + event.preventDefault(); + return; + } + + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + const activeElement = document.activeElement; + + if (event.shiftKey) { + if (activeElement === firstElement) { + event.preventDefault(); + lastElement.focus(); + } + } else { + if (activeElement === lastElement) { + event.preventDefault(); + firstElement.focus(); + } + } + }, + [containerRef], + ); + + useEffect(() => { + const element = containerRef.current; + if (!element) { + return; + } + + element.addEventListener('keydown', handleKeyDown); + return () => { + element.removeEventListener('keydown', handleKeyDown); + }; + }, [handleKeyDown, containerRef]); +} + +/** + * Hook for managing live region announcements. + * Announces messages to screen readers. + */ +export function useLiveRegion(message: string, priority: 'polite' | 'assertive' = 'assertive') { + useEffect(() => { + if (!message) { + return; + } + + let liveRegion = document.querySelector( + `[data-notification-live-region="true"][data-priority="${priority}"]`, + ); + + if (!liveRegion) { + liveRegion = document.createElement('div'); + liveRegion.setAttribute('aria-live', priority); + liveRegion.setAttribute('aria-atomic', 'true'); + liveRegion.setAttribute('data-notification-live-region', 'true'); + liveRegion.setAttribute('data-priority', priority); + liveRegion.className = 'sr-only'; + document.body.appendChild(liveRegion); + } + + liveRegion.textContent = message; + + return () => { + if (liveRegion && !liveRegion.textContent) { + liveRegion.remove(); + } + }; + }, [message, priority]); +} diff --git a/src/hooks/useClearOnNavigate.ts b/src/hooks/useClearOnNavigate.ts new file mode 100644 index 0000000..e3fb2fc --- /dev/null +++ b/src/hooks/useClearOnNavigate.ts @@ -0,0 +1,12 @@ +import { useEffect } from 'react'; + +/** + * Hook to clear notifications on route change. + * Requires a clearAll callback and a location key. + */ +export function useClearOnNavigate(clearAll: () => void, locationKey: string) { + useEffect(() => { + clearAll(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [locationKey]); +} diff --git a/src/hooks/useNoop.ts b/src/hooks/useNoop.ts deleted file mode 100644 index a0d82a3..0000000 --- a/src/hooks/useNoop.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { useCallback } from 'react'; -import { noop } from '../utils'; - -export function useNoop() { - return useCallback(() => noop(), []); -} diff --git a/src/hooks/useNotification.ts b/src/hooks/useNotification.ts new file mode 100644 index 0000000..9c9b008 --- /dev/null +++ b/src/hooks/useNotification.ts @@ -0,0 +1,11 @@ +import { useContext } from 'react'; +import { NotificationContext } from '../context/NotificationContext.js'; + +export function useNotification() { + const context = useContext(NotificationContext); + if (!context) { + throw new Error('useNotification must be used within a NotificationProvider'); + } + + return context; +} diff --git a/src/index.ts b/src/index.ts index c55977d..b01c53c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,5 @@ -export * from './components'; -export * from './hooks'; -export * from './utils'; +export * from './components/index.js'; +export * from './hooks/index.js'; +export * from './models/index.js'; +export * from './store/index.js'; +export * from './utils/index.js'; diff --git a/src/models/index.ts b/src/models/index.ts new file mode 100644 index 0000000..c103055 --- /dev/null +++ b/src/models/index.ts @@ -0,0 +1 @@ +export * from './notification.js'; diff --git a/src/models/notification.ts b/src/models/notification.ts new file mode 100644 index 0000000..fd6154b --- /dev/null +++ b/src/models/notification.ts @@ -0,0 +1,93 @@ +import type { ReactNode } from 'react'; + +export type NotificationType = 'success' | 'error' | 'warning' | 'info' | 'loading' | 'default'; + +export type NotificationPosition = + | 'top-left' + | 'top-center' + | 'top-right' + | 'center' + | 'bottom-left' + | 'bottom-center' + | 'bottom-right'; + +export type NotificationAnimationType = 'slide' | 'fade' | 'scale'; + +export type NotificationAnimation = { + type: NotificationAnimationType; + durationMs: number; +}; + +export type NotificationAction = { + label: string; + onClick: () => void; +}; + +export type NotificationContent = { + title?: string; + message?: string; + body?: ReactNode; +}; + +export type NotificationIconNode = ReactNode; + +export type NotificationOptions = { + id?: string; + type?: NotificationType; + position?: NotificationPosition; + animation?: NotificationAnimation; + autoDismiss?: boolean; + durationMs?: number; + pauseOnHover?: boolean; + pauseOnFocus?: boolean; + closeButton?: boolean; + clearOnNavigate?: boolean; + onClick?: () => void; + actions?: NotificationAction[]; + icon?: NotificationIconNode | null; + ariaRole?: 'status' | 'alert'; +}; + +export type NotificationConfig = NotificationContent & NotificationOptions; + +export type NotificationRecord = NotificationConfig & { + id: string; + type: NotificationType; + position: NotificationPosition; + animation: NotificationAnimation; + autoDismiss: boolean; + durationMs: number; + pauseOnHover: boolean; + pauseOnFocus: boolean; + closeButton: boolean; + clearOnNavigate: boolean; + createdAt: number; + state: 'visible' | 'dismissing'; +}; + +export type NotificationUpdate = Partial & { id: string }; + +export type NotificationHistoryItem = NotificationRecord & { + dismissedAt: number; +}; + +export type NotificationProviderConfig = { + maxVisible?: number; + defaultType?: NotificationType; + defaultPosition?: NotificationPosition; + defaultAnimation?: NotificationAnimation; + defaultAutoDismiss?: boolean; + defaultDurationMs?: number; + defaultPauseOnHover?: boolean; + defaultPauseOnFocus?: boolean; + defaultCloseButton?: boolean; + defaultClearOnNavigate?: boolean; + defaultAriaRole?: 'status' | 'alert'; + defaultIcon?: NotificationIconNode | null; + historyLimit?: number; +}; + +export type NotificationStoreState = { + notifications: NotificationRecord[]; + history: NotificationHistoryItem[]; +}; diff --git a/src/store/index.ts b/src/store/index.ts new file mode 100644 index 0000000..538ae61 --- /dev/null +++ b/src/store/index.ts @@ -0,0 +1 @@ +export * from './notificationStore.js'; diff --git a/src/store/notificationStore.ts b/src/store/notificationStore.ts new file mode 100644 index 0000000..4895ff8 --- /dev/null +++ b/src/store/notificationStore.ts @@ -0,0 +1,209 @@ +import type { + NotificationConfig, + NotificationHistoryItem, + NotificationProviderConfig, + NotificationRecord, + NotificationStoreState, + NotificationUpdate, +} from '../models/index.js'; + +const DEFAULT_ANIMATION = { type: 'slide', durationMs: 300 } as const; + +const DEFAULT_PROVIDER_CONFIG: Required = { + maxVisible: 5, + defaultType: 'default', + defaultPosition: 'top-right', + defaultAnimation: DEFAULT_ANIMATION, + defaultAutoDismiss: true, + defaultDurationMs: 4000, + defaultPauseOnHover: true, + defaultPauseOnFocus: true, + defaultCloseButton: true, + defaultClearOnNavigate: false, + defaultAriaRole: 'status', + defaultIcon: null, + historyLimit: 20, +}; + +let idCounter = 0; + +function nextId() { + idCounter += 1; + return `nk_${Date.now()}_${idCounter}`; +} + +function toRecord( + config: NotificationConfig, + provider: Required, +): NotificationRecord { + const createdAt = Date.now(); + const id = config.id ?? nextId(); + const type = config.type ?? provider.defaultType; + const position = config.position ?? provider.defaultPosition; + const animation = config.animation ?? provider.defaultAnimation; + const autoDismiss = config.autoDismiss ?? provider.defaultAutoDismiss; + const durationMs = config.durationMs ?? provider.defaultDurationMs; + const pauseOnHover = config.pauseOnHover ?? provider.defaultPauseOnHover; + const pauseOnFocus = config.pauseOnFocus ?? provider.defaultPauseOnFocus; + const closeButton = config.closeButton ?? provider.defaultCloseButton; + const clearOnNavigate = config.clearOnNavigate ?? provider.defaultClearOnNavigate; + const ariaRole = config.ariaRole ?? provider.defaultAriaRole; + const icon = config.icon ?? provider.defaultIcon; + + return { + ...config, + id, + type, + position, + animation, + autoDismiss, + durationMs, + pauseOnHover, + pauseOnFocus, + closeButton, + clearOnNavigate, + ariaRole, + icon, + createdAt, + state: 'visible', + }; +} + +function pushHistory( + history: NotificationHistoryItem[], + item: NotificationRecord, + limit: number, +): NotificationHistoryItem[] { + const updated = [{ ...item, dismissedAt: Date.now() }, ...history]; + if (updated.length <= limit) { + return updated; + } + return updated.slice(0, limit); +} + +export class NotificationStore { + private provider: Required; + private state: NotificationStoreState; + private listeners: Set<() => void> = new Set(); + + constructor(providerConfig?: NotificationProviderConfig) { + this.provider = { ...DEFAULT_PROVIDER_CONFIG, ...providerConfig }; + this.state = { notifications: [], history: [] }; + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + private notify() { + this.listeners.forEach((listener) => listener()); + } + + getProviderConfig() { + return this.provider; + } + + getState() { + return this.state; + } + + setProviderConfig(nextConfig: NotificationProviderConfig) { + this.provider = { ...this.provider, ...nextConfig }; + this.notify(); + } + + add(config: NotificationConfig) { + const record = toRecord(config, this.provider); + const notifications = [...this.state.notifications, record]; + const maxVisible = this.provider.maxVisible; + const overflow = + maxVisible > 0 && notifications.length > maxVisible ? notifications.length - maxVisible : 0; + const dismissedByOverflow = overflow > 0 ? notifications.slice(0, overflow) : []; + const trimmed = overflow > 0 ? notifications.slice(overflow) : notifications; + + const history = dismissedByOverflow.reduce( + (acc, item) => pushHistory(acc, item, this.provider.historyLimit), + this.state.history, + ); + + this.state = { + notifications: trimmed, + history, + }; + + this.notify(); + return record; + } + + update(update: NotificationUpdate) { + const notifications = this.state.notifications.map((item) => + item.id === update.id ? { ...item, ...update } : item, + ); + + this.state = { + ...this.state, + notifications, + }; + this.notify(); + } + + dismiss(id: string) { + const target = this.state.notifications.find((item) => item.id === id); + if (!target) { + return; + } + + this.state = { + notifications: this.state.notifications.filter((item) => item.id !== id), + history: pushHistory(this.state.history, target, this.provider.historyLimit), + }; + this.notify(); + } + + clearAll() { + const history = this.state.notifications.reduce( + (acc, item) => pushHistory(acc, item, this.provider.historyLimit), + this.state.history, + ); + + this.state = { + notifications: [], + history, + }; + this.notify(); + } + + clearOnNavigate() { + const toKeep = this.state.notifications.filter((item) => !item.clearOnNavigate); + const toDismiss = this.state.notifications.filter((item) => item.clearOnNavigate); + + const history = toDismiss.reduce( + (acc, item) => pushHistory(acc, item, this.provider.historyLimit), + this.state.history, + ); + + this.state = { + notifications: toKeep, + history, + }; + this.notify(); + } + + restore(id: string) { + const historyItem = this.state.history.find((item) => item.id === id); + if (!historyItem) { + return; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { dismissedAt: _dismissedAt, ...rest } = historyItem; + this.add(rest); + this.state = { + ...this.state, + history: this.state.history.filter((item) => item.id !== id), + }; + } +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 6211c64..cb0ff5c 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,4 +1 @@ -// Example placeholder export — replace with real utils later. -export const __utils_placeholder = true; - -export * from './noop'; +export {}; diff --git a/src/utils/noop.ts b/src/utils/noop.ts deleted file mode 100644 index c3a1aab..0000000 --- a/src/utils/noop.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function noop(): void { - // intentionally empty -} diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..c54a8ab --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,16 @@ +import tailwindcssRtl from 'tailwindcss-rtl'; + +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./src/**/*.{ts,tsx}'], + darkMode: 'class', + theme: { + extend: { + zIndex: { + 60: '60', + 70: '70', + }, + }, + }, + plugins: [tailwindcssRtl], +}; diff --git a/tailwind.config.mjs b/tailwind.config.mjs new file mode 100644 index 0000000..c54a8ab --- /dev/null +++ b/tailwind.config.mjs @@ -0,0 +1,16 @@ +import tailwindcssRtl from 'tailwindcss-rtl'; + +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./src/**/*.{ts,tsx}'], + darkMode: 'class', + theme: { + extend: { + zIndex: { + 60: '60', + 70: '70', + }, + }, + }, + plugins: [tailwindcssRtl], +}; From 87a1f8c4d1cd96b13676043189e0dff75736bd66 Mon Sep 17 00:00:00 2001 From: a-elkhiraooui-ciscode Date: Fri, 27 Mar 2026 17:03:45 +0100 Subject: [PATCH 3/3] fixed a style issue and added changeset --- .changeset/bright-owls-approve.md | 27 ++++++++++++++++++++++++ package.json | 4 ++-- src/assets/styles/style.css | 5 ----- src/components/NotificationContainer.tsx | 1 + src/components/NotificationViewport.tsx | 10 ++++++++- 5 files changed, 39 insertions(+), 8 deletions(-) create mode 100644 .changeset/bright-owls-approve.md diff --git a/.changeset/bright-owls-approve.md b/.changeset/bright-owls-approve.md new file mode 100644 index 0000000..9bad333 --- /dev/null +++ b/.changeset/bright-owls-approve.md @@ -0,0 +1,27 @@ +--- +'@ciscode/ui-notification-kit': minor +--- + +Ship the first functional release of NotificationKit-UI. + +### Added + +- Notification provider and hook API (`NotificationProvider`, `useNotification`) +- Notification types: success, error, warning, info, loading, default +- Position support: top-left, top-center, top-right, center, bottom-left, bottom-center, bottom-right +- Configurable animations (slide, fade, scale), durations, auto-dismiss, close button, actions, and custom icons +- Store lifecycle with add/update/dismiss/clear/restore and history tracking +- Route-aware clearing support (`clearOnNavigate`, `navigationKey`) +- Accessibility support (live-region announcements, ARIA roles, keyboard escape-to-dismiss) +- Tailwind + RTL styling support and published style asset export (`./style.css`) +- Test coverage for store behavior, provider behavior, and a11y essentials + +### Changed + +- Package entry exports updated to align with generated build outputs +- Notification rendering moved to a portal to avoid stacking-context issues in host apps +- Layering hardened so notifications stay above dashboard content + +### Notes + +- Import styles in host apps using: `@ciscode/ui-notification-kit/style.css` diff --git a/package.json b/package.json index 2f30877..cbe542f 100644 --- a/package.json +++ b/package.json @@ -17,13 +17,13 @@ "exports": { ".": { "types": "./dist/index.d.ts", - "import": "./dist/index.mjs", + "import": "./dist/index.js", "require": "./dist/index.cjs" }, "./style.css": "./dist/style.css" }, "main": "./dist/index.cjs", - "module": "./dist/index.mjs", + "module": "./dist/index.js", "types": "./dist/index.d.ts", "publishConfig": { "access": "public" diff --git a/src/assets/styles/style.css b/src/assets/styles/style.css index 67a97a6..7467a29 100644 --- a/src/assets/styles/style.css +++ b/src/assets/styles/style.css @@ -2,11 +2,6 @@ @tailwind components; @tailwind utilities; -/* Base layer for NotificationKit-UI */ -:root { - color-scheme: light dark; -} - /* Accessibility: Screen reader only class */ @layer utilities { .sr-only { diff --git a/src/components/NotificationContainer.tsx b/src/components/NotificationContainer.tsx index 69ff292..8f8e22d 100644 --- a/src/components/NotificationContainer.tsx +++ b/src/components/NotificationContainer.tsx @@ -25,6 +25,7 @@ export function NotificationContainer({ position, items, onDismiss }: Notificati return (
[...items].sort((a, b) => a.createdAt - b.createdAt), [items]); - return ; + if (typeof document === 'undefined') { + return null; + } + + return createPortal( + , + document.body, + ); }