diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fd564b4..308fdebc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,30 @@ ### [@coreui/angular](https://coreui.io/angular/) changelog +--- + +#### `5.3.9` + +- chore(dependencies): update +- fix(accordion): accordion item not expanded when visible=true on init (regression) +- refactor(alert): signal inputs, host bindings, cleanup, tests +- refactor(breadcrumb): signal inputs, host bindings, cleanup, tests +- refactor(grid): signal inputs, host bindings, cleanup, tests +- refactor(header): signal inputs, host bindings, cleanup, tests +- refactor(theme.directive): signal inputs, host bindings, cleanup, tests +- refactor(offcanvas): signal inputs, host bindings, cleanup, tests +- refactor(pagination): signal inputs, host bindings, cleanup, tests +- refactor(carousel): signal inputs, host bindings, cleanup, tests +- feat(carousel-indicators): allow custom content via TemplateId directive, refactor +- test(accordion): coverage +- test(backdrop): coverage +- test(card-img): coverage +- test(collapse): coverage +- test(element-ref): update +- test(placeholder): coverage +- test(popover): coverage +- test(tooltip): coverage + --- #### `5.3.8` diff --git a/package-lock.json b/package-lock.json index 8191d22d..417eb8c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,24 +1,24 @@ { "name": "coreui-angular-dev", - "version": "5.3.7", + "version": "5.3.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "coreui-angular-dev", - "version": "5.3.7", - "license": "MIT", - "dependencies": { - "@angular/animations": "^19.1.1", - "@angular/cdk": "^19.1.0", - "@angular/common": "^19.1.1", - "@angular/compiler": "^19.1.1", - "@angular/core": "^19.1.1", - "@angular/forms": "^19.1.1", - "@angular/localize": "^19.1.1", - "@angular/platform-browser": "^19.1.1", - "@angular/platform-browser-dynamic": "^19.1.1", - "@angular/router": "^19.1.1", + "version": "5.3.9", + "license": "MIT", + "dependencies": { + "@angular/animations": "^19.1.4", + "@angular/cdk": "^19.1.2", + "@angular/common": "^19.1.4", + "@angular/compiler": "^19.1.4", + "@angular/core": "^19.1.4", + "@angular/forms": "^19.1.4", + "@angular/localize": "^19.1.4", + "@angular/platform-browser": "^19.1.4", + "@angular/platform-browser-dynamic": "^19.1.4", + "@angular/router": "^19.1.4", "@coreui/chartjs": "^4.0.0", "@coreui/icons": "^3.0.1", "@popperjs/core": "~2.11.8", @@ -29,27 +29,27 @@ "zone.js": "~0.15.0" }, "devDependencies": { - "@angular-devkit/build-angular": "^19.1.1", - "@angular-devkit/schematics": "^19.1.1", - "@angular/cli": "^19.1.1", - "@angular/compiler-cli": "^19.1.1", - "@angular/language-service": "^19.1.1", + "@angular-devkit/build-angular": "^19.1.5", + "@angular-devkit/schematics": "^19.1.5", + "@angular/cli": "^19.1.5", + "@angular/compiler-cli": "^19.1.4", + "@angular/language-service": "^19.1.4", "@types/jasmine": "^5.1.5", "@types/lodash-es": "^4.17.12", - "@types/node": "^22.10.7", + "@types/node": "^22.13.1", "angular-eslint": "^19.0.2", "copyfiles": "^2.4.1", - "eslint": "^9.18.0", + "eslint": "^9.19.0", "jasmine-core": "^5.5.0", "karma": "^6.4.4", "karma-chrome-launcher": "^3.2.0", "karma-coverage": "^2.2.1", "karma-jasmine": "^5.1.0", "karma-jasmine-html-reporter": "^2.1.0", - "ng-packagr": "^19.1.0", + "ng-packagr": "^19.1.2", "prettier": "^3.4.2", "typescript": "~5.6.3", - "typescript-eslint": "^8.20.0" + "typescript-eslint": "^8.23.0" }, "engines": { "node": "^20.11.1 || ^22.0.0", @@ -70,13 +70,13 @@ } }, "node_modules/@angular-devkit/architect": { - "version": "0.1901.1", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1901.1.tgz", - "integrity": "sha512-fhRID3z4Va1nvP4QS7iraMP+J0zpvsqax0MtoaHXiaSvruwcPbcYSvWj9aO/oo9cq2XTd1zVigrKUwfhabXRVw==", + "version": "0.1901.5", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1901.5.tgz", + "integrity": "sha512-zlRudZx34FkFZnSdaQCjxDleHwbQYNLdBFcLi+FBwt0UXqxmhbEIasK3l/3kCOC3QledrjUzVXgouji+OZ/WGQ==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "19.1.1", + "@angular-devkit/core": "19.1.5", "rxjs": "7.8.1" }, "engines": { @@ -86,17 +86,17 @@ } }, "node_modules/@angular-devkit/build-angular": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-19.1.1.tgz", - "integrity": "sha512-hpldWNWWvALDOzWodoRBXJtOBwZfIgvN8isuLHzSbmA4IeHh2WsTUfLFaWUstiJC9mapi9JT+p1dGwxXlaI/8Q==", + "version": "19.1.5", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-19.1.5.tgz", + "integrity": "sha512-ny7ktNOTxaEi6cS3V6XFP5bbJkgiMt3OUNUYLdfdbv4y6wolVlPVHKl+wb4xs6tgbnmx63+e6zGpoDMCRytgcg==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.1901.1", - "@angular-devkit/build-webpack": "0.1901.1", - "@angular-devkit/core": "19.1.1", - "@angular/build": "19.1.1", + "@angular-devkit/architect": "0.1901.5", + "@angular-devkit/build-webpack": "0.1901.5", + "@angular-devkit/core": "19.1.5", + "@angular/build": "19.1.5", "@babel/core": "7.26.0", "@babel/generator": "7.26.3", "@babel/helper-annotate-as-pure": "7.25.9", @@ -107,7 +107,7 @@ "@babel/preset-env": "7.26.0", "@babel/runtime": "7.26.0", "@discoveryjs/json-ext": "0.6.3", - "@ngtools/webpack": "19.1.1", + "@ngtools/webpack": "19.1.5", "@vitejs/plugin-basic-ssl": "1.2.0", "ansi-colors": "4.1.3", "autoprefixer": "10.4.20", @@ -161,7 +161,7 @@ "@angular/localize": "^19.0.0", "@angular/platform-server": "^19.0.0", "@angular/service-worker": "^19.0.0", - "@angular/ssr": "^19.1.1", + "@angular/ssr": "^19.1.5", "@web/test-runner": "^0.19.0", "browser-sync": "^3.0.2", "jest": "^29.5.0", @@ -169,7 +169,7 @@ "karma": "^6.3.0", "ng-packagr": "^19.0.0", "protractor": "^7.0.0", - "tailwindcss": "^2.0.0 || ^3.0.0", + "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", "typescript": ">=5.5 <5.8" }, "peerDependenciesMeta": { @@ -212,13 +212,13 @@ } }, "node_modules/@angular-devkit/build-webpack": { - "version": "0.1901.1", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1901.1.tgz", - "integrity": "sha512-WKh0uA7TNfGeOFLAfHS4yCRx3pPtsFrQJTOeYLU/r/scJS/xvDfvFNE0P1CZeL0G6V0O80V/Kj0ZoTS8zBac0g==", + "version": "0.1901.5", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1901.5.tgz", + "integrity": "sha512-UxEoF7F8L1GpH/N4me7VGe5ZPfxIiVHyhw5/ck3rcVbT6YD22/GYFGSJRGYP+D7LLTJ7OOQvfD6Bc/q62HhWvA==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/architect": "0.1901.1", + "@angular-devkit/architect": "0.1901.5", "rxjs": "7.8.1" }, "engines": { @@ -232,9 +232,9 @@ } }, "node_modules/@angular-devkit/core": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.1.1.tgz", - "integrity": "sha512-CAqst7WEasPHR4OFdbxxX3+NVqNTvYk3vtPbXT/jZ0L2EZRICQta2EClkdhSIiMkiMf0/2LNT05rYD7k4NHIQA==", + "version": "19.1.5", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.1.5.tgz", + "integrity": "sha512-wGKV+i5mCM/Hd/3CsdrIYcVi5G2Wg/D5941bUDXivrbsqHfKVINxAkI3OI1eaD90VnAL8ICrQEoAhh6ni2Umkg==", "dev": true, "license": "MIT", "dependencies": { @@ -260,13 +260,13 @@ } }, "node_modules/@angular-devkit/schematics": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.1.1.tgz", - "integrity": "sha512-4xodirv/kErn7L5N6NhIDfuVuNgNDmGX1+Pdu3yG2c1moOTyRBV684lv2qQJClNctOpELDM55IuX3MXud2qQaw==", + "version": "19.1.5", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.1.5.tgz", + "integrity": "sha512-8QjOlO2CktcTT0TWcaABea2xSePxoPKaZu96+6gc8oZzj/y8DbdGiO9mRvIac9+m4hiZI41Cqm1W+yMsCzYMkA==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "19.1.1", + "@angular-devkit/core": "19.1.5", "jsonc-parser": "3.3.1", "magic-string": "0.30.17", "ora": "5.4.1", @@ -382,9 +382,9 @@ } }, "node_modules/@angular/animations": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-19.1.1.tgz", - "integrity": "sha512-MWZKQSFBr7iEfLH4tSpsjjC/Afq8Udp4v6kv4YGRcXuJKn8cL6KZ+8nPFkZACYPNrB/5jrWN27HGjWWlO2Z2Hg==", + "version": "19.1.4", + "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-19.1.4.tgz", + "integrity": "sha512-QGswsf/X+k7TijIgBzL6V8+KcArFAgebY6zM0L/Len8v5PNzPzdjJH99+P++5AOLiJctYKfISUwnlMbDb50NrA==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -393,18 +393,19 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/core": "19.1.1" + "@angular/core": "19.1.4" } }, "node_modules/@angular/build": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/@angular/build/-/build-19.1.1.tgz", - "integrity": "sha512-E4jvi48zRCLkwcThQQm1Q1vq9aypf3+xSMQQMDcHzvt/89sucWTKkwgNHSW/Fi8qH23GhijCLXBHa4dHSoCB/A==", + "version": "19.1.5", + "resolved": "https://registry.npmjs.org/@angular/build/-/build-19.1.5.tgz", + "integrity": "sha512-byoHcv0/s6WGWap59s43N/eC+4NsviuTnGoj+iR0ayubk8snn6jdkZLbFDfnTuQlTiu4ok8/XcksjzeMkgGyyw==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.1901.1", + "@angular-devkit/architect": "0.1901.5", + "@angular-devkit/core": "19.1.5", "@babel/core": "7.26.0", "@babel/helper-annotate-as-pure": "7.25.9", "@babel/helper-split-export-declaration": "7.24.7", @@ -426,7 +427,7 @@ "rollup": "4.30.1", "sass": "1.83.1", "semver": "7.6.3", - "vite": "6.0.7", + "vite": "6.0.11", "watchpack": "2.4.2" }, "engines": { @@ -443,11 +444,11 @@ "@angular/localize": "^19.0.0", "@angular/platform-server": "^19.0.0", "@angular/service-worker": "^19.0.0", - "@angular/ssr": "^19.1.1", + "@angular/ssr": "^19.1.5", "less": "^4.2.0", "ng-packagr": "^19.0.0", "postcss": "^8.4.0", - "tailwindcss": "^2.0.0 || ^3.0.0", + "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", "typescript": ">=5.5 <5.8" }, "peerDependenciesMeta": { @@ -478,9 +479,9 @@ } }, "node_modules/@angular/cdk": { - "version": "19.1.0", - "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-19.1.0.tgz", - "integrity": "sha512-h7VSaMA/vFHb7u1bwoHKl3L3mZLIcXNZw6v7Nei9ITfEo1PfSKbrYhleeqpNikzE+LxNDKJrbZtpAckSYHblmA==", + "version": "19.1.2", + "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-19.1.2.tgz", + "integrity": "sha512-rzrZ4BkGNIZWSdw0OsuSB/H9UB5ppPvmBq+uRHdYmZoYjo5wu1pmePxAIZDIBR8xdaNy9rZ4ecS6IebDkgYPrg==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -495,18 +496,18 @@ } }, "node_modules/@angular/cli": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-19.1.1.tgz", - "integrity": "sha512-eggTJ6jaGtSlvq0qbncLvOGVFCvSUaHJarpnLUJhZKX6F3zXtc4gPGvW4za4yp2+IbeXWYbOhdqeDkwgEomNHg==", + "version": "19.1.5", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-19.1.5.tgz", + "integrity": "sha512-bedjH3jUcrLgN3GOTTuvjbPcY3Lm0YcYBVY35S1ugI88UK6nbtttiRdgK++Qk2Q8wbg6zuaBAr4ACbfPMsnRaA==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/architect": "0.1901.1", - "@angular-devkit/core": "19.1.1", - "@angular-devkit/schematics": "19.1.1", + "@angular-devkit/architect": "0.1901.5", + "@angular-devkit/core": "19.1.5", + "@angular-devkit/schematics": "19.1.5", "@inquirer/prompts": "7.2.1", "@listr2/prompt-adapter-inquirer": "2.0.18", - "@schematics/angular": "19.1.1", + "@schematics/angular": "19.1.5", "@yarnpkg/lockfile": "1.1.0", "ini": "5.0.0", "jsonc-parser": "3.3.1", @@ -529,9 +530,9 @@ } }, "node_modules/@angular/common": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/@angular/common/-/common-19.1.1.tgz", - "integrity": "sha512-2ZbnV8lM81ekLjRMRufRho7N8adz+Yjwj+3y5RB7+GW8fX5f9mm740ifyieBCXPLtiWb8ZK1i9gime6y64BEBQ==", + "version": "19.1.4", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-19.1.4.tgz", + "integrity": "sha512-E4MCl13VIotOxmzKQ/UGciPeaRXQgH7ymesEjYVGcT8jmC+qz5dEcoN7L5Jvq9aUsmLBt9MFp/B5QqKCIXMqYA==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -540,14 +541,14 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/core": "19.1.1", + "@angular/core": "19.1.4", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/compiler": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-19.1.1.tgz", - "integrity": "sha512-bXPiJKQYjH6kSBnlVHx8aLzYY7YhWw1cidthWwqNaXyZ4YYILom1lN3C7nJYOVDX8W64QCMimHqf8iD4guByxA==", + "version": "19.1.4", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-19.1.4.tgz", + "integrity": "sha512-9vGUZ+QhGWvf5dfeILybrh5rvZQtNqS8WumMeX2/vCb0JTA0N4DsL1Sy47HuWcgKBxbmHVUdF5/iufcFaqk2FA==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -556,7 +557,7 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/core": "19.1.1" + "@angular/core": "19.1.4" }, "peerDependenciesMeta": { "@angular/core": { @@ -565,9 +566,9 @@ } }, "node_modules/@angular/compiler-cli": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-19.1.1.tgz", - "integrity": "sha512-mk+CIV98WtZPfk4R0cdo7Jx8bZZewCO5K0dcG7/AMck+e2dDl2pjtXz1wYJi8NpUUAtcrr9HWlvxQ2L8IJMIag==", + "version": "19.1.4", + "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-19.1.4.tgz", + "integrity": "sha512-ozJvTUzPOgFqlz69YnV14Ncod+iH0cXZvUKerjw8o+JsixLG2LmJpwQ79Gh4a/ZQmAkAxMAYYK5izCiio8MmTg==", "license": "MIT", "dependencies": { "@babel/core": "7.26.0", @@ -588,14 +589,14 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/compiler": "19.1.1", + "@angular/compiler": "19.1.4", "typescript": ">=5.5 <5.8" } }, "node_modules/@angular/core": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/@angular/core/-/core-19.1.1.tgz", - "integrity": "sha512-uEDnomaIh7yUPx6hHWMFcWrUMOwishkkPToSFMltVLfRrfmAQL+WMpOGtR6qiFG6PIppsADIxXPRWVzfnYOYZg==", + "version": "19.1.4", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-19.1.4.tgz", + "integrity": "sha512-r3T81lM9evmuW36HA3VAxIJ61M8kirGR8yHoln9fXSnYG8UeJ7JlWEbVRHmVHKOB48VK0bS/VxqN+w9TOq3bZg==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -609,9 +610,9 @@ } }, "node_modules/@angular/forms": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-19.1.1.tgz", - "integrity": "sha512-MtvoAeOXa2v+24U+5BMwmJpbQs/SQ296u+mJiZ/hIuuB/XBZdlPMzGg0U9ENDg6kwwoZIypcgiQ0/+gIwxlCSw==", + "version": "19.1.4", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-19.1.4.tgz", + "integrity": "sha512-dcf4G+vXrfvy5NAP+C4A2rBeaZuwKs/TeWjZDpkRUPQMwTvDJcSNH+pqOeVsYUGNY2BkY1uPjzmgZh4F5NMQ9A==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -620,16 +621,16 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/common": "19.1.1", - "@angular/core": "19.1.1", - "@angular/platform-browser": "19.1.1", + "@angular/common": "19.1.4", + "@angular/core": "19.1.4", + "@angular/platform-browser": "19.1.4", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/language-service": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/@angular/language-service/-/language-service-19.1.1.tgz", - "integrity": "sha512-W2NDVvDVMcVcQ7cfl1fUFRWoOpsPO9YXcbFNoBlANfamAcu4gR7cssaAWh3o3UPchdxQui9Ka8X+UhrF5bGxVA==", + "version": "19.1.4", + "resolved": "https://registry.npmjs.org/@angular/language-service/-/language-service-19.1.4.tgz", + "integrity": "sha512-4W6dlBvukL3b7BnGiMM5cPGx3rAAVhBNicfNHX6hXCkz26AV0VFIbfrt/8GRSFmsDYZEOhXvhAy8dxHQCtyCqA==", "dev": true, "license": "MIT", "engines": { @@ -637,9 +638,9 @@ } }, "node_modules/@angular/localize": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/@angular/localize/-/localize-19.1.1.tgz", - "integrity": "sha512-MHrCE8bxNEp2BkSoLeuGJE0PMBF+Xu3zV52VpUaCrMQRHvHpq4ie+qosinQSDX72DKUUJUZuv0unApFCvhr2HA==", + "version": "19.1.4", + "resolved": "https://registry.npmjs.org/@angular/localize/-/localize-19.1.4.tgz", + "integrity": "sha512-AFfaxnGUWl1QZGmhYNTH8adWynSqjNwHweOUQ/ItIQ+MkbIPOpAtZp+ar6SRJZpatR59O8797jPKVFTAebLvLQ==", "license": "MIT", "dependencies": { "@babel/core": "7.26.0", @@ -656,14 +657,14 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/compiler": "19.1.1", - "@angular/compiler-cli": "19.1.1" + "@angular/compiler": "19.1.4", + "@angular/compiler-cli": "19.1.4" } }, "node_modules/@angular/platform-browser": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-19.1.1.tgz", - "integrity": "sha512-L33rk7j3FepDqHo29iqp7ucL1tBjGQed+e22ei9bCsj7CG0GNi5w8id3nyNImhwN26wtg++4cu4la+XxKWIkXg==", + "version": "19.1.4", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-19.1.4.tgz", + "integrity": "sha512-IoVIvemj7ni6GLDCvwtZhTgMQjPyG+xPW7rASN2RVl9T3uS1fJUpXrh5JzBcCikIj20O2KV9mqt7p4iIXy9jbQ==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -672,9 +673,9 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/animations": "19.1.1", - "@angular/common": "19.1.1", - "@angular/core": "19.1.1" + "@angular/animations": "19.1.4", + "@angular/common": "19.1.4", + "@angular/core": "19.1.4" }, "peerDependenciesMeta": { "@angular/animations": { @@ -683,9 +684,9 @@ } }, "node_modules/@angular/platform-browser-dynamic": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-19.1.1.tgz", - "integrity": "sha512-iEVOFKpBEFXKDqQb42xhEXpseQc2vpl16kuT9gbjuvBC8KJLsTdvE34HIoZN1Igm22wZzp+PBzSWYa8WiQK83A==", + "version": "19.1.4", + "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-19.1.4.tgz", + "integrity": "sha512-r1AM8qkjl63cg46tgOHsVV4URHDctcVpt98DU/d/yN8JAugrx6GA1qOM/HMDspMjEIU4aYcSkUUY6h6uIkYmOQ==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -694,16 +695,16 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/common": "19.1.1", - "@angular/compiler": "19.1.1", - "@angular/core": "19.1.1", - "@angular/platform-browser": "19.1.1" + "@angular/common": "19.1.4", + "@angular/compiler": "19.1.4", + "@angular/core": "19.1.4", + "@angular/platform-browser": "19.1.4" } }, "node_modules/@angular/router": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/@angular/router/-/router-19.1.1.tgz", - "integrity": "sha512-DbgluW4P0AfZ01kaoSJK+4eKrYnBvP5yGVSx+rhZhXOPpoVx76IOn691cdwC9CQuDIG9RqQWZL5TLfS959K0cA==", + "version": "19.1.4", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-19.1.4.tgz", + "integrity": "sha512-0gEhGGqcCS7adKuv/XeQjRbhEqRXPhIH4ygjwfonV+uvmK+C1sf+bnAt4o01hxwf12w4FcnNPkgBKt+rJJ+LpA==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -712,9 +713,9 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/common": "19.1.1", - "@angular/core": "19.1.1", - "@angular/platform-browser": "19.1.1", + "@angular/common": "19.1.4", + "@angular/core": "19.1.4", + "@angular/platform-browser": "19.1.4", "rxjs": "^6.5.3 || ^7.4.0" } }, @@ -2951,9 +2952,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.18.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.18.0.tgz", - "integrity": "sha512-fK6L7rxcq6/z+AaQMtiFTkvbHkBLNlwyRxHpKawP0x3u9+NC6MQTnFW+AdpwC6gfHTW0051cokQgtTN2FqlxQA==", + "version": "9.19.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.19.0.tgz", + "integrity": "sha512-rbq9/g38qjfqFLOVPvwjIvFFdNziEC5S65jmjPw5r6A//QH+W91akh9irMwjDN8zKUTak6W9EsAv4m/7Wnw0UQ==", "dev": true, "license": "MIT", "engines": { @@ -4069,9 +4070,9 @@ } }, "node_modules/@ngtools/webpack": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-19.1.1.tgz", - "integrity": "sha512-FKtKRiXTmfLfRFLNScyNNCQpazC9CylUFSCQmFjd02jChfC0IH2WYh4zPrWKYDbOgv2H1oerRD2fZDmFaBi5Ew==", + "version": "19.1.5", + "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-19.1.5.tgz", + "integrity": "sha512-oIpE5Ci/Gl2iZqa0Hs6IOxaXEDHkF/zisHcflzYGkMnYcSFj+wRgYEuBFaHLCwuxQf9OdGu31i05w849i6tY1Q==", "dev": true, "license": "MIT", "engines": { @@ -5035,9 +5036,9 @@ ] }, "node_modules/@rollup/wasm-node": { - "version": "4.29.1", - "resolved": "https://registry.npmjs.org/@rollup/wasm-node/-/wasm-node-4.29.1.tgz", - "integrity": "sha512-AOtO2Y+XzElJfmJgAECOgbutmKAK5XcKH7CipGDQDBMfLY04ezEoCHWEpmoX5L7/WH3k17rXMCClNiwDbWW+mw==", + "version": "4.34.2", + "resolved": "https://registry.npmjs.org/@rollup/wasm-node/-/wasm-node-4.34.2.tgz", + "integrity": "sha512-+lN8XHBdka9vIq0Hdsn1NLjdRYQXG1eoFCQws8K7KXPfw2jE0j1c3rSAi50pVrk9ykVPDJHoPq79twHOA5RvGg==", "dev": true, "license": "MIT", "dependencies": { @@ -5055,14 +5056,14 @@ } }, "node_modules/@schematics/angular": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-19.1.1.tgz", - "integrity": "sha512-XrnmSbCcDPePCbEVhgEPFZFiL/fowvkPJ8qOa1m9tWHSPYb739Vk3g+VDrAqNMm7FULcRzQzqHBq/IBB8qYfIg==", + "version": "19.1.5", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-19.1.5.tgz", + "integrity": "sha512-Yks2QD87z2qJhVLi6O0tQDBG4pyX5n5c8BYEyZ+yiThjzIXBRkHjWS1jIFvd/y1+yU/NQFHYG/sy8sVOxfQ9IA==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "19.1.1", - "@angular-devkit/schematics": "19.1.1", + "@angular-devkit/core": "19.1.5", + "@angular-devkit/schematics": "19.1.5", "jsonc-parser": "3.3.1" }, "engines": { @@ -5338,9 +5339,9 @@ } }, "node_modules/@types/express-serve-static-core": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.5.tgz", - "integrity": "sha512-GLZPrd9ckqEBFMcVM/qRFAP0Hg3qiVEojgEFsx/N/zKXsBzbGF6z5FBDpZ0+Xhp1xr+qRZYjfGr1cWHB9oFHSA==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.6.tgz", + "integrity": "sha512-3xhRnjJPkULekpSzgtoNYYcTWgEZkp4myc+Saevii5JPnHNvHMRlBSHDbs7Bh1iPPoVTERHEZXyhyLbMEsExsA==", "dev": true, "license": "MIT", "dependencies": { @@ -5419,9 +5420,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.10.7", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.7.tgz", - "integrity": "sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==", + "version": "22.13.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.1.tgz", + "integrity": "sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew==", "dev": true, "license": "MIT", "dependencies": { @@ -5503,9 +5504,9 @@ } }, "node_modules/@types/ws": { - "version": "8.5.13", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.13.tgz", - "integrity": "sha512-osM/gWBTPKgHV8XkTunnegTRIsvF6owmf5w+JtAfOw472dptdm0dlGv4xCt6GwQRcC2XVOvvRE/0bAoQcL2QkA==", + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.14.tgz", + "integrity": "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw==", "dev": true, "license": "MIT", "dependencies": { @@ -5513,21 +5514,21 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.20.0.tgz", - "integrity": "sha512-naduuphVw5StFfqp4Gq4WhIBE2gN1GEmMUExpJYknZJdRnc+2gDzB8Z3+5+/Kv33hPQRDGzQO/0opHE72lZZ6A==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.23.0.tgz", + "integrity": "sha512-vBz65tJgRrA1Q5gWlRfvoH+w943dq9K1p1yDBY2pc+a1nbBLZp7fB9+Hk8DaALUbzjqlMfgaqlVPT1REJdkt/w==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.20.0", - "@typescript-eslint/type-utils": "8.20.0", - "@typescript-eslint/utils": "8.20.0", - "@typescript-eslint/visitor-keys": "8.20.0", + "@typescript-eslint/scope-manager": "8.23.0", + "@typescript-eslint/type-utils": "8.23.0", + "@typescript-eslint/utils": "8.23.0", + "@typescript-eslint/visitor-keys": "8.23.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.0.0" + "ts-api-utils": "^2.0.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5553,16 +5554,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.20.0.tgz", - "integrity": "sha512-gKXG7A5HMyjDIedBi6bUrDcun8GIjnI8qOwVLiY3rx6T/sHP/19XLJOnIq/FgQvWLHja5JN/LSE7eklNBr612g==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.23.0.tgz", + "integrity": "sha512-h2lUByouOXFAlMec2mILeELUbME5SZRN/7R9Cw2RD2lRQQY08MWMM+PmVVKKJNK1aIwqTo9t/0CvOxwPbRIE2Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.20.0", - "@typescript-eslint/types": "8.20.0", - "@typescript-eslint/typescript-estree": "8.20.0", - "@typescript-eslint/visitor-keys": "8.20.0", + "@typescript-eslint/scope-manager": "8.23.0", + "@typescript-eslint/types": "8.23.0", + "@typescript-eslint/typescript-estree": "8.23.0", + "@typescript-eslint/visitor-keys": "8.23.0", "debug": "^4.3.4" }, "engines": { @@ -5578,14 +5579,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.20.0.tgz", - "integrity": "sha512-J7+VkpeGzhOt3FeG1+SzhiMj9NzGD/M6KoGn9f4dbz3YzK9hvbhVTmLj/HiTp9DazIzJ8B4XcM80LrR9Dm1rJw==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.23.0.tgz", + "integrity": "sha512-OGqo7+dXHqI7Hfm+WqkZjKjsiRtFUQHPdGMXzk5mYXhJUedO7e/Y7i8AK3MyLMgZR93TX4bIzYrfyVjLC+0VSw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.20.0", - "@typescript-eslint/visitor-keys": "8.20.0" + "@typescript-eslint/types": "8.23.0", + "@typescript-eslint/visitor-keys": "8.23.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5596,16 +5597,16 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.20.0.tgz", - "integrity": "sha512-bPC+j71GGvA7rVNAHAtOjbVXbLN5PkwqMvy1cwGeaxUoRQXVuKCebRoLzm+IPW/NtFFpstn1ummSIasD5t60GA==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.23.0.tgz", + "integrity": "sha512-iIuLdYpQWZKbiH+RkCGc6iu+VwscP5rCtQ1lyQ7TYuKLrcZoeJVpcLiG8DliXVkUxirW/PWlmS+d6yD51L9jvA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.20.0", - "@typescript-eslint/utils": "8.20.0", + "@typescript-eslint/typescript-estree": "8.23.0", + "@typescript-eslint/utils": "8.23.0", "debug": "^4.3.4", - "ts-api-utils": "^2.0.0" + "ts-api-utils": "^2.0.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5620,9 +5621,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.20.0.tgz", - "integrity": "sha512-cqaMiY72CkP+2xZRrFt3ExRBu0WmVitN/rYPZErA80mHjHx/Svgp8yfbzkJmDoQ/whcytOPO9/IZXnOc+wigRA==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.23.0.tgz", + "integrity": "sha512-1sK4ILJbCmZOTt9k4vkoulT6/y5CHJ1qUYxqpF1K/DBAd8+ZUL4LlSCxOssuH5m4rUaaN0uS0HlVPvd45zjduQ==", "dev": true, "license": "MIT", "engines": { @@ -5634,20 +5635,20 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.20.0.tgz", - "integrity": "sha512-Y7ncuy78bJqHI35NwzWol8E0X7XkRVS4K4P4TCyzWkOJih5NDvtoRDW4Ba9YJJoB2igm9yXDdYI/+fkiiAxPzA==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.23.0.tgz", + "integrity": "sha512-LcqzfipsB8RTvH8FX24W4UUFk1bl+0yTOf9ZA08XngFwMg4Kj8A+9hwz8Cr/ZS4KwHrmo9PJiLZkOt49vPnuvQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.20.0", - "@typescript-eslint/visitor-keys": "8.20.0", + "@typescript-eslint/types": "8.23.0", + "@typescript-eslint/visitor-keys": "8.23.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", - "ts-api-utils": "^2.0.0" + "ts-api-utils": "^2.0.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5661,16 +5662,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.20.0.tgz", - "integrity": "sha512-dq70RUw6UK9ei7vxc4KQtBRk7qkHZv447OUZ6RPQMQl71I3NZxQJX/f32Smr+iqWrB02pHKn2yAdHBb0KNrRMA==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.23.0.tgz", + "integrity": "sha512-uB/+PSo6Exu02b5ZEiVtmY6RVYO7YU5xqgzTIVZwTHvvK3HsL8tZZHFaTLFtRG3CsV4A5mhOv+NZx5BlhXPyIA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.20.0", - "@typescript-eslint/types": "8.20.0", - "@typescript-eslint/typescript-estree": "8.20.0" + "@typescript-eslint/scope-manager": "8.23.0", + "@typescript-eslint/types": "8.23.0", + "@typescript-eslint/typescript-estree": "8.23.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5685,13 +5686,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.20.0.tgz", - "integrity": "sha512-v/BpkeeYAsPkKCkR8BDwcno0llhzWVqPOamQrAEMdpZav2Y9OVjd9dwJyBLJWwf335B5DmlifECIkZRJCaGaHA==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.23.0.tgz", + "integrity": "sha512-oWWhcWDLwDfu++BGTZcmXWqpwtkwb5o7fxUIGksMQQDSdPW9prsSnfIOZMlsj4vBOSrcnjIUZMiIjODgGosFhQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.20.0", + "@typescript-eslint/types": "8.23.0", "eslint-visitor-keys": "^4.2.0" }, "engines": { @@ -7024,9 +7025,9 @@ "license": "MIT" }, "node_modules/commander": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-13.0.0.tgz", - "integrity": "sha512-oPYleIY8wmTVzkvQq10AEok6YcTC4sRUBl8F9gVuwchGVUCTbl/vhLTaQqutuuySYOsu8YTgV+OxKc/8Yvx+mQ==", + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", "dev": true, "license": "MIT", "engines": { @@ -8127,9 +8128,9 @@ } }, "node_modules/eslint": { - "version": "9.18.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.18.0.tgz", - "integrity": "sha512-+waTfRWQlSbpt3KWE+CjrPPYnbq9kfZIYUqapc0uBXyjTp8aYXZDsUH16m39Ryq3NjAVP4tjuF7KaukeqoCoaA==", + "version": "9.19.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.19.0.tgz", + "integrity": "sha512-ug92j0LepKlbbEv6hD911THhoRHmbdXt2gX+VDABAW/Ir7D3nqKdv5Pf5vtlyY6HQMTEP2skXY43ueqTCWssEA==", "dev": true, "license": "MIT", "dependencies": { @@ -8138,7 +8139,7 @@ "@eslint/config-array": "^0.19.0", "@eslint/core": "^0.10.0", "@eslint/eslintrc": "^3.2.0", - "@eslint/js": "9.18.0", + "@eslint/js": "9.19.0", "@eslint/plugin-kit": "^0.2.5", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -11569,9 +11570,9 @@ "license": "MIT" }, "node_modules/ng-packagr": { - "version": "19.1.0", - "resolved": "https://registry.npmjs.org/ng-packagr/-/ng-packagr-19.1.0.tgz", - "integrity": "sha512-i2S0tj2sQNOQGW+0bYViEftrnvzGSxW+/kDELVwEjnRh6KgAL0p6wH2w+aslCcELhruuGGCk96AaOfYwGPVsgQ==", + "version": "19.1.2", + "resolved": "https://registry.npmjs.org/ng-packagr/-/ng-packagr-19.1.2.tgz", + "integrity": "sha512-h8YDp6YdPwAwbl7rs0lJE7vVugobY6m+JogS0hQ7P+52RmslPT8kRCgdvGLIS1JySwPrDFQkPh2PLBaSjwcRqQ==", "dev": true, "license": "MIT", "dependencies": { @@ -11607,7 +11608,7 @@ }, "peerDependencies": { "@angular/compiler-cli": "^19.0.0 || ^19.1.0-next.0 || ^19.2.0-next.0", - "tailwindcss": "^2.0.0 || ^3.0.0", + "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", "tslib": "^2.3.0", "typescript": ">=5.5 <5.8" }, @@ -14831,9 +14832,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.0.tgz", - "integrity": "sha512-xCt/TOAc+EOHS1XPnijD3/yzpH6qg2xppZO1YDqGoVsNXfQfzHpOdNuXwrwOU8u4ITXJyDCTyt8w5g1sZv9ynQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.1.tgz", + "integrity": "sha512-dnlgjFSVetynI8nzgJ+qF62efpglpWRk8isUEWZGWlJYySCTD6aKvbUDu+zbPeDakk3bg5H4XpitHukgfL1m9w==", "dev": true, "license": "MIT", "engines": { @@ -14925,15 +14926,15 @@ } }, "node_modules/typescript-eslint": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.20.0.tgz", - "integrity": "sha512-Kxz2QRFsgbWj6Xcftlw3Dd154b3cEPFqQC+qMZrMypSijPd4UanKKvoKDrJ4o8AIfZFKAF+7sMaEIR8mTElozA==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.23.0.tgz", + "integrity": "sha512-/LBRo3HrXr5LxmrdYSOCvoAMm7p2jNizNfbIpCgvG4HMsnoprRUOce/+8VJ9BDYWW68rqIENE/haVLWPeFZBVQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.20.0", - "@typescript-eslint/parser": "8.20.0", - "@typescript-eslint/utils": "8.20.0" + "@typescript-eslint/eslint-plugin": "8.23.0", + "@typescript-eslint/parser": "8.23.0", + "@typescript-eslint/utils": "8.23.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -15203,9 +15204,9 @@ } }, "node_modules/vite": { - "version": "6.0.7", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.0.7.tgz", - "integrity": "sha512-RDt8r/7qx9940f8FcOIAH9PTViRrghKaK2K1jY3RaAURrEUbm9Du1mJ72G+jlhtG3WwodnfzY8ORQZbBavZEAQ==", + "version": "6.0.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.0.11.tgz", + "integrity": "sha512-4VL9mQPKoHy4+FE0NnRE/kbY51TOfaknxAjt3fJbGJxhIpBZiqVzlZDEesWWsuREXHwNdAoOFZ9MkPEVXczHwg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 62016a11..ed17dad3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "coreui-angular-dev", - "version": "5.3.8", + "version": "5.3.9", "description": "CoreUI Components Library for Angular", "copyright": "Copyright 2025 creativeLabs Łukasz Holeczek", "license": "MIT", @@ -10,7 +10,7 @@ "watch:lib:dev": "ng build coreui-angular --watch --configuration production", "build:lib:prod": "ng build coreui-angular", "postbuild:lib:prod": "npm run build --prefix projects/coreui-angular", - "test:lib:dev": "ng test --no-watch --code-coverage coreui-angular ", + "test:lib:dev": "ng test coreui-angular", "test:lib:prod": "ng test coreui-angular --karma-config=projects/coreui-angular/karma.conf.github.js", "prepublish:lib": "npm run prepublish:icons && ng lint coreui-angular && ng test coreui-angular --watch=false && npm run build:lib:prod", "publish:lib": "cd dist/coreui-angular/ && npm publish --tag next --dry-run", @@ -39,16 +39,16 @@ }, "private": true, "dependencies": { - "@angular/animations": "^19.1.1", - "@angular/cdk": "^19.1.0", - "@angular/common": "^19.1.1", - "@angular/compiler": "^19.1.1", - "@angular/core": "^19.1.1", - "@angular/forms": "^19.1.1", - "@angular/localize": "^19.1.1", - "@angular/platform-browser": "^19.1.1", - "@angular/platform-browser-dynamic": "^19.1.1", - "@angular/router": "^19.1.1", + "@angular/animations": "^19.1.4", + "@angular/cdk": "^19.1.2", + "@angular/common": "^19.1.4", + "@angular/compiler": "^19.1.4", + "@angular/core": "^19.1.4", + "@angular/forms": "^19.1.4", + "@angular/localize": "^19.1.4", + "@angular/platform-browser": "^19.1.4", + "@angular/platform-browser-dynamic": "^19.1.4", + "@angular/router": "^19.1.4", "@coreui/chartjs": "^4.0.0", "@coreui/icons": "^3.0.1", "@popperjs/core": "~2.11.8", @@ -59,27 +59,27 @@ "zone.js": "~0.15.0" }, "devDependencies": { - "@angular-devkit/build-angular": "^19.1.1", - "@angular-devkit/schematics": "^19.1.1", - "@angular/cli": "^19.1.1", - "@angular/compiler-cli": "^19.1.1", - "@angular/language-service": "^19.1.1", + "@angular-devkit/build-angular": "^19.1.5", + "@angular-devkit/schematics": "^19.1.5", + "@angular/cli": "^19.1.5", + "@angular/compiler-cli": "^19.1.4", + "@angular/language-service": "^19.1.4", "@types/jasmine": "^5.1.5", "@types/lodash-es": "^4.17.12", - "@types/node": "^22.10.7", + "@types/node": "^22.13.1", "angular-eslint": "^19.0.2", "copyfiles": "^2.4.1", - "eslint": "^9.18.0", + "eslint": "^9.19.0", "jasmine-core": "^5.5.0", "karma": "^6.4.4", "karma-chrome-launcher": "^3.2.0", "karma-coverage": "^2.2.1", "karma-jasmine": "^5.1.0", "karma-jasmine-html-reporter": "^2.1.0", - "ng-packagr": "^19.1.0", + "ng-packagr": "^19.1.2", "prettier": "^3.4.2", "typescript": "~5.6.3", - "typescript-eslint": "^8.20.0" + "typescript-eslint": "^8.23.0" }, "keywords": [ "angular", diff --git a/projects/coreui-angular-chartjs/package.json b/projects/coreui-angular-chartjs/package.json index 5087ae5c..5853d04a 100644 --- a/projects/coreui-angular-chartjs/package.json +++ b/projects/coreui-angular-chartjs/package.json @@ -1,6 +1,6 @@ { "name": "@coreui/angular-chartjs", - "version": "5.3.8", + "version": "5.3.9", "description": "Angular wrapper component for Chart.js", "copyright": "Copyright 2025 creativeLabs Łukasz Holeczek", "license": "MIT", @@ -25,7 +25,7 @@ "url": "https://github.com/coreui/coreui-angular/issues" }, "peerDependencies": { - "@angular/core": "^19.1.1", + "@angular/core": "^19.1.4", "@coreui/chartjs": "^4.0.0", "chart.js": "^4.4.7" }, diff --git a/projects/coreui-angular/package.json b/projects/coreui-angular/package.json index d32ebaf6..988956ab 100644 --- a/projects/coreui-angular/package.json +++ b/projects/coreui-angular/package.json @@ -1,6 +1,6 @@ { "name": "@coreui/angular", - "version": "5.3.8", + "version": "5.3.9", "description": "CoreUI Components Library for Angular", "copyright": "Copyright 2025 creativeLabs Łukasz Holeczek", "license": "MIT", @@ -23,11 +23,11 @@ }, "sideEffects": false, "peerDependencies": { - "@angular/animations": "^19.1.1", - "@angular/cdk": "^19.1.0", - "@angular/common": "^19.1.1", - "@angular/core": "^19.1.1", - "@angular/router": "^19.1.1", + "@angular/animations": "^19.1.4", + "@angular/cdk": "^19.1.2", + "@angular/common": "^19.1.4", + "@angular/core": "^19.1.4", + "@angular/router": "^19.1.4", "@coreui/coreui": "^5.2.0", "@coreui/icons-angular": "~5.3.8", "rxjs": "^7.8.1" diff --git a/projects/coreui-angular/src/lib/accordion/accordion-item/accordion-item.component.spec.ts b/projects/coreui-angular/src/lib/accordion/accordion-item/accordion-item.component.spec.ts index be5f0e04..5c4ebac1 100644 --- a/projects/coreui-angular/src/lib/accordion/accordion-item/accordion-item.component.spec.ts +++ b/projects/coreui-angular/src/lib/accordion/accordion-item/accordion-item.component.spec.ts @@ -3,22 +3,24 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { AccordionButtonDirective } from '../accordion-button/accordion-button.directive'; import { AccordionService } from '../accordion.service'; import { AccordionItemComponent } from './accordion-item.component'; +import { ComponentRef } from '@angular/core'; describe('AccordionItemComponent', () => { let component: AccordionItemComponent; + let componentRef: ComponentRef; let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ providers: [AccordionService], imports: [NoopAnimationsModule, AccordionButtonDirective, AccordionItemComponent] - }) - .compileComponents(); + }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(AccordionItemComponent); component = fixture.componentInstance; + componentRef = fixture.componentRef; fixture.detectChanges(); }); @@ -29,4 +31,12 @@ describe('AccordionItemComponent', () => { it('should have css classes', () => { expect(fixture.nativeElement).toHaveClass('accordion-item'); }); + + it('should toggle item', () => { + expect(component.visible).toBeFalse(); + component.toggleItem(); + expect(component.visible).toBeTrue(); + component.toggleItem(); + expect(component.visible).toBeFalse(); + }); }); diff --git a/projects/coreui-angular/src/lib/accordion/accordion-item/accordion-item.component.ts b/projects/coreui-angular/src/lib/accordion/accordion-item/accordion-item.component.ts index 8085652f..f1235a36 100644 --- a/projects/coreui-angular/src/lib/accordion/accordion-item/accordion-item.component.ts +++ b/projects/coreui-angular/src/lib/accordion/accordion-item/accordion-item.component.ts @@ -33,7 +33,7 @@ export class AccordionItemComponent implements OnInit, OnDestroy { /** * Toggle an accordion item programmatically - * @type boolean + * @return boolean * @default false */ // eslint-disable-next-line @angular-eslint/no-input-rename @@ -42,9 +42,7 @@ export class AccordionItemComponent implements OnInit, OnDestroy { readonly itemVisible = signal(false); readonly #visibleInputChange = effect(() => { - setTimeout(() => { - this.itemVisible.set(this.visibleInput()); - }); + this.visible = this.visibleInput(); }); set visible(value: boolean) { diff --git a/projects/coreui-angular/src/lib/alert/alert.component.html b/projects/coreui-angular/src/lib/alert/alert.component.html index efee673a..24f1e1e1 100644 --- a/projects/coreui-angular/src/lib/alert/alert.component.html +++ b/projects/coreui-angular/src/lib/alert/alert.component.html @@ -1,6 +1,6 @@ -@if (visible || !hide) { +@if (visible || !hide()) { @if (dismissible) { - + } } diff --git a/projects/coreui-angular/src/lib/alert/alert.component.spec.ts b/projects/coreui-angular/src/lib/alert/alert.component.spec.ts index 82110a96..cf4a7f4f 100644 --- a/projects/coreui-angular/src/lib/alert/alert.component.spec.ts +++ b/projects/coreui-angular/src/lib/alert/alert.component.spec.ts @@ -2,21 +2,23 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { AlertComponent } from './alert.component'; +import { ComponentRef } from '@angular/core'; describe('AlertComponent', () => { let component: AlertComponent; + let componentRef: ComponentRef; let fixture: ComponentFixture; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - imports: [BrowserAnimationsModule, AlertComponent] - }) - .compileComponents(); + imports: [BrowserAnimationsModule, AlertComponent, BrowserAnimationsModule] + }).compileComponents(); })); beforeEach(() => { fixture = TestBed.createComponent(AlertComponent); component = fixture.componentInstance; + componentRef = fixture.componentRef; fixture.detectChanges(); }); @@ -24,7 +26,31 @@ describe('AlertComponent', () => { expect(component).toBeTruthy(); }); - it('should have css classes', () => { + it('should have css classes and styles', () => { expect(fixture.nativeElement).toHaveClass('alert'); + expect(fixture.nativeElement).toHaveClass('alert-primary'); + expect(fixture.nativeElement).toHaveClass('show'); + expect(fixture.nativeElement.style.opacity).toBe('1'); + componentRef.setInput('visible', false); + componentRef.setInput('color', 'danger'); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveClass('alert-danger'); + expect(fixture.nativeElement.style.opacity).toBe('0'); + expect(fixture.nativeElement.style.height).toBe('0px'); + componentRef.setInput('dismissible', true); + componentRef.setInput('fade', true); + componentRef.setInput('variant', 'solid'); + componentRef.setInput('visible', true); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveClass('alert-dismissible'); + expect(fixture.nativeElement).toHaveClass('fade'); + expect(fixture.nativeElement).not.toHaveClass('alert-danger'); + expect(fixture.nativeElement).toHaveClass('bg-danger'); + expect(fixture.nativeElement).toHaveClass('text-white'); + expect(fixture.nativeElement.style).toHaveSize(0); + }); + + it('should have attributes', () => { + expect(fixture.nativeElement.getAttribute('role')).toBe('alert'); }); }); diff --git a/projects/coreui-angular/src/lib/alert/alert.component.ts b/projects/coreui-angular/src/lib/alert/alert.component.ts index 6fa65a7b..2e80f84e 100644 --- a/projects/coreui-angular/src/lib/alert/alert.component.ts +++ b/projects/coreui-angular/src/lib/alert/alert.component.ts @@ -1,14 +1,13 @@ import { - AfterContentInit, booleanAttribute, Component, - ContentChildren, - EventEmitter, - HostBinding, - HostListener, - Input, - Output, - QueryList + computed, + contentChildren, + input, + linkedSignal, + output, + signal, + TemplateRef } from '@angular/core'; import { NgTemplateOutlet } from '@angular/common'; import { animate, AnimationEvent, state, style, transition, trigger } from '@angular/animations'; @@ -17,139 +16,158 @@ import { Colors } from '../coreui.types'; import { TemplateIdDirective } from '../shared'; import { ButtonCloseDirective } from '../button'; -type AnimateType = ('hide' | 'show'); +type AnimateType = 'hide' | 'show'; @Component({ - selector: 'c-alert', - templateUrl: './alert.component.html', - styleUrls: ['./alert.component.scss'], - exportAs: 'cAlert', - imports: [NgTemplateOutlet, ButtonCloseDirective], - animations: [ - trigger('fadeInOut', [ - state('show', style({ opacity: 1, height: '*', padding: '*', border: '*', margin: '*' })), - state('hide', style({ opacity: 0, height: 0, padding: 0, border: 0, margin: 0 })), - state('void', style({ opacity: 0, height: 0, padding: 0, border: 0, margin: 0 })), - transition('show => hide', [ - animate('.3s ease-out') - ]), - transition('hide => show', [ - animate('.3s ease-in') - ]), - transition('show => void', [ - animate('.3s ease-out') - ]), - transition('void => show', [ - animate('.3s ease-in') - ]) - ]) - ] + selector: 'c-alert', + templateUrl: './alert.component.html', + styleUrls: ['./alert.component.scss'], + exportAs: 'cAlert', + imports: [NgTemplateOutlet, ButtonCloseDirective], + animations: [ + trigger('fadeInOut', [ + state('show', style({ opacity: 1, height: '*', padding: '*', border: '*', margin: '*' })), + state('hide', style({ opacity: 0, height: 0, padding: 0, border: 0, margin: 0 })), + state('void', style({ opacity: 0, height: 0, padding: 0, border: 0, margin: 0 })), + transition('show => hide', [animate('.3s ease-out')]), + transition('hide => show', [animate('.3s ease-in')]), + transition('show => void', [animate('.3s ease-out')]), + transition('void => show', [animate('.3s ease-in')]) + ]) + ], + host: { + '[@.disabled]': '!fade()', + '[@fadeInOut]': 'animateType', + '[attr.role]': 'role()', + '[class]': 'hostClasses()', + '(@fadeInOut.start)': 'onAnimationStart($event)', + '(@fadeInOut.done)': 'onAnimationDone($event)' + } }) -export class AlertComponent implements AfterContentInit { - - hide!: boolean; +export class AlertComponent { /** * Sets the color context of the component to one of CoreUI’s themed colors. - * - * @type Colors + * @return Colors * @default 'primary' */ - @Input() color: Colors = 'primary'; + readonly color = input('primary'); + /** * Default role for alert. [docs] - * @type string + * @return string * @default 'alert' */ - @HostBinding('attr.role') - @Input() role = 'alert'; + readonly role = input('alert'); + /** * Set the alert variant to a solid. - * @type string - */ - @Input() variant?: 'solid' | string; - /** - * Event triggered on the alert dismiss. + * @return string */ - @Output() visibleChange: EventEmitter = new EventEmitter(); - templates: any = {}; - @ContentChildren(TemplateIdDirective, { descendants: true }) contentTemplates!: QueryList; + readonly variant = input<'solid'>(); /** * Optionally adds a close button to alert and allow it to self dismiss. - * @type boolean + * @return boolean * @default false */ - @Input({ transform: booleanAttribute }) dismissible: boolean = false; + readonly dismissibleInput = input(false, { transform: booleanAttribute, alias: 'dismissible' }); + + readonly #dismissible = linkedSignal({ + source: () => this.dismissibleInput(), + computation: (value) => { + return value; + } + }); + + set dismissible(value: boolean) { + this.#dismissible.set(value); + } + + get dismissible() { + return this.#dismissible(); + } /** * Adds animation for dismissible alert. - * @type boolean + * @return boolean */ - @Input({ transform: booleanAttribute }) fade: boolean = false; + readonly fade = input(false, { transform: booleanAttribute }); /** * Toggle the visibility of alert component. - * @type boolean + * @return boolean */ - @Input({ transform: booleanAttribute }) + readonly visibleInput = input(true, { transform: booleanAttribute, alias: 'visible' }); + + readonly #visible = linkedSignal({ + source: () => this.visibleInput(), + computation: (value) => { + return value; + } + }); + set visible(value: boolean) { - if (this.#visible !== value) { - this.#visible = value; + if (this.#visible() !== value) { + this.#visible.set(value); this.visibleChange.emit(value); } - }; + } get visible() { - return this.#visible; + return this.#visible(); } - #visible: boolean = true; + readonly hide = signal(false); - @HostBinding('@.disabled') - get animationDisabled(): boolean { - return !this.fade; - } + /** + * Event triggered on the alert dismiss. + */ + readonly visibleChange = output(); + + readonly contentTemplates = contentChildren(TemplateIdDirective, { descendants: true }); + + readonly templates = computed(() => { + return this.contentTemplates().reduce( + (acc, child) => { + acc[child.id] = child.templateRef; + return acc; + }, + {} as Record> + ); + }); - @HostBinding('@fadeInOut') get animateType(): AnimateType { return this.visible ? 'show' : 'hide'; } - @HostBinding('class') - get hostClasses(): any { + readonly hostClasses = computed(() => { + const color = this.color(); + const variant = this.variant(); return { alert: true, 'alert-dismissible': this.dismissible, - fade: this.fade, - show: !this.hide, - [`alert-${this.color}`]: !!this.color && this.variant !== 'solid', - [`bg-${this.color}`]: !!this.color && this.variant === 'solid', - 'text-white': !!this.color && this.variant === 'solid' - }; - } + fade: this.fade(), + show: !this.hide(), + [`alert-${color}`]: !!color && variant !== 'solid', + [`bg-${color}`]: !!color && variant === 'solid', + 'text-white': !!color && variant === 'solid' + } as Record; + }); - @HostListener('@fadeInOut.start', ['$event']) onAnimationStart($event: AnimationEvent): void { this.onAnimationEvent($event); } - @HostListener('@fadeInOut.done', ['$event']) onAnimationDone($event: AnimationEvent): void { this.onAnimationEvent($event); } - ngAfterContentInit(): void { - this.contentTemplates.forEach((child: TemplateIdDirective) => { - this.templates[child.id] = child.templateRef; - }); - } - onAnimationEvent(event: AnimationEvent): void { - this.hide = event.phaseName === 'start' && event.toState === 'show'; + this.hide.set(event.phaseName === 'start' && event.toState === 'show'); if (event.phaseName === 'done') { - this.hide = (event.toState === 'hide' || event.toState === 'void'); + this.hide.set(event.toState === 'hide' || event.toState === 'void'); if (event.toState === 'show') { - this.hide = false; + this.hide.set(false); } } } diff --git a/projects/coreui-angular/src/lib/backdrop/backdrop.service.spec.ts b/projects/coreui-angular/src/lib/backdrop/backdrop.service.spec.ts index ff5fd53a..46243b1f 100644 --- a/projects/coreui-angular/src/lib/backdrop/backdrop.service.spec.ts +++ b/projects/coreui-angular/src/lib/backdrop/backdrop.service.spec.ts @@ -1,16 +1,59 @@ -import { TestBed } from '@angular/core/testing'; +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; import { BackdropService } from './backdrop.service'; +import { DOCUMENT } from '@angular/common'; describe('BackdropService', () => { let service: BackdropService; + let document: Document; + let backdrop: any; beforeEach(() => { TestBed.configureTestingModule({}); service = TestBed.inject(BackdropService); + document = TestBed.inject(DOCUMENT); }); + afterAll(() => { + expect(document.querySelector('.modal-backdrop')).toBeNull(); + }, 500); + it('should be created', () => { expect(service).toBeTruthy(); }); + + it('should set backdrop', fakeAsync(() => { + // expect(service.scrollbarWidth).toBe('0px'); + expect(document.querySelector('.modal-backdrop')).toBeNull(); + backdrop = service.setBackdrop(); + tick(); + expect(document.querySelector('.modal-backdrop')).not.toBeNull(); + expect(backdrop).toHaveClass('modal-backdrop'); + expect(backdrop).toHaveClass('fade'); + expect(backdrop).toHaveClass('show'); + service.clearBackdrop(backdrop); + expect(backdrop).not.toHaveClass('show'); + expect(document.querySelector('.modal-backdrop')).not.toBeNull(); + })); + + it('should hide scrollbar', () => { + service.hideScrollbar(); + expect(document.body.style.overflow).toBe('hidden'); + // expect(document.body.style.paddingRight).toBe('0px'); + }); + + it('should reset scrollbar', () => { + service.resetScrollbar(); + expect(document.body.style.overflow).not.toBe('hidden'); + }); + + it('should react to backdrop click', fakeAsync(() => { + backdrop = service.setBackdrop(); + tick(); + service.backdropClick$.subscribe((value) => { + expect(value).toBeTrue(); + }); + backdrop.dispatchEvent(new MouseEvent('click')); + service.clearBackdrop(backdrop); + })); }); diff --git a/projects/coreui-angular/src/lib/backdrop/backdrop.service.ts b/projects/coreui-angular/src/lib/backdrop/backdrop.service.ts index 38b5b82f..e090e93a 100644 --- a/projects/coreui-angular/src/lib/backdrop/backdrop.service.ts +++ b/projects/coreui-angular/src/lib/backdrop/backdrop.service.ts @@ -6,7 +6,6 @@ import { Subject } from 'rxjs'; providedIn: 'root' }) export class BackdropService { - readonly #backdropClick = new Subject(); readonly backdropClick$ = this.#backdropClick.asObservable(); @@ -58,7 +57,9 @@ export class BackdropService { return undefined; } - get #isRTL() { return this.#document.documentElement.dir === 'rtl' || this.#document.body.dir === 'rtl'; } + get #isRTL() { + return [this.#document.documentElement.dir, this.#document.body.dir].includes('rtl'); + } #scrollBarVisible = true; diff --git a/projects/coreui-angular/src/lib/breadcrumb/breadcrumb-item/breadcrumb-item.component.html b/projects/coreui-angular/src/lib/breadcrumb/breadcrumb-item/breadcrumb-item.component.html index ff8e0bf0..7c67d055 100644 --- a/projects/coreui-angular/src/lib/breadcrumb/breadcrumb-item/breadcrumb-item.component.html +++ b/projects/coreui-angular/src/lib/breadcrumb/breadcrumb-item/breadcrumb-item.component.html @@ -1,19 +1,19 @@ -@if (!active) { - } @else { - + } diff --git a/projects/coreui-angular/src/lib/breadcrumb/breadcrumb-item/breadcrumb-item.component.spec.ts b/projects/coreui-angular/src/lib/breadcrumb/breadcrumb-item/breadcrumb-item.component.spec.ts index a47e3c8b..d2f39847 100644 --- a/projects/coreui-angular/src/lib/breadcrumb/breadcrumb-item/breadcrumb-item.component.spec.ts +++ b/projects/coreui-angular/src/lib/breadcrumb/breadcrumb-item/breadcrumb-item.component.spec.ts @@ -1,21 +1,24 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { RouterTestingModule } from '@angular/router/testing'; import { BreadcrumbItemComponent } from './breadcrumb-item.component'; +import { provideRouter } from '@angular/router'; +import { ComponentRef } from '@angular/core'; describe('BreadcrumbItemComponent', () => { let component: BreadcrumbItemComponent; + let componentRef: ComponentRef; let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [RouterTestingModule, BreadcrumbItemComponent] - }) - .compileComponents(); + imports: [BreadcrumbItemComponent], + providers: [provideRouter([])] + }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(BreadcrumbItemComponent); component = fixture.componentInstance; + componentRef = fixture.componentRef; fixture.detectChanges(); }); @@ -25,5 +28,22 @@ describe('BreadcrumbItemComponent', () => { it('should have css classes', () => { expect(fixture.nativeElement).toHaveClass('breadcrumb-item'); + expect(fixture.nativeElement).not.toHaveClass('active'); + }); + + it('should have active class', () => { + componentRef.setInput('active', true); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveClass('active'); + }); + + it('should have aria-current attribute', () => { + expect(fixture.nativeElement.getAttribute('aria-current')).toBeNull(); + componentRef.setInput('active', true); + fixture.detectChanges(); + expect(fixture.nativeElement.getAttribute('aria-current')).toBe('page'); + componentRef.setInput('active', false); + fixture.detectChanges(); + expect(fixture.nativeElement.getAttribute('aria-current')).toBeNull(); }); }); diff --git a/projects/coreui-angular/src/lib/breadcrumb/breadcrumb-item/breadcrumb-item.component.ts b/projects/coreui-angular/src/lib/breadcrumb/breadcrumb-item/breadcrumb-item.component.ts index d7b85300..953fa61b 100644 --- a/projects/coreui-angular/src/lib/breadcrumb/breadcrumb-item/breadcrumb-item.component.ts +++ b/projects/coreui-angular/src/lib/breadcrumb/breadcrumb-item/breadcrumb-item.component.ts @@ -1,4 +1,4 @@ -import { booleanAttribute, Component, HostBinding, Input } from '@angular/core'; +import { booleanAttribute, Component, computed, input } from '@angular/core'; import { NgTemplateOutlet } from '@angular/common'; import { RouterModule } from '@angular/router'; @@ -6,43 +6,49 @@ import { HtmlAttributesDirective } from '../../shared'; import { INavAttributes, INavLinkProps } from './breadcrumb-item'; @Component({ - selector: 'c-breadcrumb-item', - templateUrl: './breadcrumb-item.component.html', - styleUrls: ['./breadcrumb-item.component.scss'], - imports: [RouterModule, NgTemplateOutlet, HtmlAttributesDirective] + selector: 'c-breadcrumb-item', + templateUrl: './breadcrumb-item.component.html', + styleUrls: ['./breadcrumb-item.component.scss'], + imports: [RouterModule, NgTemplateOutlet, HtmlAttributesDirective], + exportAs: 'breadcrumbItem', + host: { + '[attr.aria-current]': 'ariaCurrent()', + '[class]': 'hostClasses()' + } }) export class BreadcrumbItemComponent { - /** * Toggle the active state for the component. [docs] - * @type boolean + * @return boolean */ - @Input({ transform: booleanAttribute }) active?: boolean; + readonly active = input(undefined, { transform: booleanAttribute }); + /** * The `url` prop for the inner `[routerLink]` directive. [docs] * @type string */ - @Input() url?: string | any[]; + readonly url = input(); + /** * Additional html attributes for link. [docs] * @type INavAttributes */ - @Input() attributes?: INavAttributes; + readonly attributes = input(); + /** * Some `NavigationExtras` props for the inner `[routerLink]` directive and `routerLinkActiveOptions`. [docs] * @type INavLinkProps */ - @Input() linkProps?: INavLinkProps; + readonly linkProps = input(); - @HostBinding('attr.aria-current') get ariaCurrent(): string | null { - return this.active ? 'page' : null; - } + readonly ariaCurrent = computed((): string | null => { + return this.active() ? 'page' : null; + }); - @HostBinding('class') - get hostClasses(): any { + readonly hostClasses = computed(() => { return { 'breadcrumb-item': true, - active: this.active - }; - } + active: this.active() + } as Record; + }); } diff --git a/projects/coreui-angular/src/lib/breadcrumb/breadcrumb-item/breadcrumb-item.ts b/projects/coreui-angular/src/lib/breadcrumb/breadcrumb-item/breadcrumb-item.ts index d82381a9..944de443 100644 --- a/projects/coreui-angular/src/lib/breadcrumb/breadcrumb-item/breadcrumb-item.ts +++ b/projects/coreui-angular/src/lib/breadcrumb/breadcrumb-item/breadcrumb-item.ts @@ -6,6 +6,7 @@ interface IBreadcrumbItem { attributes?: INavAttributes; linkProps?: INavLinkProps; class?: string; + queryParams?: { [key: string]: any }; } export { INavAttributes, INavLinkProps, IBreadcrumbItem }; diff --git a/projects/coreui-angular/src/lib/breadcrumb/breadcrumb-router/breadcrumb-router.component.spec.ts b/projects/coreui-angular/src/lib/breadcrumb/breadcrumb-router/breadcrumb-router.component.spec.ts index cc0a3ccb..b3ba2669 100644 --- a/projects/coreui-angular/src/lib/breadcrumb/breadcrumb-router/breadcrumb-router.component.spec.ts +++ b/projects/coreui-angular/src/lib/breadcrumb/breadcrumb-router/breadcrumb-router.component.spec.ts @@ -1,31 +1,72 @@ +import { ComponentRef } from '@angular/core'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { provideRouter, Router } from '@angular/router'; +import { provideRouter, Route } from '@angular/router'; +import { RouterTestingHarness } from '@angular/router/testing'; +import { take } from 'rxjs'; import { BreadcrumbRouterComponent } from './breadcrumb-router.component'; import { BreadcrumbRouterService } from './breadcrumb-router.service'; describe('BreadcrumbComponent', () => { let component: BreadcrumbRouterComponent; + let componentRef: ComponentRef; let fixture: ComponentFixture; - let router: Router; + let harness: RouterTestingHarness; + + const routes: Route[] = [ + { path: 'home', component: BreadcrumbRouterComponent, data: { title: 'Home' } }, + { path: 'color', component: BreadcrumbRouterComponent, title: 'Color' }, + { path: '', component: BreadcrumbRouterComponent } + ]; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - imports: [ - BreadcrumbRouterComponent - ], - providers: [BreadcrumbRouterService, provideRouter([])] + imports: [BreadcrumbRouterComponent], + providers: [BreadcrumbRouterService, provideRouter(routes)] }).compileComponents(); })); - beforeEach(() => { + beforeEach(async () => { fixture = TestBed.createComponent(BreadcrumbRouterComponent); - router = TestBed.inject(Router); component = fixture.componentInstance; + componentRef = fixture.componentRef; + + harness = await RouterTestingHarness.create(); fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); + + it('should have breadcrumbs', () => { + expect(component.breadcrumbs).toBeDefined(); + }); + + it('should get breadcrumbs from service', async () => { + const comp = await harness.navigateByUrl('/home'); + component.breadcrumbs?.pipe(take(1)).subscribe((breadcrumbs) => { + expect(breadcrumbs).toEqual([{ label: 'Home', url: '/home', queryParams: {} }]); + }); + }); + it('should get breadcrumbs from service', async () => { + const comp = await harness.navigateByUrl('/color?id=1&test=2'); + component.breadcrumbs?.pipe(take(1)).subscribe((breadcrumbs) => { + expect(breadcrumbs).toEqual([{ label: 'Color', url: '/color', queryParams: { id: '1', test: '2' } }]); + }); + }); + it('should get breadcrumbs from service', async () => { + const comp = await harness.navigateByUrl('/'); + component.breadcrumbs?.pipe(take(1)).subscribe((breadcrumbs) => { + expect(breadcrumbs).toEqual([{ label: '', url: '/', queryParams: {} }]); + }); + }); + + it('should emit breadcrumbs on items change', () => { + componentRef.setInput('items', [{ label: 'test' }]); + fixture.detectChanges(); + component.breadcrumbs?.pipe(take(1)).subscribe((breadcrumbs) => { + expect(breadcrumbs).toEqual([{ label: 'test' }]); + }); + }); }); diff --git a/projects/coreui-angular/src/lib/breadcrumb/breadcrumb-router/breadcrumb-router.component.ts b/projects/coreui-angular/src/lib/breadcrumb/breadcrumb-router/breadcrumb-router.component.ts index 4361568a..c849ccdb 100644 --- a/projects/coreui-angular/src/lib/breadcrumb/breadcrumb-router/breadcrumb-router.component.ts +++ b/projects/coreui-angular/src/lib/breadcrumb/breadcrumb-router/breadcrumb-router.component.ts @@ -1,4 +1,4 @@ -import { Component, inject, Input, OnChanges, OnDestroy, OnInit, SimpleChanges } from '@angular/core'; +import { Component, effect, inject, input, OnDestroy, OnInit } from '@angular/core'; import { Observable, Observer } from 'rxjs'; import { AsyncPipe } from '@angular/common'; @@ -12,35 +12,31 @@ import { BreadcrumbItemComponent } from '../breadcrumb-item/breadcrumb-item.comp templateUrl: './breadcrumb-router.component.html', imports: [BreadcrumbComponent, BreadcrumbItemComponent, AsyncPipe] }) -export class BreadcrumbRouterComponent implements OnChanges, OnDestroy, OnInit { +export class BreadcrumbRouterComponent implements OnDestroy, OnInit { readonly service = inject(BreadcrumbRouterService); /** * Optional array of IBreadcrumbItem to override default BreadcrumbRouter behavior. [docs] - * @type IBreadcrumbItem[] + * @return IBreadcrumbItem[] */ - @Input() items?: IBreadcrumbItem[]; + readonly items = input(); public breadcrumbs: Observable | undefined; ngOnInit(): void { this.breadcrumbs = this.service.breadcrumbs$; } - public ngOnChanges(changes: SimpleChanges): void { - if (changes['items']) { - this.setup(); - } - } - - setup(): void { - if (this.items && this.items.length > 0) { + readonly setup = effect(() => { + const items = this.items(); + if (items && items.length > 0) { this.breadcrumbs = new Observable((observer: Observer) => { - if (this.items) { - observer.next(this.items); + const itemsValue = this.items(); + if (itemsValue) { + observer.next(itemsValue); } }); } - } + }); ngOnDestroy(): void { this.breadcrumbs = undefined; diff --git a/projects/coreui-angular/src/lib/breadcrumb/breadcrumb/breadcrumb.component.spec.ts b/projects/coreui-angular/src/lib/breadcrumb/breadcrumb/breadcrumb.component.spec.ts index 1a408b98..9dd86e69 100644 --- a/projects/coreui-angular/src/lib/breadcrumb/breadcrumb/breadcrumb.component.spec.ts +++ b/projects/coreui-angular/src/lib/breadcrumb/breadcrumb/breadcrumb.component.spec.ts @@ -9,8 +9,7 @@ describe('BreadcrumbComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [BreadcrumbComponent] - }) - .compileComponents(); + }).compileComponents(); }); beforeEach(() => { @@ -26,4 +25,12 @@ describe('BreadcrumbComponent', () => { it('should have css classes', () => { expect(fixture.nativeElement).toHaveClass('breadcrumb'); }); + + it('should have aria-label attribute', () => { + expect(fixture.nativeElement.getAttribute('aria-label')).toBe('breadcrumb'); + }); + + it('should have role attribute', () => { + expect(fixture.nativeElement.getAttribute('role')).toBe('navigation'); + }); }); diff --git a/projects/coreui-angular/src/lib/breadcrumb/breadcrumb/breadcrumb.component.ts b/projects/coreui-angular/src/lib/breadcrumb/breadcrumb/breadcrumb.component.ts index efd36466..7d4a8412 100644 --- a/projects/coreui-angular/src/lib/breadcrumb/breadcrumb/breadcrumb.component.ts +++ b/projects/coreui-angular/src/lib/breadcrumb/breadcrumb/breadcrumb.component.ts @@ -1,26 +1,26 @@ -import { Component, HostBinding, Input } from '@angular/core'; +import { Component, input } from '@angular/core'; @Component({ selector: 'c-breadcrumb', template: '', - host: { class: 'breadcrumb' } + host: { + class: 'breadcrumb', + '[attr.aria-label]': 'ariaLabel()', + '[attr.role]': 'role()' + } }) export class BreadcrumbComponent { /** * Default aria-label for breadcrumb. [docs] - * @type string + * @return string * @default 'breadcrumb' */ - @HostBinding('attr.aria-label') - @Input() - ariaLabel = 'breadcrumb'; + readonly ariaLabel = input('breadcrumb'); /** * Default role for breadcrumb. [docs] - * @type string + * @return string * @default 'navigation' */ - @HostBinding('attr.role') - @Input() - role = 'navigation'; + readonly role = input('navigation'); } diff --git a/projects/coreui-angular/src/lib/card/card-img.directive.spec.ts b/projects/coreui-angular/src/lib/card/card-img.directive.spec.ts index 7934f066..40b7c3db 100644 --- a/projects/coreui-angular/src/lib/card/card-img.directive.spec.ts +++ b/projects/coreui-angular/src/lib/card/card-img.directive.spec.ts @@ -1,11 +1,50 @@ +import { Component, DebugElement } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { CardImgDirective } from './card-img.directive'; -import { TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +@Component({ + imports: [CardImgDirective], + template: `
` +}) +export class TestComponent { + orientation: 'top' | 'bottom' | 'start' | 'end' | undefined = undefined; +} describe('CardImgDirective', () => { + let component: TestComponent; + let fixture: ComponentFixture; + let debugElement: DebugElement; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [TestComponent] + }).compileComponents(); + fixture = TestBed.createComponent(TestComponent); + component = fixture.componentInstance; + debugElement = fixture.debugElement.query(By.directive(CardImgDirective)); + fixture.detectChanges(); // initial binding + }); + it('should create an instance', () => { TestBed.runInInjectionContext(() => { const directive = new CardImgDirective(); expect(directive).toBeTruthy(); }); }); + + it('should have css classes', () => { + component.orientation = 'start'; + fixture.detectChanges(); + expect(debugElement.nativeElement).toHaveClass('rounded-start'); + component.orientation = 'end'; + fixture.detectChanges(); + expect(debugElement.nativeElement).toHaveClass('rounded-end'); + component.orientation = 'top'; + fixture.detectChanges(); + expect(debugElement.nativeElement).toHaveClass('card-img-top'); + component.orientation = 'bottom'; + fixture.detectChanges(); + expect(debugElement.nativeElement).toHaveClass('card-img-bottom'); + }); }); diff --git a/projects/coreui-angular/src/lib/card/card.component.spec.ts b/projects/coreui-angular/src/lib/card/card.component.spec.ts index 222c147f..2e13b05c 100644 --- a/projects/coreui-angular/src/lib/card/card.component.spec.ts +++ b/projects/coreui-angular/src/lib/card/card.component.spec.ts @@ -9,8 +9,7 @@ describe('CardComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [CardComponent] - }) - .compileComponents(); + }).compileComponents(); })); beforeEach(() => { diff --git a/projects/coreui-angular/src/lib/carousel/carousel-caption/carousel-caption.component.ts b/projects/coreui-angular/src/lib/carousel/carousel-caption/carousel-caption.component.ts index 5c2b5573..fead5551 100644 --- a/projects/coreui-angular/src/lib/carousel/carousel-caption/carousel-caption.component.ts +++ b/projects/coreui-angular/src/lib/carousel/carousel-caption/carousel-caption.component.ts @@ -1,10 +1,11 @@ -import { Component, HostBinding } from '@angular/core'; +import { Component } from '@angular/core'; @Component({ selector: 'c-carousel-caption', template: '', - styleUrls: ['./carousel-caption.component.scss'] + styleUrls: ['./carousel-caption.component.scss'], + host: { + '[class.carousel-caption]': 'true' + } }) -export class CarouselCaptionComponent { - @HostBinding('class.carousel-caption') carouselCaptionClass = true; -} +export class CarouselCaptionComponent {} diff --git a/projects/coreui-angular/src/lib/carousel/carousel-control/carousel-control.component.html b/projects/coreui-angular/src/lib/carousel/carousel-control/carousel-control.component.html index a952982b..76066c0d 100644 --- a/projects/coreui-angular/src/lib/carousel/carousel-control/carousel-control.component.html +++ b/projects/coreui-angular/src/lib/carousel/carousel-control/carousel-control.component.html @@ -1,8 +1,8 @@ -@if (hasContent) { +@if (hasContent()) {
} @else { - - {{ caption }} + + {{ caption() }} } diff --git a/projects/coreui-angular/src/lib/carousel/carousel-control/carousel-control.component.spec.ts b/projects/coreui-angular/src/lib/carousel/carousel-control/carousel-control.component.spec.ts index a74c0855..0d5a37b0 100644 --- a/projects/coreui-angular/src/lib/carousel/carousel-control/carousel-control.component.spec.ts +++ b/projects/coreui-angular/src/lib/carousel/carousel-control/carousel-control.component.spec.ts @@ -1,28 +1,33 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; import { CarouselControlComponent } from './carousel-control.component'; import { CarouselService } from '../carousel.service'; import { CarouselState } from '../carousel-state'; +import { ComponentRef, DebugElement } from '@angular/core'; +import { take } from 'rxjs/operators'; describe('CarouselControlComponent', () => { let component: CarouselControlComponent; + let componentRef: ComponentRef; let fixture: ComponentFixture; let service: CarouselService; let state: CarouselState; + let debugElement: DebugElement; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [CarouselControlComponent], providers: [CarouselService, CarouselState] - }) - .compileComponents(); + }).compileComponents(); })); beforeEach(() => { fixture = TestBed.createComponent(CarouselControlComponent); component = fixture.componentInstance; + componentRef = fixture.componentRef; service = TestBed.inject(CarouselService); state = TestBed.inject(CarouselState); + debugElement = fixture.debugElement; fixture.detectChanges(); }); @@ -33,4 +38,49 @@ describe('CarouselControlComponent', () => { it('should have css class="carousel-control-next"', () => { expect(fixture.nativeElement).toHaveClass('carousel-control-next'); }); + + it('should have role="button"', () => { + expect(fixture.nativeElement.getAttribute('role')).toBe('button'); + }); + + it('should have caption="Next"', () => { + expect(component.caption()).toBe('Next'); + }); + + it('should have caption to be undefined', () => { + componentRef.setInput('caption', 'Test'); + expect(component.caption()).toBe('Test'); + }); + + it('should have direction="next"', () => { + expect(component.direction()).toBe('next'); + }); + + it('should have carouselControlIconClass="carousel-control-next-icon"', () => { + expect(component.carouselControlIconClass()).toBe('carousel-control-next-icon'); + }); + + it('should play on click', fakeAsync(() => { + componentRef.setInput('direction', 'prev'); + component.onClick(new MouseEvent('click')); + fixture.detectChanges(); + expect(component.caption()).toBe('Previous'); + })); + + it('should play on keyup', fakeAsync(() => { + service.carouselIndex$.pipe(take(2)).subscribe((index) => { + if (index.active === 0) { + expect(index).toEqual({ active: 0, interval: -1, lastItemIndex: -1 }); + } else { + expect(index).toEqual({}); + } + }); + + debugElement.nativeElement.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowLeft' })); + tick(); + debugElement.nativeElement.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowRight' })); + tick(); + debugElement.nativeElement.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' })); + tick(); + })); }); diff --git a/projects/coreui-angular/src/lib/carousel/carousel-control/carousel-control.component.ts b/projects/coreui-angular/src/lib/carousel/carousel-control/carousel-control.component.ts index 72926709..12d9375a 100644 --- a/projects/coreui-angular/src/lib/carousel/carousel-control/carousel-control.component.ts +++ b/projects/coreui-angular/src/lib/carousel/carousel-control/carousel-control.component.ts @@ -1,87 +1,77 @@ -import { - AfterViewInit, - ChangeDetectorRef, - Component, - ElementRef, - HostBinding, - HostListener, - inject, - Input, - ViewChild -} from '@angular/core'; +import { Component, computed, ElementRef, inject, input, linkedSignal, viewChild } from '@angular/core'; import { CarouselState } from '../carousel-state'; @Component({ selector: 'c-carousel-control', - templateUrl: './carousel-control.component.html' + templateUrl: './carousel-control.component.html', + exportAs: 'cCarouselControl', + host: { + '[attr.role]': 'role()', + '[class]': 'hostClasses()', + '(keyup)': 'onKeyUp($event)', + '(click)': 'onClick($event)' + } }) -export class CarouselControlComponent implements AfterViewInit { - readonly #changeDetectorRef = inject(ChangeDetectorRef); +export class CarouselControlComponent { readonly #carouselState = inject(CarouselState); /** * Carousel control caption. [docs] - * @type string + * @return string */ - @Input() - set caption(value) { - this.#caption = value; - } + readonly captionInput = input(undefined, { alias: 'caption' }); - get caption(): string { - return !!this.#caption ? this.#caption : this.direction === 'prev' ? 'Previous' : 'Next'; - } - #caption?: string; + readonly caption = linkedSignal({ + source: () => this.captionInput(), + computation: (value) => { + return !!value ? value : this.direction() === 'prev' ? 'Previous' : 'Next'; + } + }); /** - * Carousel control direction. [docs] - * @type {'next' | 'prev'} + * Carousel control direction. + * @return {'next' | 'prev'} */ - @Input() direction: 'prev' | 'next' = 'next'; + readonly direction = input<'prev' | 'next'>('next'); - @HostBinding('attr.role') - get hostRole(): string { - return 'button'; - } + /** + * Carousel control role. + * @return string + */ + readonly role = input('button'); - @HostBinding('class') - get hostClasses(): string { - return `carousel-control-${this.direction}`; - } + readonly hostClasses = computed(() => { + return `carousel-control-${this.direction()}`; + }); - get carouselControlIconClass(): string { - return `carousel-control-${this.direction}-icon`; - } + readonly carouselControlIconClass = computed(() => { + return `carousel-control-${this.direction()}-icon`; + }); - @ViewChild('content') content?: ElementRef; + readonly content = viewChild('content', { read: ElementRef }); - hasContent = true; + readonly hasContent = computed(() => { + return this.content()?.nativeElement.childNodes.length ?? false; + }); - @HostListener('keyup', ['$event']) onKeyUp($event: KeyboardEvent): void { if ($event.key === 'Enter') { - this.play(); + this.#play(); } if ($event.key === 'ArrowLeft') { - this.play('prev'); + this.#play('prev'); } if ($event.key === 'ArrowRight') { - this.play('next'); + this.#play('next'); } } - @HostListener('click', ['$event']) - public onClick($event: MouseEvent): void { - this.play(); - } - - ngAfterViewInit(): void { - this.hasContent = this.content?.nativeElement.childNodes.length ?? false; - this.#changeDetectorRef.detectChanges(); + onClick($event: MouseEvent): void { + this.#play(); } - private play(direction = this.direction): void { + #play(direction = this.direction()): void { const nextIndex = this.#carouselState.direction(direction); this.#carouselState.state = { activeItemIndex: nextIndex }; } diff --git a/projects/coreui-angular/src/lib/carousel/carousel-indicators/carousel-indicators.component.html b/projects/coreui-angular/src/lib/carousel/carousel-indicators/carousel-indicators.component.html index 8c7c8c56..3fe8b0e9 100644 --- a/projects/coreui-angular/src/lib/carousel/carousel-indicators/carousel-indicators.component.html +++ b/projects/coreui-angular/src/lib/carousel/carousel-indicators/carousel-indicators.component.html @@ -1,4 +1,7 @@ - + diff --git a/projects/coreui-angular/src/lib/carousel/carousel-indicators/carousel-indicators.component.spec.ts b/projects/coreui-angular/src/lib/carousel/carousel-indicators/carousel-indicators.component.spec.ts index de6f3cb0..2a6b876e 100644 --- a/projects/coreui-angular/src/lib/carousel/carousel-indicators/carousel-indicators.component.spec.ts +++ b/projects/coreui-angular/src/lib/carousel/carousel-indicators/carousel-indicators.component.spec.ts @@ -14,19 +14,30 @@ describe('CarouselIndicatorsComponent', () => { TestBed.configureTestingModule({ imports: [CarouselIndicatorsComponent], providers: [CarouselService, CarouselState] - }) - .compileComponents(); + }).compileComponents(); })); beforeEach(() => { fixture = TestBed.createComponent(CarouselIndicatorsComponent); service = TestBed.inject(CarouselService); state = TestBed.inject(CarouselState); + state.setItems([]); component = fixture.componentInstance; + component.items = [0, 1, 2, 3]; fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); + + it('should set active index', () => { + service.setIndex({ active: 1 }); + expect(component.active).toBe(1); + }); + + it('should call onClick', () => { + component.onClick(2); + expect(component.active).toBe(2); + }); }); diff --git a/projects/coreui-angular/src/lib/carousel/carousel-indicators/carousel-indicators.component.ts b/projects/coreui-angular/src/lib/carousel/carousel-indicators/carousel-indicators.component.ts index f99348d2..a50f4962 100644 --- a/projects/coreui-angular/src/lib/carousel/carousel-indicators/carousel-indicators.component.ts +++ b/projects/coreui-angular/src/lib/carousel/carousel-indicators/carousel-indicators.component.ts @@ -1,27 +1,45 @@ -import { Component, inject, OnDestroy, OnInit } from '@angular/core'; -import { Subscription } from 'rxjs'; +import { Component, computed, contentChildren, DestroyRef, inject, OnInit, TemplateRef } from '@angular/core'; import { CarouselState } from '../carousel-state'; import { CarouselService } from '../carousel.service'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { NgTemplateOutlet } from '@angular/common'; +import { TemplateIdDirective } from '../../shared'; @Component({ selector: 'c-carousel-indicators', - templateUrl: './carousel-indicators.component.html' + exportAs: 'cCarouselIndicators', + imports: [NgTemplateOutlet], + templateUrl: './carousel-indicators.component.html', + host: { class: 'carousel-indicators' } }) -export class CarouselIndicatorsComponent implements OnInit, OnDestroy { +export class CarouselIndicatorsComponent implements OnInit { + readonly #destroyRef = inject(DestroyRef); readonly #carouselService = inject(CarouselService); readonly #carouselState = inject(CarouselState); items: (number | undefined)[] = []; active = 0; - #carouselIndexSubscription?: Subscription; - ngOnInit(): void { - this.carouselStateSubscribe(); - } + readonly contentTemplates = contentChildren(TemplateIdDirective, { descendants: true }); - ngOnDestroy(): void { - this.carouselStateSubscribe(false); + readonly templates = computed(() => { + return this.contentTemplates().reduce( + (acc, child) => { + acc[child.id] = child.templateRef; + return acc; + }, + {} as Record> + ); + }); + + ngOnInit(): void { + this.#carouselService.carouselIndex$.pipe(takeUntilDestroyed(this.#destroyRef)).subscribe((nextIndex) => { + this.items = this.#carouselState?.state?.items?.map((item) => item.index) ?? []; + if ('active' in nextIndex) { + this.active = nextIndex.active ?? 0; + } + }); } onClick(index: number): void { @@ -30,17 +48,4 @@ export class CarouselIndicatorsComponent implements OnInit, OnDestroy { this.#carouselState.state = { direction, activeItemIndex: index }; } } - - private carouselStateSubscribe(subscribe: boolean = true): void { - if (subscribe) { - this.#carouselIndexSubscription = this.#carouselService.carouselIndex$.subscribe((nextIndex) => { - this.items = this.#carouselState?.state?.items?.map((item) => item.index) ?? []; - if ('active' in nextIndex) { - this.active = nextIndex.active ?? 0; - } - }); - } else { - this.#carouselIndexSubscription?.unsubscribe(); - } - } } diff --git a/projects/coreui-angular/src/lib/carousel/carousel-inner/carousel-inner.component.html b/projects/coreui-angular/src/lib/carousel/carousel-inner/carousel-inner.component.html index cb03d32e..d072eea9 100644 --- a/projects/coreui-angular/src/lib/carousel/carousel-inner/carousel-inner.component.html +++ b/projects/coreui-angular/src/lib/carousel/carousel-inner/carousel-inner.component.html @@ -1,7 +1,3 @@ -
+
- - - - diff --git a/projects/coreui-angular/src/lib/carousel/carousel-inner/carousel-inner.component.ts b/projects/coreui-angular/src/lib/carousel/carousel-inner/carousel-inner.component.ts index fdd1be1c..a06fb68b 100644 --- a/projects/coreui-angular/src/lib/carousel/carousel-inner/carousel-inner.component.ts +++ b/projects/coreui-angular/src/lib/carousel/carousel-inner/carousel-inner.component.ts @@ -1,12 +1,4 @@ -import { - AfterContentChecked, - AfterContentInit, - Component, - ContentChildren, - HostBinding, - inject, - QueryList -} from '@angular/core'; +import { AfterContentChecked, AfterContentInit, Component, contentChildren, inject, signal } from '@angular/core'; import { fadeAnimation, slideAnimation } from '../carousel.animation'; import { CarouselItemComponent } from '../carousel-item/carousel-item.component'; @@ -16,18 +8,21 @@ import { CarouselState } from '../carousel-state'; selector: 'c-carousel-inner', templateUrl: './carousel-inner.component.html', styleUrls: ['./carousel-inner.component.scss'], - animations: [slideAnimation, fadeAnimation] + animations: [slideAnimation, fadeAnimation], + host: { + '[class.carousel-inner]': 'true' + } }) export class CarouselInnerComponent implements AfterContentInit, AfterContentChecked { readonly #carouselState = inject(CarouselState); - @HostBinding('class.carousel-inner') carouselInnerClass = true; - activeIndex?: number; - animate?: boolean; - slide = { left: true }; - transition = 'slide'; - @ContentChildren(CarouselItemComponent) private contentItems!: QueryList; - #prevContentItems!: QueryList; + readonly activeIndex = signal(undefined); + readonly animate = signal(true); + readonly slide = signal({ left: true }); + readonly transition = signal('slide'); + + readonly contentItems = contentChildren(CarouselItemComponent); + readonly #prevContentItems = signal([]); ngAfterContentInit(): void { this.setItems(); @@ -38,18 +33,19 @@ export class CarouselInnerComponent implements AfterContentInit, AfterContentChe const state = this.#carouselState?.state; const nextIndex = state?.activeItemIndex; const nextDirection = state?.direction; - if (this.activeIndex !== nextIndex) { - this.animate = state?.animate; - this.slide = { left: nextDirection === 'next' }; - this.activeIndex = state?.activeItemIndex; - this.transition = state?.transition ?? 'slide'; + if (this.activeIndex() !== nextIndex) { + this.animate.set(state?.animate ?? false); + this.slide.set({ left: nextDirection === 'next' }); + this.activeIndex.set(state?.activeItemIndex); + this.transition.set(state?.transition ?? 'slide'); } } setItems(): void { - if (this.#prevContentItems !== this.contentItems) { - this.#prevContentItems = this.contentItems; - this.#carouselState.setItems(this.contentItems); + const contentItems = this.contentItems(); + if (this.#prevContentItems() !== contentItems) { + this.#prevContentItems.set([...contentItems]); + this.#carouselState.setItems(contentItems); } } } diff --git a/projects/coreui-angular/src/lib/carousel/carousel-item/carousel-item.component.html b/projects/coreui-angular/src/lib/carousel/carousel-item/carousel-item.component.html index 50df8e42..961fa2af 100644 --- a/projects/coreui-angular/src/lib/carousel/carousel-item/carousel-item.component.html +++ b/projects/coreui-angular/src/lib/carousel/carousel-item/carousel-item.component.html @@ -1,3 +1,3 @@ -@if (active) { +@if (active()) { } diff --git a/projects/coreui-angular/src/lib/carousel/carousel-item/carousel-item.component.ts b/projects/coreui-angular/src/lib/carousel/carousel-item/carousel-item.component.ts index 52245cc6..1db5b881 100644 --- a/projects/coreui-angular/src/lib/carousel/carousel-item/carousel-item.component.ts +++ b/projects/coreui-angular/src/lib/carousel/carousel-item/carousel-item.component.ts @@ -1,79 +1,48 @@ -import { - AfterViewInit, - booleanAttribute, - ChangeDetectorRef, - Component, - HostBinding, - inject, - Input, - OnDestroy -} from '@angular/core'; -import { Subscription } from 'rxjs'; +import { booleanAttribute, Component, DestroyRef, inject, input, linkedSignal } from '@angular/core'; import { CarouselService } from '../carousel.service'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ selector: 'c-carousel-item', templateUrl: './carousel-item.component.html', styleUrls: ['./carousel-item.component.scss'], - host: { class: 'carousel-item' } + exportAs: 'cCarouselItem', + host: { + class: 'carousel-item', + '[class.active]': 'active()' + } }) -export class CarouselItemComponent implements OnDestroy, AfterViewInit { +export class CarouselItemComponent { + readonly #destroyRef = inject(DestroyRef); readonly #carouselService = inject(CarouselService); - readonly #changeDetectorRef = inject(ChangeDetectorRef); index?: number; - #carouselIndexSubscription?: Subscription; /** * @ignore */ - @Input({ transform: booleanAttribute }) - set active(value) { - this.#active = value; - this.#changeDetectorRef.markForCheck(); - } - - get active(): boolean { - return this.#active; - } + readonly activeInput = input(false, { transform: booleanAttribute, alias: 'active' }); - #active = false; + readonly active = linkedSignal({ + source: () => this.activeInput(), + computation: (value) => { + return value; + } + }); /** * Time delay before cycling to next item. If -1, uses carousel interval value. - * @type number + * @return number * @default -1 */ - @Input() interval: number = -1; - - @HostBinding('class') - get hostClasses(): any { - return { - 'carousel-item': true, - active: this.active - }; - } - - ngOnDestroy(): void { - this.carouselStateSubscribe(false); - } + readonly interval = input(-1); - ngAfterViewInit(): void { - setTimeout(() => { - this.carouselStateSubscribe(); + constructor() { + this.#carouselService.carouselIndex$.pipe(takeUntilDestroyed(this.#destroyRef)).subscribe((nextIndex) => { + if ('active' in nextIndex) { + this.active.set(nextIndex.active === this.index); + } }); } - - private carouselStateSubscribe(subscribe: boolean = true): void { - if (subscribe) { - this.#carouselIndexSubscription = this.#carouselService.carouselIndex$.subscribe((nextIndex) => { - if ('active' in nextIndex) { - this.active = nextIndex.active === this.index; - } - }); - } else { - this.#carouselIndexSubscription?.unsubscribe(); - } - } } diff --git a/projects/coreui-angular/src/lib/carousel/carousel-state.ts b/projects/coreui-angular/src/lib/carousel/carousel-state.ts index 75e7ff20..ab311e38 100644 --- a/projects/coreui-angular/src/lib/carousel/carousel-state.ts +++ b/projects/coreui-angular/src/lib/carousel/carousel-state.ts @@ -7,7 +7,7 @@ import { CarouselItemComponent } from './carousel-item/carousel-item.component'; export class CarouselState { readonly #carouselService = inject(CarouselService); - private _state: ICarouselState = { + #state = { activeItemIndex: -1, animate: true, items: [], @@ -16,16 +16,16 @@ export class CarouselState { }; get state(): ICarouselState { - return this._state; + return this.#state; } set state(state) { - const prevState = { ...this._state }; - const nextState = { ...this._state, ...state }; - this._state = nextState; + const prevState = { ...this.#state }; + const nextState = { ...this.#state, ...state }; + this.#state = nextState; if (prevState.activeItemIndex !== nextState.activeItemIndex) { const activeItemIndex = this.state.activeItemIndex || 0; - const itemInterval = (this.state.items && this.state.items[activeItemIndex]?.interval) || -1; + const itemInterval = (this.state.items && this.state.items[activeItemIndex]?.interval()) || -1; this.#carouselService.setIndex({ active: nextState.activeItemIndex, interval: itemInterval, @@ -36,12 +36,12 @@ export class CarouselState { setItems(newItems: any): void { if (newItems.length) { - const itemsArray = newItems.toArray(); + const itemsArray = newItems; itemsArray.forEach((item: CarouselItemComponent, i: number) => { item.index = i; }); this.state = { - items: itemsArray + items: [...itemsArray] }; } else { this.reset(); diff --git a/projects/coreui-angular/src/lib/carousel/carousel.config.ts b/projects/coreui-angular/src/lib/carousel/carousel.config.ts index 54dee000..8680c6e3 100644 --- a/projects/coreui-angular/src/lib/carousel/carousel.config.ts +++ b/projects/coreui-angular/src/lib/carousel/carousel.config.ts @@ -1,13 +1,11 @@ import { Injectable } from '@angular/core'; -@Injectable() +@Injectable({ providedIn: 'root' }) export class CarouselConfig { /* Animate transition of slides */ activeIndex = 0; /* Animate transition of slides */ animate = true; - /* Darken controls, indicators, and captions */ - dark? = false; /* Default direction of auto changing of slides */ direction: 'next' | 'prev' = 'next'; /* Default interval of auto changing of slides */ diff --git a/projects/coreui-angular/src/lib/carousel/carousel.service.ts b/projects/coreui-angular/src/lib/carousel/carousel.service.ts index 7e1adaa2..e2aa043d 100644 --- a/projects/coreui-angular/src/lib/carousel/carousel.service.ts +++ b/projects/coreui-angular/src/lib/carousel/carousel.service.ts @@ -9,10 +9,10 @@ export interface ICarouselIndex { @Injectable() export class CarouselService { - private carouselIndex = new BehaviorSubject({}); - carouselIndex$ = this.carouselIndex.asObservable(); + readonly #carouselIndex = new BehaviorSubject({}); + readonly carouselIndex$ = this.#carouselIndex.asObservable(); setIndex(index: ICarouselIndex): void { - this.carouselIndex.next(index); + this.#carouselIndex.next(index); } } diff --git a/projects/coreui-angular/src/lib/carousel/carousel/carousel.component.spec.ts b/projects/coreui-angular/src/lib/carousel/carousel/carousel.component.spec.ts index 18c1fc68..1b10f19b 100644 --- a/projects/coreui-angular/src/lib/carousel/carousel/carousel.component.spec.ts +++ b/projects/coreui-angular/src/lib/carousel/carousel/carousel.component.spec.ts @@ -1,20 +1,26 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ComponentFixture, fakeAsync, TestBed, waitForAsync } from '@angular/core/testing'; import { CarouselComponent } from './carousel.component'; +import { CarouselService } from '../carousel.service'; describe('CarouselComponent', () => { let component: CarouselComponent; let fixture: ComponentFixture; + let service: CarouselService; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [CarouselComponent] - }) - .compileComponents(); + }).compileComponents(); })); beforeEach(() => { + TestBed.configureTestingModule({ + imports: [CarouselComponent], + providers: [CarouselService] + }).compileComponents(); fixture = TestBed.createComponent(CarouselComponent); + service = TestBed.inject(CarouselService); component = fixture.componentInstance; fixture.detectChanges(); }); @@ -26,5 +32,24 @@ describe('CarouselComponent', () => { it('should have css classes', () => { expect(fixture.nativeElement).toHaveClass('carousel'); expect(fixture.nativeElement).toHaveClass('slide'); + fixture.componentRef.setInput('transition', 'crossfade'); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveClass('carousel-fade'); + }); + + it('should have default values', () => { + expect(component.activeIndex()).toBe(0); + expect(component.animate()).toBe(true); + expect(component.direction()).toBe('next'); + expect(component.interval()).toBe(3000); }); + + it('should call timer functions', fakeAsync(() => { + const spySet = spyOn(component, 'setTimer'); + const spyReset = spyOn(component, 'resetTimer'); + fixture.nativeElement.dispatchEvent(new Event('mouseenter')); + fixture.nativeElement.dispatchEvent(new Event('mouseleave')); + expect(spySet).toHaveBeenCalled(); + expect(spyReset).toHaveBeenCalled(); + })); }); diff --git a/projects/coreui-angular/src/lib/carousel/carousel/carousel.component.ts b/projects/coreui-angular/src/lib/carousel/carousel/carousel.component.ts index 23842263..abab62c9 100644 --- a/projects/coreui-angular/src/lib/carousel/carousel/carousel.component.ts +++ b/projects/coreui-angular/src/lib/carousel/carousel/carousel.component.ts @@ -3,14 +3,13 @@ import { Component, DestroyRef, ElementRef, - EventEmitter, - HostBinding, - inject, Inject, - Input, + inject, + input, + linkedSignal, OnDestroy, OnInit, - Output + output } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { fromEvent, Subscription } from 'rxjs'; @@ -28,80 +27,109 @@ import { CarouselConfig } from '../carousel.config'; selector: 'c-carousel', template: '', styleUrls: ['./carousel.component.scss'], - providers: [CarouselService, CarouselState, CarouselConfig, ListenersService], + providers: [CarouselService, CarouselState, ListenersService], hostDirectives: [{ directive: ThemeDirective, inputs: ['dark'] }], - host: { class: 'carousel slide' } + exportAs: 'cCarousel', + host: { + class: 'carousel slide', + '[class.carousel-fade]': 'transition() === "crossfade"' + } }) export class CarouselComponent implements OnInit, OnDestroy, AfterContentInit { - constructor( - @Inject(CarouselConfig) private config: CarouselConfig, - private hostElement: ElementRef, - private carouselService: CarouselService, - private carouselState: CarouselState, - private intersectionService: IntersectionService, - private listenersService: ListenersService - ) { - Object.assign(this, config); + readonly #hostElement = inject(ElementRef); + readonly #carouselService = inject(CarouselService); + readonly #carouselState = inject(CarouselState); + readonly #intersectionService = inject(IntersectionService); + readonly #listenersService = inject(ListenersService); + + constructor(@Inject(CarouselConfig) private config: CarouselConfig) { + this.loadConfig(); + } + + loadConfig() { + this.activeIndex.set(this.config?.activeIndex ?? this.activeIndex()); + this.animate.set(this.config?.animate ?? this.animate()); + this.direction.set(this.config?.direction ?? this.direction()); + this.interval.set(this.config?.interval ?? this.interval()); } /** * Index of the active item. - * @type number + * @return number */ - @Input() activeIndex: number = 0; + readonly activeIndexInput = input(0, { alias: 'activeIndex' }); + + readonly activeIndex = linkedSignal({ + source: () => this.activeIndexInput(), + computation: (value: number) => value + }); + /** * Carousel automatically starts cycle items. - * @type boolean + * @return boolean */ - @Input() animate: boolean = true; + readonly animateInput = input(true, { alias: 'animate' }); + + readonly animate = linkedSignal({ + source: () => this.animateInput(), + computation: (value: boolean) => value + }); + /** * Carousel direction. [docs] - * @type {'next' | 'prev'} + * @return {'next' | 'prev'} */ - @Input() direction: 'next' | 'prev' = 'next'; + readonly directionInput = input<'next' | 'prev'>('next', { alias: 'direction' }); + + readonly direction = linkedSignal({ + source: () => this.directionInput(), + computation: (value: 'next' | 'prev') => value + }); + /** * The amount of time to delay between automatically cycling an item. If false, carousel will not automatically cycle. - * @type number + * @return number * @default 0 */ - @Input() interval: number = 0; + readonly intervalInput = input(0, { alias: 'interval' }); + + readonly interval = linkedSignal({ + source: () => this.intervalInput(), + computation: (value: number) => value + }); + /** * Sets which event handlers you’d like provided to your pause prop. You can specify one trigger or an array of them. - * @type {'hover' | 'focus' | 'click'} + * @return {'hover' | 'focus' | 'click'} */ - @Input() pause: Triggers | Triggers[] | false = 'hover'; + readonly pause = input('hover'); + /** * Support left/right swipe interactions on touchscreen devices. - * @type boolean + * @return boolean * @default true */ - @Input() touch: boolean = true; + readonly touch = input(true); + /** * Set type of the transition. - * @type {'slide' | 'crossfade'} + * @return {'slide' | 'crossfade'} * @default 'slide' */ - @Input() transition: 'slide' | 'crossfade' = 'slide'; + readonly transition = input<'slide' | 'crossfade'>('slide'); + /** * Set whether the carousel should cycle continuously or have hard stops. - * @type boolean + * @return boolean * @default true */ - @Input() wrap: boolean = true; + readonly wrap = input(true); + /** * Event emitted on carousel item change. [docs] - * @type number + * @return number */ - @Output() itemChange: EventEmitter = new EventEmitter(); - - @HostBinding('class') - get hostClasses(): any { - return { - carousel: true, - slide: true, - 'carousel-fade': this.transition === 'crossfade' - }; - } + readonly itemChange = output(); private timerId: ReturnType | undefined; private activeItemInterval = 0; @@ -120,15 +148,15 @@ export class CarouselComponent implements OnInit, OnDestroy, AfterContentInit { ngAfterContentInit(): void { this.intersectionServiceSubscribe(); - this.carouselState.state = { activeItemIndex: this.activeIndex, animate: this.animate }; + this.#carouselState.state = { activeItemIndex: this.activeIndex(), animate: this.animate() }; this.setListeners(); this.swipeSubscribe(); } private setListeners(): void { const config: IListenersConfig = { - hostElement: this.hostElement, - trigger: this.pause || [], + hostElement: this.#hostElement, + trigger: this.pause() || [], callbackOff: () => { this.setTimer(); }, @@ -136,30 +164,31 @@ export class CarouselComponent implements OnInit, OnDestroy, AfterContentInit { this.resetTimer(); } }; - this.listenersService.setListeners(config); + this.#listenersService.setListeners(config); } private clearListeners(): void { - this.listenersService.clearListeners(); + this.#listenersService.clearListeners(); } set visible(value) { - this._visible = value; + this.#visible = value; } get visible() { - return this._visible; + return this.#visible; } - private _visible: boolean = true; + #visible: boolean = true; setTimer(): void { const interval = this.activeItemInterval || 0; + const direction = this.direction(); this.resetTimer(); if (interval > 0) { this.timerId = setTimeout(() => { - const nextIndex = this.carouselState.direction(this.direction); - this.carouselState.state = { activeItemIndex: nextIndex }; + const nextIndex = this.#carouselState.direction(direction); + this.#carouselState.state = { activeItemIndex: nextIndex }; }, interval); } } @@ -170,26 +199,27 @@ export class CarouselComponent implements OnInit, OnDestroy, AfterContentInit { } private carouselStateSubscribe(): void { - this.carouselService.carouselIndex$.pipe(takeUntilDestroyed(this.#destroyRef)).subscribe((nextItem) => { - if ('active' in nextItem) { + this.#carouselService.carouselIndex$.pipe(takeUntilDestroyed(this.#destroyRef)).subscribe((nextItem) => { + if ('active' in nextItem && typeof nextItem.active === 'number') { this.itemChange.emit(nextItem.active); } this.activeItemInterval = - typeof nextItem.interval === 'number' && nextItem.interval > -1 ? nextItem.interval : this.interval; + typeof nextItem.interval === 'number' && nextItem.interval > -1 ? nextItem.interval : this.interval(); + const direction = this.direction(); const isLastItem = - (nextItem.active === nextItem.lastItemIndex && this.direction === 'next') || - (nextItem.active === 0 && this.direction === 'prev'); - !this.wrap && isLastItem ? this.resetTimer() : this.setTimer(); + (nextItem.active === nextItem.lastItemIndex && direction === 'next') || + (nextItem.active === 0 && direction === 'prev'); + !this.wrap() && isLastItem ? this.resetTimer() : this.setTimer(); }); } private intersectionServiceSubscribe(): void { - this.intersectionService.createIntersectionObserver(this.hostElement); - this.intersectionService.intersecting$ + this.#intersectionService.createIntersectionObserver(this.#hostElement); + this.#intersectionService.intersecting$ .pipe( - filter((next) => next.hostElement === this.hostElement), + filter((next) => next.hostElement === this.#hostElement), finalize(() => { - this.intersectionService.unobserve(this.hostElement); + this.#intersectionService.unobserve(this.#hostElement); }), takeUntilDestroyed(this.#destroyRef) ) @@ -200,8 +230,8 @@ export class CarouselComponent implements OnInit, OnDestroy, AfterContentInit { } private swipeSubscribe(subscribe: boolean = true): void { - if (this.touch && subscribe) { - const carouselElement = this.hostElement.nativeElement; + if (this.touch() && subscribe) { + const carouselElement = this.#hostElement.nativeElement; const touchStart$ = fromEvent(carouselElement, 'touchstart'); const touchEnd$ = fromEvent(carouselElement, 'touchend'); const touchMove$ = fromEvent(carouselElement, 'touchmove'); @@ -210,10 +240,10 @@ export class CarouselComponent implements OnInit, OnDestroy, AfterContentInit { .subscribe(([touchstart, [touchend, touchmove]]) => { touchstart.stopPropagation(); touchmove.stopPropagation(); - const distanceX = touchstart.touches[0].clientX - touchmove.touches[0].clientX; + const distanceX = touchstart.touches[0]?.clientX - touchmove.touches[0]?.clientX || 0; if (Math.abs(distanceX) > 0.3 * carouselElement.clientWidth && touchstart.timeStamp <= touchmove.timeStamp) { - const nextIndex = this.carouselState.direction(distanceX > 0 ? 'next' : 'prev'); - this.carouselState.state = { activeItemIndex: nextIndex }; + const nextIndex = this.#carouselState.direction(distanceX > 0 ? 'next' : 'prev'); + this.#carouselState.state = { activeItemIndex: nextIndex }; } }); } else { diff --git a/projects/coreui-angular/src/lib/collapse/collapse.directive.spec.ts b/projects/coreui-angular/src/lib/collapse/collapse.directive.spec.ts index edc6fc67..5212c6fa 100644 --- a/projects/coreui-angular/src/lib/collapse/collapse.directive.spec.ts +++ b/projects/coreui-angular/src/lib/collapse/collapse.directive.spec.ts @@ -1,22 +1,24 @@ import { CollapseDirective } from './collapse.directive'; import { Component, DebugElement, ElementRef, Renderer2 } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComponentFixture, fakeAsync, TestBed } from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { By } from '@angular/platform-browser'; class MockElementRef extends ElementRef {} @Component({ - template: '
Test
', - imports: [CollapseDirective] + template: '
Test
', + imports: [CollapseDirective] }) -class TestComponent {} +class TestComponent { + horizontal = false; + visible = false; +} describe('CollapseDirective', () => { let component: TestComponent; let fixture: ComponentFixture; let elementRef: DebugElement; - let renderer: Renderer2; beforeEach(() => { TestBed.configureTestingModule({ @@ -27,7 +29,7 @@ describe('CollapseDirective', () => { fixture = TestBed.createComponent(TestComponent); component = fixture.componentInstance; elementRef = fixture.debugElement.query(By.directive(CollapseDirective)); - + component.visible = false; fixture.detectChanges(); // initial binding }); @@ -38,7 +40,17 @@ describe('CollapseDirective', () => { }); }); - it('should have css classes', () => { + it('should have css classes', fakeAsync(() => { + expect(elementRef.nativeElement.style.display).toContain('none'); + expect(elementRef.nativeElement).not.toHaveClass('collapse-horizontal'); + component.horizontal = true; + component.visible = true; + fixture.detectChanges(); expect(elementRef.nativeElement).toHaveClass('collapse-horizontal'); - }); + expect(elementRef.nativeElement.style.display).toBe(''); + component.horizontal = false; + component.visible = false; + fixture.detectChanges(); + expect(elementRef.nativeElement).not.toHaveClass('collapse-horizontal'); + })); }); diff --git a/projects/coreui-angular/src/lib/collapse/collapse.directive.ts b/projects/coreui-angular/src/lib/collapse/collapse.directive.ts index 6d711d6b..f3055988 100644 --- a/projects/coreui-angular/src/lib/collapse/collapse.directive.ts +++ b/projects/coreui-angular/src/lib/collapse/collapse.directive.ts @@ -9,6 +9,7 @@ import { ElementRef, inject, input, + linkedSignal, OnDestroy, output, Renderer2, @@ -46,10 +47,9 @@ export class CollapseDirective implements OnDestroy { */ readonly animateInput = input(true, { transform: booleanAttribute, alias: 'animate' }); - readonly animate = signal(true); - - readonly #animateInputEffect = effect(() => { - this.animate.set(this.animateInput()); + readonly animate = linkedSignal({ + source: () => this.animateInput(), + computation: (value: boolean) => value }); /** @@ -68,17 +68,14 @@ export class CollapseDirective implements OnDestroy { readonly visibleChange = output(); - readonly #visibleInputEffect = effect(() => { - this.visible.set(this.visibleInput()); - }); - - readonly visible = signal(false); + readonly visible = linkedSignal({ source: () => this.visibleInput(), computation: (value: boolean) => value }); readonly #initialized = signal(false); readonly #visibleEffect = effect(() => { + const visible = this.visible(); if (this.#initialized()) { - this.createPlayer(this.visible()); + this.createPlayer(visible); } }); diff --git a/projects/coreui-angular/src/lib/grid/col.directive.spec.ts b/projects/coreui-angular/src/lib/grid/col.directive.spec.ts index dd3c940e..75ea63c6 100644 --- a/projects/coreui-angular/src/lib/grid/col.directive.spec.ts +++ b/projects/coreui-angular/src/lib/grid/col.directive.spec.ts @@ -1,8 +1,60 @@ -import { ColDirective } from './col.directive'; +import { Component, DebugElement } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { ColDirective, ColOffsetType, ColOrderType } from './col.directive'; + +@Component({ + imports: [ColDirective], + template: ` +
+
+
+ ` +}) +export class TestComponent { + col!: number; + offset: ColOffsetType = { md: 2, xs: 1 }; + order: ColOrderType = { xl: 'first', xxl: 'last', md: 1, xs: 1 }; +} describe('ColDirective', () => { + let fixture: ComponentFixture; + let debugElement: DebugElement; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [TestComponent] + }); + fixture = TestBed.createComponent(TestComponent); + fixture.detectChanges(); + }); + it('should create an instance', () => { - const directive = new ColDirective(); - expect(directive).toBeTruthy(); + TestBed.runInInjectionContext(() => { + const directive = new ColDirective(); + expect(directive).toBeTruthy(); + }); + }); + + it('should have css class', () => { + debugElement = fixture.debugElement.query(By.css('#col0')); + expect(debugElement.nativeElement).toHaveClass('col'); + expect(debugElement.nativeElement).toHaveClass('col-lg-auto'); + expect(debugElement.nativeElement).toHaveClass('col-xl'); + debugElement = fixture.debugElement.query(By.css('#col1')); + expect(debugElement.nativeElement).toHaveClass('col-6'); + expect(debugElement.nativeElement).toHaveClass('order-1'); + expect(debugElement.nativeElement).toHaveClass('offset-1'); + expect(debugElement.nativeElement).toHaveClass('col-sm-5'); + expect(debugElement.nativeElement).toHaveClass('col-md-4'); + expect(debugElement.nativeElement).toHaveClass('col-lg-3'); + expect(debugElement.nativeElement).toHaveClass('col-xl-2'); + expect(debugElement.nativeElement).toHaveClass('col-xxl-1'); + debugElement = fixture.debugElement.query(By.css('#col2')); + expect(debugElement.nativeElement).toHaveClass('col'); + expect(debugElement.nativeElement).toHaveClass('offset-md-2'); + expect(debugElement.nativeElement).toHaveClass('order-md-1'); + expect(debugElement.nativeElement).toHaveClass('order-xl-first'); + expect(debugElement.nativeElement).toHaveClass('order-xxl-last'); }); }); diff --git a/projects/coreui-angular/src/lib/grid/col.directive.ts b/projects/coreui-angular/src/lib/grid/col.directive.ts index 387c295b..0657307b 100644 --- a/projects/coreui-angular/src/lib/grid/col.directive.ts +++ b/projects/coreui-angular/src/lib/grid/col.directive.ts @@ -1,13 +1,20 @@ -import { Directive, HostBinding, Input } from '@angular/core'; -import { BooleanInput, coerceBooleanProperty, coerceNumberProperty, NumberInput } from '@angular/cdk/coercion'; - -import { ColOrder, ICol } from './col.type'; +import { booleanAttribute, computed, Directive, input, numberAttribute } from '@angular/core'; +import { BooleanInput, NumberInput } from '@angular/cdk/coercion'; import { BreakpointInfix } from '../coreui.types'; +import { ColOrder } from './col.type'; + +export type ColOffsetType = number | { xs?: number; sm?: number; md?: number; lg?: number; xl?: number; xxl?: number }; +export type ColOrderType = + | ColOrder + | { xs?: ColOrder; sm?: ColOrder; md?: ColOrder; lg?: ColOrder; xl?: ColOrder; xxl?: ColOrder }; @Directive({ - selector: '[cCol]' + selector: '[cCol]', + host: { + '[class]': 'hostClasses()' + } }) -export class ColDirective implements ICol { +export class ColDirective { static ngAcceptInputType_cCol: BooleanInput | NumberInput; static ngAcceptInputType_xs: BooleanInput | NumberInput; static ngAcceptInputType_sm: BooleanInput | NumberInput; @@ -18,142 +25,111 @@ export class ColDirective implements ICol { /** * The number of columns/offset/order on extra small devices (<576px). - * @type { 'auto' | number | boolean } + * @return { 'auto' | number | boolean } */ - @Input() - set cCol(value: BooleanInput | NumberInput) { - this.xs = this.xs || this.coerceInput(value); - } - @Input() - set xs(value) { - this._xs = this.coerceInput(value); - } - get xs(): BooleanInput | NumberInput { - return this._xs; - } - private _xs: BooleanInput | NumberInput = false; + readonly cCol = input(false, { transform: this.coerceInput }); + readonly xs = input(false, { transform: this.coerceInput }); /** * The number of columns/offset/order on small devices (<768px). - * @type { 'auto' | number | boolean } + * @return { 'auto' | number | boolean } */ - @Input() - set sm(value) { - this._sm = this.coerceInput(value); - } - get sm(): BooleanInput | NumberInput { - return this._sm; - } - private _sm: BooleanInput | NumberInput = false; + readonly sm = input(false, { transform: this.coerceInput }); /** * The number of columns/offset/order on medium devices (<992px). - * @type { 'auto' | number | boolean } + * @return { 'auto' | number | boolean } */ - @Input() - set md(value) { - this._md = this.coerceInput(value); - } - get md(): BooleanInput | NumberInput { - return this._md; - } - private _md: BooleanInput | NumberInput = false; + readonly md = input(false, { transform: this.coerceInput }); /** * The number of columns/offset/order on large devices (<1200px). - * @type { 'auto' | number | boolean } + * @return { 'auto' | number | boolean } */ - @Input() - set lg(value) { - this._lg = this.coerceInput(value); - } - get lg(): BooleanInput | NumberInput { - return this._lg; - } - private _lg: BooleanInput | NumberInput = false; + readonly lg = input(false, { transform: this.coerceInput }); /** * The number of columns/offset/order on X-Large devices (<1400px). - * @type { 'auto' | number | boolean } + * @return { 'auto' | number | boolean } */ - @Input() - set xl(value) { - this._xl = this.coerceInput(value); - } - get xl(): BooleanInput | NumberInput { - return this._xl; - } - private _xl: BooleanInput | NumberInput = false; + readonly xl = input(false, { transform: this.coerceInput }); /** * The number of columns/offset/order on XX-Large devices (≥1400px). - * @type { 'auto' | number | boolean } + * @return { 'auto' | number | boolean } */ - @Input() - set xxl(value) { - this._xxl = this.coerceInput(value); - } - get xxl(): BooleanInput | NumberInput { - return this._xxl; - } - private _xxl: BooleanInput | NumberInput = false; + readonly xxl = input(false, { transform: this.coerceInput }); + + readonly breakpoints = computed(() => { + return { + xs: this.xs() || this.cCol(), + sm: this.sm(), + md: this.md(), + lg: this.lg(), + xl: this.xl(), + xxl: this.xxl() + } as Record; + }); - @Input() offset?: number | { xs?: number; sm?: number; md?: number; lg?: number; xl?: number; xxl?: number }; - @Input() order?: - | ColOrder - | { xs?: ColOrder; sm?: ColOrder; md?: ColOrder; lg?: ColOrder; xl?: ColOrder; xxl?: ColOrder }; + readonly offset = input(); + readonly order = input(); - @HostBinding('class') - get hostClasses(): any { - const classes: any = { + readonly hostClasses = computed(() => { + const classes: Record = { col: true }; + const breakpoints = this.breakpoints(); + const offsetInput = this.offset(); + const orderInput = this.order(); + Object.keys(BreakpointInfix).forEach((breakpoint) => { - // @ts-ignore - const value: number | string | boolean = this[breakpoint]; + const value = breakpoints[breakpoint]; const infix = breakpoint === 'xs' ? '' : `-${breakpoint}`; classes[`col${infix}`] = value === true; classes[`col${infix}-${value}`] = typeof value === 'number' || typeof value === 'string'; }); - if (typeof this.offset === 'object') { - const offset = { ...this.offset }; + if (typeof offsetInput === 'object') { + const offset = { ...offsetInput }; Object.entries(offset).forEach((entry) => { const [breakpoint, value] = [...entry]; const infix = breakpoint === 'xs' ? '' : `-${breakpoint}`; classes[`offset${infix}-${value}`] = value >= 0 && value <= 11; }); } else { - classes[`offset-${this.offset}`] = typeof this.offset === 'number' && this.offset > 0 && this.offset <= 11; + const offset = numberAttribute(offsetInput); + classes[`offset-${offset}`] = typeof offset === 'number' && offset > 0 && offset <= 11; } - if (typeof this.order === 'object') { - const order = { ...this.order }; + if (typeof orderInput === 'object') { + const order = { ...orderInput }; Object.entries(order).forEach((entry) => { const [breakpoint, value] = [...entry]; const infix = breakpoint === 'xs' ? '' : `-${breakpoint}`; - classes[`order${infix}-${value}`] = value; + classes[`order${infix}-${value}`] = !!value; }); } else { - classes[`order-${this.order}`] = !!this.order; + const order = orderInput; + classes[`order-${order}`] = !!order; } // if there is no 'col' class, add one - classes.col = !Object.entries(classes).filter((i) => i[0].startsWith('col-') && i[1]).length || this.xs === true; - return classes; - } + classes['col'] = + !Object.entries(classes).filter((i) => i[0].startsWith('col-') && i[1]).length || breakpoints['xs'] === true; + return classes as Record; + }); coerceInput(value: BooleanInput | NumberInput) { if (value === 'auto') { return value; } if (value === '' || value === undefined || value === null) { - return coerceBooleanProperty(value); + return booleanAttribute(value); } if (typeof value === 'boolean') { return value; } - return coerceNumberProperty(value); + return numberAttribute(value); } } diff --git a/projects/coreui-angular/src/lib/grid/container.component.spec.ts b/projects/coreui-angular/src/lib/grid/container.component.spec.ts index f2a75b1a..8650c2b9 100644 --- a/projects/coreui-angular/src/lib/grid/container.component.spec.ts +++ b/projects/coreui-angular/src/lib/grid/container.component.spec.ts @@ -1,21 +1,23 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { ContainerComponent } from './container.component'; +import { ComponentRef } from '@angular/core'; describe('ContainerComponent', () => { let component: ContainerComponent; + let componentRef: ComponentRef; let fixture: ComponentFixture; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [ContainerComponent] - }) - .compileComponents(); + }).compileComponents(); })); beforeEach(() => { fixture = TestBed.createComponent(ContainerComponent); component = fixture.componentInstance; + componentRef = fixture.componentRef; fixture.detectChanges(); }); @@ -25,5 +27,11 @@ describe('ContainerComponent', () => { it('should have css classes', () => { expect(fixture.nativeElement).toHaveClass('container'); + expect(fixture.nativeElement).not.toHaveClass('container-fluid'); + expect(fixture.nativeElement).not.toHaveClass('container-xl'); + componentRef.setInput('fluid', true); + componentRef.setInput('breakpoint', 'xl'); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveClass('container-xl'); }); }); diff --git a/projects/coreui-angular/src/lib/grid/container.component.ts b/projects/coreui-angular/src/lib/grid/container.component.ts index ee0112e6..8ae9c202 100644 --- a/projects/coreui-angular/src/lib/grid/container.component.ts +++ b/projects/coreui-angular/src/lib/grid/container.component.ts @@ -1,6 +1,4 @@ -import { booleanAttribute, Component, computed, input, InputSignal, InputSignalWithTransform } from '@angular/core'; - -import { IContainer } from './container.type'; +import { booleanAttribute, Component, computed, input } from '@angular/core'; import { Breakpoints } from '../coreui.types'; @Component({ @@ -9,19 +7,17 @@ import { Breakpoints } from '../coreui.types'; styleUrls: ['./container.component.scss'], host: { '[class]': 'hostClasses()' } }) -export class ContainerComponent implements IContainer { +export class ContainerComponent { /** * Set container 100% wide until a breakpoint. */ - readonly breakpoint: InputSignal> = input>(''); + readonly breakpoint = input>(''); /** * Set container 100% wide, spanning the entire width of the viewport. - * @type InputSignalWithTransform + * @return boolean */ - readonly fluid: InputSignalWithTransform = input(false, { - transform: booleanAttribute - }); + readonly fluid = input(false, { transform: booleanAttribute }); readonly hostClasses = computed(() => { const breakpoint = this.breakpoint(); diff --git a/projects/coreui-angular/src/lib/grid/gutter.directive.spec.ts b/projects/coreui-angular/src/lib/grid/gutter.directive.spec.ts index 8e8dabea..144458fe 100644 --- a/projects/coreui-angular/src/lib/grid/gutter.directive.spec.ts +++ b/projects/coreui-angular/src/lib/grid/gutter.directive.spec.ts @@ -1,8 +1,45 @@ +import { Component, DebugElement } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; import { GutterDirective } from './gutter.directive'; +import { GutterBreakpoints, Gutters, IGutterObject } from './gutter.type'; + +@Component({ + imports: [GutterDirective], + template: '
' +}) +export class TestComponent { + gutter: IGutterObject | GutterBreakpoints | Gutters = 5; +} describe('GutterDirective', () => { + let fixture: ComponentFixture; + let debugElement: DebugElement; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [TestComponent] + }); + fixture = TestBed.createComponent(TestComponent); + debugElement = fixture.debugElement.query(By.directive(GutterDirective)); + fixture.detectChanges(); + }); + it('should create an instance', () => { - const directive = new GutterDirective(); - expect(directive).toBeTruthy(); + TestBed.runInInjectionContext(() => { + const directive = new GutterDirective(); + expect(directive).toBeTruthy(); + }); + }); + + it('should have css class', () => { + expect(debugElement.nativeElement).toHaveClass('g-5'); + fixture.componentInstance.gutter = { gx: 2, gy: 1 }; + fixture.detectChanges(); + expect(debugElement.nativeElement).toHaveClass('gx-2'); + expect(debugElement.nativeElement).toHaveClass('gy-1'); + fixture.componentInstance.gutter = { md: { g: 3 } }; + fixture.detectChanges(); + expect(debugElement.nativeElement).toHaveClass('g-md-3'); }); }); diff --git a/projects/coreui-angular/src/lib/grid/gutter.directive.ts b/projects/coreui-angular/src/lib/grid/gutter.directive.ts index f06e1323..6a4a69be 100644 --- a/projects/coreui-angular/src/lib/grid/gutter.directive.ts +++ b/projects/coreui-angular/src/lib/grid/gutter.directive.ts @@ -1,43 +1,46 @@ -import { Directive, HostBinding, Input } from '@angular/core'; +import { computed, Directive, input } from '@angular/core'; import { BreakpointInfix } from '../coreui.types'; import { GutterBreakpoints, Gutters, IGutter, IGutterObject } from './gutter.type'; @Directive({ // eslint-disable-next-line @angular-eslint/directive-selector - selector: '[gutter]' + selector: '[gutter]', + exportAs: 'gutter', + host: { + '[class]': 'hostClasses()' + } }) export class GutterDirective implements IGutter { /** * Define padding between columns to space and align content responsively in the Bootstrap grid system. */ - @Input() gutter: IGutterObject | GutterBreakpoints | Gutters = {}; + readonly gutter = input({}); - @HostBinding('class') - get hostClasses(): any { - let gutterClass: any; + readonly hostClasses = computed(() => { + let gutterClass: Record; + const gutterInput = this.gutter(); - if (typeof this.gutter === 'number') { - gutterClass = GutterDirective.getGutterClasses({ g: this.gutter }); + if (typeof gutterInput === 'number') { + gutterClass = GutterDirective.getGutterClasses({ g: gutterInput }); return gutterClass; } { - // @ts-ignore - const { g, gx, gy } = { ...this.gutter }; + const { g, gx, gy } = { ...(gutterInput as IGutterObject) }; gutterClass = GutterDirective.getGutterClasses({ g, gx, gy }); } Object.keys(BreakpointInfix).forEach((key) => { // @ts-ignore - const gutter = this.gutter[key] ? { ...this.gutter[key] } : undefined; + const gutter: IGutterObject = gutterInput[key] ? { ...gutterInput[key] } : undefined; if (gutter) { const classes = GutterDirective.getGutterClasses(gutter, key); gutterClass = { ...gutterClass, ...classes }; } }); return gutterClass; - } + }); private static getGutterClasses(gutter: IGutterObject, breakpoint?: string): any { const { g, gx, gy } = { ...gutter }; diff --git a/projects/coreui-angular/src/lib/grid/gutter.type.ts b/projects/coreui-angular/src/lib/grid/gutter.type.ts index f8cf41d0..05864874 100644 --- a/projects/coreui-angular/src/lib/grid/gutter.type.ts +++ b/projects/coreui-angular/src/lib/grid/gutter.type.ts @@ -1,7 +1,8 @@ +import { type InputSignal } from '@angular/core'; import { BreakpointInfix } from '../coreui.types'; export interface IGutter { - gutter?: (IGutterObject | GutterBreakpoints | Gutters); + gutter?: InputSignal; } export type Gutters = 0 | 1 | 2 | 3 | 4 | 5 | number; diff --git a/projects/coreui-angular/src/lib/grid/row.directive.spec.ts b/projects/coreui-angular/src/lib/grid/row.directive.spec.ts index 65e2b2bc..52b62e41 100644 --- a/projects/coreui-angular/src/lib/grid/row.directive.spec.ts +++ b/projects/coreui-angular/src/lib/grid/row.directive.spec.ts @@ -1,8 +1,37 @@ +import { Component, DebugElement } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { RowDirective } from './row.directive'; +import { By } from '@angular/platform-browser'; + +@Component({ + imports: [RowDirective], + template: `
` +}) +export class TestComponent {} describe('RowDirective', () => { + let fixture: ComponentFixture; + let debugElement: DebugElement; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [TestComponent] + }); + fixture = TestBed.createComponent(TestComponent); + fixture.detectChanges(); + }); + it('should create an instance', () => { - const directive = new RowDirective(); - expect(directive).toBeTruthy(); + TestBed.runInInjectionContext(() => { + const directive = new RowDirective(); + expect(directive).toBeTruthy(); + }); + }); + + it('should have css class', () => { + debugElement = fixture.debugElement.query(By.css('#row0')); + expect(debugElement.nativeElement).toHaveClass('row'); + expect(debugElement.nativeElement).toHaveClass('row-cols-auto'); + expect(debugElement.nativeElement).toHaveClass('row-cols-md-7'); }); }); diff --git a/projects/coreui-angular/src/lib/grid/row.directive.ts b/projects/coreui-angular/src/lib/grid/row.directive.ts index e8235f0b..a15098e5 100644 --- a/projects/coreui-angular/src/lib/grid/row.directive.ts +++ b/projects/coreui-angular/src/lib/grid/row.directive.ts @@ -1,56 +1,63 @@ -import { Directive, HostBinding, Input } from '@angular/core'; +import { computed, Directive, input } from '@angular/core'; import { BreakpointInfix } from '../coreui.types'; -import { IRow, NumberOfColumns } from './row.type'; +import { NumberOfColumns } from './row.type'; @Directive({ selector: '[cRow]', - host: { class: 'row' } + host: { + class: 'row', + '[class]': 'hostClasses()' + } }) -export class RowDirective implements IRow { +export class RowDirective { /** * The number of columns/offset/order on extra small devices (<576px). - * @type {{ cols: 'auto' | number } + * @return { cols: 'auto' | number } */ - @Input() xs?: NumberOfColumns; + readonly xs = input(); + /** * The number of columns/offset/order on small devices (<768px). - * @type {{ cols: 'auto' | number } + * @return { cols: 'auto' | number } */ - @Input() sm?: NumberOfColumns; + readonly sm = input(); + /** * The number of columns/offset/order on medium devices (<992px). - * @type {{ cols: 'auto' | number } + * @return { cols: 'auto' | number } */ - @Input() md?: NumberOfColumns; + readonly md = input(); + /** * The number of columns/offset/order on large devices (<1200px). - * @type {{ cols: 'auto' | number } + * @return { cols: 'auto' | number } */ - @Input() lg?: NumberOfColumns; + readonly lg = input(); + /** * The number of columns/offset/order on X-Large devices (<1400px). - * @type {{ cols: 'auto' | number } + * @return { cols: 'auto' | number } */ - @Input() xl?: NumberOfColumns; + readonly xl = input(); + /** * The number of columns/offset/order on XX-Large devices (≥1400px). - * @type {{ cols: 'auto' | number } + * @return { cols: 'auto' | number } */ - @Input() xxl?: NumberOfColumns; + readonly xxl = input(); - @HostBinding('class') - get hostClasses(): any { - const cols = this.xs; + readonly hostClasses = computed(() => { + const cols = this.xs(); - const classes: any = { + const classes: Record = { row: true, [`row-cols-${cols}`]: !!cols }; Object.keys(BreakpointInfix).forEach((breakpoint) => { // @ts-ignore - const value: any = this[breakpoint]; + const value: any = this[breakpoint](); if (typeof value === 'number' || typeof value === 'string') { const infix: string = breakpoint === 'xs' ? '' : `-${breakpoint}`; classes[`row-cols${infix}-${value}`] = !!value; @@ -58,5 +65,5 @@ export class RowDirective implements IRow { }); return classes; - } + }); } diff --git a/projects/coreui-angular/src/lib/header/header-brand/header-brand.component.spec.ts b/projects/coreui-angular/src/lib/header/header-brand/header-brand.component.spec.ts index 04978a05..1bf139af 100644 --- a/projects/coreui-angular/src/lib/header/header-brand/header-brand.component.spec.ts +++ b/projects/coreui-angular/src/lib/header/header-brand/header-brand.component.spec.ts @@ -1,23 +1,19 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { RouterTestingModule } from '@angular/router/testing'; -import { Router } from '@angular/router'; import { HeaderBrandComponent } from './header-brand.component'; describe('HeaderBrandComponent', () => { let component: HeaderBrandComponent; let fixture: ComponentFixture; - let router: Router; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - imports: [RouterTestingModule.withRoutes([]), HeaderBrandComponent] + imports: [HeaderBrandComponent] }).compileComponents(); })); beforeEach(() => { fixture = TestBed.createComponent(HeaderBrandComponent); - router = TestBed.inject(Router); component = fixture.componentInstance; fixture.detectChanges(); }); @@ -29,4 +25,8 @@ describe('HeaderBrandComponent', () => { it('should have css classes', () => { expect(fixture.nativeElement).toHaveClass('header-brand'); }); + + it('should have role', () => { + expect(fixture.nativeElement.getAttribute('role')).toBe('button'); + }); }); diff --git a/projects/coreui-angular/src/lib/header/header-brand/header-brand.component.ts b/projects/coreui-angular/src/lib/header/header-brand/header-brand.component.ts index aca7a50d..56face65 100644 --- a/projects/coreui-angular/src/lib/header/header-brand/header-brand.component.ts +++ b/projects/coreui-angular/src/lib/header/header-brand/header-brand.component.ts @@ -1,18 +1,19 @@ -import { Component, HostBinding, Input } from '@angular/core'; +import { Component, input } from '@angular/core'; @Component({ selector: 'c-header-brand', - template: '' + template: '', + exportAs: 'cHeaderBrand', + host: { + '[attr.role]': 'role()', + class: 'header-brand' + } }) export class HeaderBrandComponent { /** * Default role for header-brand. [docs] - * @type string + * @return string * @default 'button' */ - @HostBinding('attr.role') - @Input() - role = 'button'; - - @HostBinding('class.header-brand') headerBrandClass = true; + readonly role = input('button'); } diff --git a/projects/coreui-angular/src/lib/header/header-divider/header-divider.component.ts b/projects/coreui-angular/src/lib/header/header-divider/header-divider.component.ts index 26830632..6bf57be0 100644 --- a/projects/coreui-angular/src/lib/header/header-divider/header-divider.component.ts +++ b/projects/coreui-angular/src/lib/header/header-divider/header-divider.component.ts @@ -1,9 +1,10 @@ -import { Component, HostBinding } from '@angular/core'; +import { Component } from '@angular/core'; @Component({ selector: 'c-header-divider, [cHeaderDivider]', - template: `` + template: ``, + host: { + class: 'header-divider' + } }) -export class HeaderDividerComponent { - @HostBinding('class.header-divider') headerDividerClass = true; -} +export class HeaderDividerComponent {} diff --git a/projects/coreui-angular/src/lib/header/header-nav/header-nav.component.spec.ts b/projects/coreui-angular/src/lib/header/header-nav/header-nav.component.spec.ts index b7940aaa..aa593494 100644 --- a/projects/coreui-angular/src/lib/header/header-nav/header-nav.component.spec.ts +++ b/projects/coreui-angular/src/lib/header/header-nav/header-nav.component.spec.ts @@ -9,8 +9,7 @@ describe('HeaderNavComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [HeaderNavComponent] - }) - .compileComponents(); + }).compileComponents(); }); beforeEach(() => { @@ -26,4 +25,8 @@ describe('HeaderNavComponent', () => { it('should have css classes', () => { expect(fixture.nativeElement).toHaveClass('header-nav'); }); + + it('should have role', () => { + expect(fixture.nativeElement.getAttribute('role')).toBe('navigation'); + }); }); diff --git a/projects/coreui-angular/src/lib/header/header-nav/header-nav.component.ts b/projects/coreui-angular/src/lib/header/header-nav/header-nav.component.ts index 4cf44998..6ea49036 100644 --- a/projects/coreui-angular/src/lib/header/header-nav/header-nav.component.ts +++ b/projects/coreui-angular/src/lib/header/header-nav/header-nav.component.ts @@ -1,19 +1,20 @@ -import { Component, HostBinding, Input } from '@angular/core'; +import { Component, input } from '@angular/core'; @Component({ selector: 'c-header-nav', template: '', - styleUrls: ['./header-nav.component.scss'] + styleUrls: ['./header-nav.component.scss'], + exportAs: 'cHeaderNav', + host: { + '[attr.role]': 'role()', + class: 'header-nav' + } }) export class HeaderNavComponent { /** * Default role for header-nav. [docs] - * @type string + * @return string * @default 'navigation' */ - @HostBinding('attr.role') - @Input() - role = 'navigation'; - - @HostBinding('class.header-nav') headerNavClass = true; + readonly role = input('navigation'); } diff --git a/projects/coreui-angular/src/lib/header/header-text/header-text.component.ts b/projects/coreui-angular/src/lib/header/header-text/header-text.component.ts index d7389242..a777d57d 100644 --- a/projects/coreui-angular/src/lib/header/header-text/header-text.component.ts +++ b/projects/coreui-angular/src/lib/header/header-text/header-text.component.ts @@ -1,9 +1,10 @@ -import { Component, HostBinding } from '@angular/core'; +import { Component } from '@angular/core'; @Component({ selector: 'c-header-text, [cHeaderText]', - template: '' + template: '', + host: { + class: 'header-text' + } }) -export class HeaderTextComponent { - @HostBinding('class.header-text') headerTextClass = true; -} +export class HeaderTextComponent {} diff --git a/projects/coreui-angular/src/lib/header/header-toggler/header-toggler.directive.spec.ts b/projects/coreui-angular/src/lib/header/header-toggler/header-toggler.directive.spec.ts index 0fff2b6c..9e9e6e2f 100644 --- a/projects/coreui-angular/src/lib/header/header-toggler/header-toggler.directive.spec.ts +++ b/projects/coreui-angular/src/lib/header/header-toggler/header-toggler.directive.spec.ts @@ -1,15 +1,31 @@ -import { TestBed } from '@angular/core/testing'; -import { ElementRef, Renderer2 } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Component, DebugElement, ElementRef, Renderer2 } from '@angular/core'; +import { By } from '@angular/platform-browser'; import { HeaderTogglerDirective } from './header-toggler.directive'; +@Component({ + imports: [HeaderTogglerDirective], + template: '
' +}) +export class TestComponent { + theme!: 'dark' | 'light' | undefined; +} + class MockElementRef extends ElementRef {} describe('HeaderTogglerDirective', () => { + let fixture: ComponentFixture; + let debugElement: DebugElement; + beforeEach(() => { TestBed.configureTestingModule({ + imports: [TestComponent], providers: [Renderer2, { provide: ElementRef, useClass: MockElementRef }] }); + fixture = TestBed.createComponent(TestComponent); + debugElement = fixture.debugElement.query(By.directive(HeaderTogglerDirective)); + fixture.detectChanges(); }); it('should create an instance', () => { @@ -18,4 +34,13 @@ describe('HeaderTogglerDirective', () => { expect(directive).toBeTruthy(); }); }); + + it('should have css class', () => { + expect(debugElement.nativeElement).toHaveClass('header-toggler'); + }); + + it('should set attributes', () => { + expect(debugElement.nativeElement.getAttribute('type')).toBe('button'); + expect(debugElement.nativeElement.getAttribute('aria-label')).toBe('Toggle navigation'); + }); }); diff --git a/projects/coreui-angular/src/lib/header/header-toggler/header-toggler.directive.ts b/projects/coreui-angular/src/lib/header/header-toggler/header-toggler.directive.ts index b34dbb02..4d3df892 100644 --- a/projects/coreui-angular/src/lib/header/header-toggler/header-toggler.directive.ts +++ b/projects/coreui-angular/src/lib/header/header-toggler/header-toggler.directive.ts @@ -1,31 +1,31 @@ -import { AfterContentInit, Directive, ElementRef, HostBinding, inject, Input, Renderer2 } from '@angular/core'; +import { AfterContentInit, Directive, ElementRef, inject, input, Renderer2 } from '@angular/core'; @Directive({ - selector: '[cHeaderToggler]' + selector: '[cHeaderToggler]', + exportAs: 'cHeaderToggler', + host: { + '[attr.type]': 'type()', + '[attr.aria-label]': 'ariaLabel()', + class: 'header-toggler' + } }) export class HeaderTogglerDirective implements AfterContentInit { readonly #renderer = inject(Renderer2); readonly #hostElement = inject(ElementRef); - @HostBinding('class.header-toggler') headerToggler = true; /** - * Default role for header-toggler. [docs] - * @type string + * Default type for header-toggler button. [docs] + * @return string * @default 'button' */ - @HostBinding('attr.type') - @Input() - type = 'button'; + readonly type = input('button'); + /** * Default aria-label attr for header-toggler. [docs] * @type string * @default 'Toggle navigation' */ - @HostBinding('attr.aria-label') - @Input() - ariaLabel = 'Toggle navigation'; - - #hasContent!: boolean; + readonly ariaLabel = input('Toggle navigation'); addDefaultIcon(): void { const span = this.#renderer.createElement('span'); @@ -34,8 +34,8 @@ export class HeaderTogglerDirective implements AfterContentInit { } ngAfterContentInit(): void { - this.#hasContent = this.#hostElement.nativeElement.childNodes.length > 0; - if (!this.#hasContent) { + const hasContent = this.#hostElement.nativeElement.childNodes.length > 0; + if (!hasContent) { this.addDefaultIcon(); } } diff --git a/projects/coreui-angular/src/lib/header/header/header.component.ts b/projects/coreui-angular/src/lib/header/header/header.component.ts index d2f9bf38..097b56c0 100644 --- a/projects/coreui-angular/src/lib/header/header/header.component.ts +++ b/projects/coreui-angular/src/lib/header/header/header.component.ts @@ -6,10 +6,11 @@ import { Positions } from '../../coreui.types'; type Container = boolean | 'sm' | 'md' | 'lg' | 'xl' | 'xxl' | 'fluid'; @Component({ - selector: 'c-header, [c-header]', - templateUrl: './header.component.html', - imports: [NgClass], - host: { '[attr.role]': 'role()', '[class]': 'hostClasses()' } + selector: 'c-header, [c-header]', + templateUrl: './header.component.html', + imports: [NgClass], + exportAs: 'cHeader', + host: { '[attr.role]': 'role()', '[class]': 'hostClasses()' } }) export class HeaderComponent { /** diff --git a/projects/coreui-angular/src/lib/offcanvas/offcanvas-title/offcanvas-title.directive.spec.ts b/projects/coreui-angular/src/lib/offcanvas/offcanvas-title/offcanvas-title.directive.spec.ts index d0638f9d..0a8b6d66 100644 --- a/projects/coreui-angular/src/lib/offcanvas/offcanvas-title/offcanvas-title.directive.spec.ts +++ b/projects/coreui-angular/src/lib/offcanvas/offcanvas-title/offcanvas-title.directive.spec.ts @@ -1,8 +1,37 @@ import { OffcanvasTitleDirective } from './offcanvas-title.directive'; +import { Component, DebugElement } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +@Component({ + template: '
Test
', + imports: [OffcanvasTitleDirective] +}) +class TestComponent {} describe('OffcanvasTitleDirective', () => { + let component: TestComponent; + let fixture: ComponentFixture; + let elementRef: DebugElement; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [TestComponent] + }).compileComponents(); + + fixture = TestBed.createComponent(TestComponent); + component = fixture.componentInstance; + elementRef = fixture.debugElement.query(By.directive(OffcanvasTitleDirective)); + + fixture.detectChanges(); // initial binding + }); + it('should create an instance', () => { const directive = new OffcanvasTitleDirective(); expect(directive).toBeTruthy(); }); + + it('should have css classes', () => { + expect(elementRef.nativeElement).toHaveClass('offcanvas-title'); + }); }); diff --git a/projects/coreui-angular/src/lib/offcanvas/offcanvas-toggle/offcanvas-toggle.directive.spec.ts b/projects/coreui-angular/src/lib/offcanvas/offcanvas-toggle/offcanvas-toggle.directive.spec.ts index da035e94..9f49c308 100644 --- a/projects/coreui-angular/src/lib/offcanvas/offcanvas-toggle/offcanvas-toggle.directive.spec.ts +++ b/projects/coreui-angular/src/lib/offcanvas/offcanvas-toggle/offcanvas-toggle.directive.spec.ts @@ -1,7 +1,8 @@ import { Component, DebugElement } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComponentFixture, fakeAsync, TestBed } from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { By } from '@angular/platform-browser'; +import { take } from 'rxjs/operators'; import { OffcanvasToggleDirective } from './offcanvas-toggle.directive'; import { OffcanvasService } from '../offcanvas.service'; @@ -10,22 +11,22 @@ import { OffcanvasService } from '../offcanvas.service'; template: ` `, imports: [OffcanvasToggleDirective] }) -class TestButtonComponent {} +class TestComponent {} describe('OffcanvasToggleDirective', () => { - let component: TestButtonComponent; - let fixture: ComponentFixture; - let buttonEl: DebugElement; + let component: TestComponent; + let fixture: ComponentFixture; + let debugElement: DebugElement; let service: OffcanvasService; beforeEach(() => { TestBed.configureTestingModule({ - imports: [NoopAnimationsModule, OffcanvasToggleDirective, TestButtonComponent], + imports: [NoopAnimationsModule, OffcanvasToggleDirective, TestComponent], providers: [OffcanvasService] }); - fixture = TestBed.createComponent(TestButtonComponent); + fixture = TestBed.createComponent(TestComponent); component = fixture.componentInstance; - buttonEl = fixture.debugElement.query(By.css('button')); + debugElement = fixture.debugElement.query(By.css('button')); service = TestBed.inject(OffcanvasService); fixture.detectChanges(); // initial binding }); @@ -36,4 +37,11 @@ describe('OffcanvasToggleDirective', () => { expect(directive).toBeTruthy(); }); }); + + it('should toggle offcanvas on click', fakeAsync(() => { + service.offcanvasState$.pipe(take(1)).subscribe((value) => { + expect(value).toEqual({ show: 'toggle', id: 'OffcanvasEnd' }); + }); + debugElement.nativeElement.dispatchEvent(new MouseEvent('click')); + })); }); diff --git a/projects/coreui-angular/src/lib/offcanvas/offcanvas-toggle/offcanvas-toggle.directive.ts b/projects/coreui-angular/src/lib/offcanvas/offcanvas-toggle/offcanvas-toggle.directive.ts index 5978e76c..d58c7ab8 100644 --- a/projects/coreui-angular/src/lib/offcanvas/offcanvas-toggle/offcanvas-toggle.directive.ts +++ b/projects/coreui-angular/src/lib/offcanvas/offcanvas-toggle/offcanvas-toggle.directive.ts @@ -1,22 +1,24 @@ -import { Directive, HostListener, inject, Input } from '@angular/core'; +import { Directive, inject, input } from '@angular/core'; import { OffcanvasService } from '../offcanvas.service'; @Directive({ - selector: '[cOffcanvasToggle]' + selector: '[cOffcanvasToggle]', + host: { + '(click)': 'toggleOpen($event)' + } }) export class OffcanvasToggleDirective { readonly #offcanvasService = inject(OffcanvasService); /** * Html id attr of offcanvas to toggle. - * @type string + * @return string */ - @Input('cOffcanvasToggle') id?: string; + readonly id = input(undefined, { alias: 'cOffcanvasToggle' }); - @HostListener('click', ['$event']) - toggleOpen($event: any): void { + protected toggleOpen($event: MouseEvent): void { $event.preventDefault(); - this.#offcanvasService.toggle({ show: 'toggle', id: this.id }); + this.#offcanvasService.toggle({ show: 'toggle', id: this.id() }); } } diff --git a/projects/coreui-angular/src/lib/offcanvas/offcanvas/offcanvas.component.spec.ts b/projects/coreui-angular/src/lib/offcanvas/offcanvas/offcanvas.component.spec.ts index f1de0be9..1a142315 100644 --- a/projects/coreui-angular/src/lib/offcanvas/offcanvas/offcanvas.component.spec.ts +++ b/projects/coreui-angular/src/lib/offcanvas/offcanvas/offcanvas.component.spec.ts @@ -1,22 +1,25 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComponentFixture, fakeAsync, flushMicrotasks, TestBed, tick } from '@angular/core/testing'; import { OffcanvasComponent } from './offcanvas.component'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { ComponentRef } from '@angular/core'; +import { DOCUMENT } from '@angular/common'; describe('OffcanvasComponent', () => { let component: OffcanvasComponent; + let componentRef: ComponentRef; let fixture: ComponentFixture; + let document: Document; beforeEach(async () => { await TestBed.configureTestingModule({ imports: [NoopAnimationsModule, OffcanvasComponent] - }) - .compileComponents(); - }); + }).compileComponents(); - beforeEach(() => { fixture = TestBed.createComponent(OffcanvasComponent); component = fixture.componentInstance; + componentRef = fixture.componentRef; + document = TestBed.inject(DOCUMENT); fixture.detectChanges(); }); @@ -26,5 +29,48 @@ describe('OffcanvasComponent', () => { it('should have css classes', () => { expect(fixture.nativeElement).toHaveClass('offcanvas'); + expect(fixture.nativeElement).toHaveClass('offcanvas-start'); + expect(fixture.nativeElement.getAttribute('id')).toContain('offcanvas-start-'); }); + + it('should react to visible changes', fakeAsync(() => { + expect(componentRef.instance.visible()).toBeFalse(); + componentRef.setInput('visible', true); + fixture.detectChanges(); + flushMicrotasks(); + expect(componentRef.instance.visible()).toBeTrue(); + expect(fixture.nativeElement.getAttribute('inert')).toBeNull(); + })); + + it('should close offcanvas to Esc keydown event', fakeAsync(() => { + componentRef.setInput('visible', true); + fixture.detectChanges(); + expect(componentRef.instance.visible()).toBeTrue(); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + tick(); + fixture.detectChanges(); + expect(componentRef.instance.visible()).toBeFalse(); + expect(fixture.nativeElement.getAttribute('inert')).toBeTruthy(); + })); + + it('should close offcanvas on backdrop click', fakeAsync(() => { + componentRef.setInput('visible', true); + fixture.detectChanges(); + expect(componentRef.instance.visible()).toBeTrue(); + const backdrop = document.querySelector('.offcanvas-backdrop'); + expect(backdrop).not.toBeNull(); + if (backdrop) { + backdrop?.dispatchEvent(new MouseEvent('click')); + tick(); + fixture.detectChanges(); + // expect(componentRef.instance.visible()).toBeFalse(); + // expect(fixture.nativeElement.getAttribute('inert')).toBeTruthy(); + } + })); + + it('should return breakpoint value', fakeAsync(() => { + componentRef.setInput('responsive', 'false'); + fixture.detectChanges(); + expect(fixture.componentInstance.responsiveBreakpoint).toBeFalse(); + })); }); diff --git a/projects/coreui-angular/src/lib/offcanvas/offcanvas/offcanvas.component.ts b/projects/coreui-angular/src/lib/offcanvas/offcanvas/offcanvas.component.ts index dc9e34cc..abb4d634 100644 --- a/projects/coreui-angular/src/lib/offcanvas/offcanvas/offcanvas.component.ts +++ b/projects/coreui-angular/src/lib/offcanvas/offcanvas/offcanvas.component.ts @@ -3,18 +3,20 @@ import { DOCUMENT, isPlatformBrowser } from '@angular/common'; import { booleanAttribute, Component, + computed, DestroyRef, + effect, ElementRef, EventEmitter, - HostBinding, - HostListener, inject, - Input, + input, OnDestroy, OnInit, - Output, + output, PLATFORM_ID, - Renderer2 + Renderer2, + signal, + untracked } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { A11yModule } from '@angular/cdk/a11y'; @@ -52,7 +54,19 @@ let nextId = 0; exportAs: 'cOffcanvas', imports: [A11yModule], hostDirectives: [{ directive: ThemeDirective, inputs: ['dark'] }], - host: { ngSkipHydration: 'true', '[attr.inert]': 'ariaHidden || null' } + host: { + ngSkipHydration: 'true', + '[@showHide]': 'animateTrigger', + '[attr.id]': 'id()', + '[attr.inert]': 'ariaHidden() || null', + '[attr.role]': 'role()', + '[attr.aria-modal]': 'ariaModal()', + '[attr.tabindex]': 'tabIndex', + '[class]': 'hostClasses()', + '(@showHide.start)': 'animateStart($event)', + '(@showHide.done)': 'animateDone($event)', + '(document:keydown)': 'onKeyDownHandler($event)' + } }) export class OffcanvasComponent implements OnInit, OnDestroy { readonly #document = inject(DOCUMENT); @@ -66,45 +80,47 @@ export class OffcanvasComponent implements OnInit, OnDestroy { /** * Apply a backdrop on body while offcanvas is open. - * @type boolean | 'static' + * @return boolean | 'static' * @default true */ - @Input() backdrop: boolean | 'static' = true; + readonly backdrop = input(true); /** * Closes the offcanvas when escape key is pressed [docs] - * @type boolean + * @return boolean * @default true */ - @Input({ transform: booleanAttribute }) keyboard = true; + readonly keyboard = input(true, { transform: booleanAttribute }); /** * Components placement, there’s no default placement. - * @type {'start' | 'end' | 'top' | 'bottom'} + * @return {'start' | 'end' | 'top' | 'bottom'} * @default 'start' */ - @Input() placement: string | 'start' | 'end' | 'top' | 'bottom' = 'start'; + readonly placement = input('start'); /** * Responsive offcanvas property hides content outside the viewport from a specified breakpoint and down. - * @type boolean | 'sm' | 'md' | 'lg' | 'xl' | 'xxl'; + * @return boolean | 'sm' | 'md' | 'lg' | 'xl' | 'xxl'; * @default true * @since 4.3.10 */ - @Input() responsive?: boolean | 'sm' | 'md' | 'lg' | 'xl' | 'xxl' = true; - @Input() id = `offcanvas-${this.placement}-${nextId++}`; + readonly responsive = input<(boolean | 'sm' | 'md' | 'lg' | 'xl' | 'xxl') | undefined>(true); + readonly id = input(`offcanvas-${this.placement()}-${nextId++}`); + /** * Default role for offcanvas. [docs] - * @type string + * @return string * @default 'dialog' */ - @Input() @HostBinding('attr.role') role = 'dialog'; + readonly role = input('dialog'); + /** * Set aria-modal html attr for offcanvas. [docs] - * @type boolean + * @return boolean * @default true */ - @Input({ transform: booleanAttribute }) @HostBinding('attr.aria-modal') ariaModal = true; + readonly ariaModal = input(true, { transform: booleanAttribute }); #activeBackdrop!: HTMLDivElement; #backdropClickSubscription!: Subscription; @@ -113,68 +129,70 @@ export class OffcanvasComponent implements OnInit, OnDestroy { /** * Allow body scrolling while offcanvas is visible. - * @type boolean + * @return boolean * @default false */ - @Input({ transform: booleanAttribute }) scroll: boolean = false; + readonly scroll = input(false, { transform: booleanAttribute }); /** * Toggle the visibility of offcanvas component. - * @type boolean + * @return boolean * @default false */ - @Input({ transform: booleanAttribute }) - set visible(value: boolean) { - this.#visible = value; - if (this.#visible) { - this.setBackdrop(this.backdrop); + readonly visibleInput = input(false, { transform: booleanAttribute, alias: 'visible' }); + + readonly visibleInputEffect = effect(() => { + const visible = this.visibleInput(); + untracked(() => { + this.visible.set(visible); + }); + }); + + readonly visible = signal(false); + + readonly visibleEffect = effect(() => { + const visible = this.visible(); + if (visible) { + this.setBackdrop(this.backdrop()); this.setFocus(); } else { this.setBackdrop(false); } - this.layoutChangeSubscribe(this.#visible); - this.visibleChange.emit(value); - } - - get visible(): boolean { - return this.#visible; - } - - #visible: boolean = false; + this.layoutChangeSubscribe(visible); + this.visibleChange.emit(visible); + }); /** * Event triggered on visible change. - * @type EventEmitter + * @return EventEmitter */ - @Output() readonly visibleChange: EventEmitter = new EventEmitter(); + readonly visibleChange = output(); - @HostBinding('class') - get hostClasses(): any { + readonly hostClasses = computed(() => { + const responsive = this.responsive(); + const placement = this.placement(); return { - offcanvas: typeof this.responsive === 'boolean', - [`offcanvas-${this.responsive}`]: typeof this.responsive !== 'boolean', - [`offcanvas-${this.placement}`]: !!this.placement, + offcanvas: typeof responsive === 'boolean', + [`offcanvas-${responsive}`]: typeof responsive !== 'boolean', + [`offcanvas-${placement}`]: !!placement, show: this.show - }; - } + } as Record; + }); - // @HostBinding('attr.aria-hidden') - get ariaHidden(): boolean | null { - return this.visible ? null : true; - } + readonly ariaHidden = computed(() => { + return this.visible() ? null : true; + }); - @HostBinding('attr.tabindex') get tabIndex(): string | null { return '-1'; } - @HostBinding('@showHide') get animateTrigger(): string { - return this.visible ? 'visible' : 'hidden'; + return this.visible() ? 'visible' : 'hidden'; } get show(): boolean { - return this.visible && this.#show; + return this.visible() && this.#show; } set show(value: boolean) { @@ -182,22 +200,21 @@ export class OffcanvasComponent implements OnInit, OnDestroy { } get responsiveBreakpoint(): string | false { - if (typeof this.responsive !== 'string') { + const responsive = this.responsive(); + if (typeof responsive !== 'string') { return false; } const element: Element = this.#document.documentElement; - const responsiveBreakpoint = this.responsive; const breakpointValue = this.#document.defaultView ?.getComputedStyle(element) - ?.getPropertyValue(`--cui-breakpoint-${responsiveBreakpoint.trim()}`) ?? false; + ?.getPropertyValue(`--cui-breakpoint-${responsive.trim()}`) ?? false; return breakpointValue ? `${parseFloat(breakpointValue.trim()) - 0.02}px` : false; } - @HostListener('@showHide.start', ['$event']) animateStart(event: AnimationEvent) { if (event.toState === 'visible') { - if (!this.scroll) { + if (!this.scroll()) { this.#backdropService.hideScrollbar(); } this.#renderer.addClass(this.#hostElement.nativeElement, 'showing'); @@ -206,7 +223,6 @@ export class OffcanvasComponent implements OnInit, OnDestroy { } } - @HostListener('@showHide.done', ['$event']) animateDone(event: AnimationEvent) { setTimeout(() => { if (event.toState === 'visible') { @@ -218,13 +234,12 @@ export class OffcanvasComponent implements OnInit, OnDestroy { this.#renderer.removeStyle(this.#document.body, 'paddingRight'); } }); - this.show = this.visible; + this.show = this.visible(); } - @HostListener('document:keydown', ['$event']) onKeyDownHandler(event: KeyboardEvent): void { - if (event.key === 'Escape' && this.keyboard && this.visible && this.backdrop !== 'static') { - this.#offcanvasService.toggle({ show: false, id: this.id }); + if (event.key === 'Escape' && this.keyboard() && this.visible() && this.backdrop() !== 'static') { + this.#offcanvasService.toggle({ show: false, id: this.id() }); } } @@ -237,7 +252,7 @@ export class OffcanvasComponent implements OnInit, OnDestroy { } ngOnDestroy(): void { - this.#offcanvasService.toggle({ show: false, id: this.id }); + this.#offcanvasService.toggle({ show: false, id: this.id() }); } setFocus(): void { @@ -248,9 +263,9 @@ export class OffcanvasComponent implements OnInit, OnDestroy { private stateToggleSubscribe(): void { this.#offcanvasService.offcanvasState$.pipe(takeUntilDestroyed(this.#destroyRef)).subscribe((action) => { - if (this === action.offcanvas || this.id === action.id) { + if (this === action.offcanvas || this.id() === action.id) { if ('show' in action) { - this.visible = action?.show === 'toggle' ? !this.visible : action.show; + this.visible.update((value) => (action?.show === 'toggle' ? !value : action.show)); } } }); @@ -261,14 +276,14 @@ export class OffcanvasComponent implements OnInit, OnDestroy { this.#backdropClickSubscription = this.#backdropService.backdropClick$ .pipe(takeUntilDestroyed(this.#destroyRef)) .subscribe((clicked) => { - this.#offcanvasService.toggle({ show: !clicked, id: this.id }); + this.#offcanvasService.toggle({ show: !clicked, id: this.id() }); }); } else { this.#backdropClickSubscription?.unsubscribe(); } } - private setBackdrop(setBackdrop: boolean | 'static'): void { + protected setBackdrop(setBackdrop: boolean | 'static'): void { this.#activeBackdrop = !!setBackdrop ? this.#backdropService.setBackdrop('offcanvas') : this.#backdropService.clearBackdrop(this.#activeBackdrop); @@ -291,7 +306,7 @@ export class OffcanvasComponent implements OnInit, OnDestroy { takeUntilDestroyed(this.#destroyRef) ) .subscribe((breakpointState: BreakpointState) => { - this.visible = breakpointState.matches; + this.visible.set(breakpointState.matches); }); } else { this.#layoutChangeSubscription?.unsubscribe(); diff --git a/projects/coreui-angular/src/lib/pagination/page-item/page-item.component.spec.ts b/projects/coreui-angular/src/lib/pagination/page-item/page-item.component.spec.ts index e123a213..a07ee0c1 100644 --- a/projects/coreui-angular/src/lib/pagination/page-item/page-item.component.spec.ts +++ b/projects/coreui-angular/src/lib/pagination/page-item/page-item.component.spec.ts @@ -9,8 +9,7 @@ describe('PaginationItemComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [PageItemComponent] - }) - .compileComponents(); + }).compileComponents(); }); beforeEach(() => { diff --git a/projects/coreui-angular/src/lib/pagination/page-item/page-item.directive.spec.ts b/projects/coreui-angular/src/lib/pagination/page-item/page-item.directive.spec.ts index 9c71be2d..790e0030 100644 --- a/projects/coreui-angular/src/lib/pagination/page-item/page-item.directive.spec.ts +++ b/projects/coreui-angular/src/lib/pagination/page-item/page-item.directive.spec.ts @@ -1,17 +1,60 @@ +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { Component, ComponentRef, DebugElement, input, Renderer2 } from '@angular/core'; +import { provideRouter, RouterLink } from '@angular/router'; + +import { PageLinkDirective } from '../page-link/page-link.directive'; +import { PageItemComponent } from './page-item.component'; import { PageItemDirective } from './page-item.directive'; -import { TestBed } from '@angular/core/testing'; -import { Renderer2 } from '@angular/core'; +import { By } from '@angular/platform-browser'; + +@Component({ + selector: 'c-test', + imports: [PageItemComponent, PageLinkDirective, PageItemComponent, PageLinkDirective, RouterLink], + template: ` + + Previous + + ` +}) +export class TestComponent { + readonly disabled = input(false); +} describe('PageItemDirective', () => { + let component: TestComponent; + let componentRef: ComponentRef; + let fixture: ComponentFixture; + let debugElement: DebugElement; + beforeEach(() => { TestBed.configureTestingModule({ - providers: [Renderer2] - }); + imports: [TestComponent], + providers: [Renderer2, provideRouter([])] + }).compileComponents(); + + fixture = TestBed.createComponent(TestComponent); + component = fixture.componentInstance; + componentRef = fixture.componentRef; + debugElement = fixture.debugElement.query(By.directive(PageLinkDirective)); }); + it('should create an instance', () => { TestBed.runInInjectionContext(() => { const directive = new PageItemDirective(); expect(directive).toBeTruthy(); }); }); + + it('should toggle disable state for the component', fakeAsync(() => { + expect(debugElement.nativeElement.getAttribute('aria-disabled')).toBeNull(); + expect(debugElement.nativeElement.getAttribute('tabindex')).toBeNull(); + componentRef.setInput('disabled', true); + tick(); + fixture.detectChanges(); + expect(debugElement.nativeElement.getAttribute('aria-disabled')).toBe('true'); + expect(debugElement.nativeElement.getAttribute('tabindex')).toBe('-1'); + componentRef.setInput('disabled', false); + tick(); + fixture.detectChanges(); + })); }); diff --git a/projects/coreui-angular/src/lib/pagination/page-item/page-item.directive.ts b/projects/coreui-angular/src/lib/pagination/page-item/page-item.directive.ts index 73cd680e..7e85ceb8 100644 --- a/projects/coreui-angular/src/lib/pagination/page-item/page-item.directive.ts +++ b/projects/coreui-angular/src/lib/pagination/page-item/page-item.directive.ts @@ -1,74 +1,58 @@ -import { - AfterContentInit, - ContentChild, - Directive, - ElementRef, - HostBinding, - inject, - Input, - OnChanges, - Renderer2, - SimpleChanges -} from '@angular/core'; +import { computed, contentChild, Directive, effect, ElementRef, inject, input, Renderer2 } from '@angular/core'; import { PageLinkDirective } from '../page-link/page-link.directive'; @Directive({ selector: '[cPageItem]', - host: { class: 'page-item' } + host: { + class: 'page-item', + '[class]': 'hostClasses()', + '[attr.aria-current]': 'ariaCurrent()' + } }) -export class PageItemDirective implements AfterContentInit, OnChanges { +export class PageItemDirective { readonly #renderer = inject(Renderer2); /** * Toggle the active state for the component. - * @type boolean + * @return boolean */ - @Input() active?: boolean; + readonly active = input(); + /** * Toggle the disabled state for the component. - * @type boolean + * @return boolean */ - @Input() disabled?: boolean; + readonly disabled = input(); - @HostBinding('attr.aria-current') - get ariaCurrent(): string | null { - return this.active ? 'page' : null; - } + readonly ariaCurrent = computed(() => { + return this.active() ? 'page' : null; + }); - @HostBinding('class') - get hostClasses(): any { + readonly hostClasses = computed(() => { return { 'page-item': true, - disabled: this.disabled, - active: this.active - }; - } - - @ContentChild(PageLinkDirective, { read: ElementRef }) pageLinkElementRef!: ElementRef; + disabled: this.disabled(), + active: this.active() + } as Record; + }); - ngAfterContentInit(): void { - this.setAttributes(); - } + readonly pageLinkElementRef = contentChild(PageLinkDirective, { read: ElementRef }); - ngOnChanges(changes: SimpleChanges): void { - if (changes['disabled']) { - this.setAttributes(); - } - } - - setAttributes(): void { - if (!this.pageLinkElementRef) { + readonly pageLinkElementRefEffect = effect(() => { + const pageLinkElementRef = this.pageLinkElementRef(); + const disabled = this.disabled(); + if (!pageLinkElementRef) { return; } - const pageLinkElement = this.pageLinkElementRef.nativeElement; + const pageLinkElement = pageLinkElementRef.nativeElement; - if (this.disabled) { + if (disabled) { this.#renderer.setAttribute(pageLinkElement, 'aria-disabled', 'true'); this.#renderer.setAttribute(pageLinkElement, 'tabindex', '-1'); } else { this.#renderer.removeAttribute(pageLinkElement, 'aria-disabled'); this.#renderer.removeAttribute(pageLinkElement, 'tabindex'); } - } + }); } diff --git a/projects/coreui-angular/src/lib/pagination/pagination/pagination.component.html b/projects/coreui-angular/src/lib/pagination/pagination/pagination.component.html index 337c1837..fe5b272c 100644 --- a/projects/coreui-angular/src/lib/pagination/pagination/pagination.component.html +++ b/projects/coreui-angular/src/lib/pagination/pagination/pagination.component.html @@ -1,3 +1,3 @@ -
    +
    diff --git a/projects/coreui-angular/src/lib/pagination/pagination/pagination.component.ts b/projects/coreui-angular/src/lib/pagination/pagination/pagination.component.ts index 62c28227..e1cff52c 100644 --- a/projects/coreui-angular/src/lib/pagination/pagination/pagination.component.ts +++ b/projects/coreui-angular/src/lib/pagination/pagination/pagination.component.ts @@ -1,37 +1,39 @@ -import { Component, HostBinding, Input } from '@angular/core'; +import { Component, computed, input } from '@angular/core'; import { NgClass } from '@angular/common'; @Component({ - selector: 'c-pagination', - templateUrl: './pagination.component.html', - imports: [NgClass] + selector: 'c-pagination', + templateUrl: './pagination.component.html', + imports: [NgClass], + host: { + '[attr.role]': 'role()' + } }) export class PaginationComponent { - /** * Set the alignment of pagination components. * @values 'start', 'center', 'end' */ - @Input() align: 'start' | 'center' | 'end' | '' = ''; + readonly align = input<'start' | 'center' | 'end' | ''>(''); /** * Size the component small or large. * @values 'sm', 'lg' */ - @Input() size?: 'sm' | 'lg'; + readonly size = input<'sm' | 'lg'>(); /** * Default role for pagination. [docs] - * @type string + * @return string * @default 'navigation' */ - @HostBinding('attr.role') - @Input() role = 'navigation'; + readonly role = input('navigation'); - get paginationClass(): any { + readonly paginationClass = computed(() => { + const size = this.size(); + const align = this.align(); return { pagination: true, - [`pagination-${this.size}`]: !!this.size, - [`justify-content-${this.align}`]: !!this.align - }; - } - + [`pagination-${size}`]: !!size, + [`justify-content-${align}`]: !!align + } as Record; + }); } diff --git a/projects/coreui-angular/src/lib/placeholder/placeholder.directive.spec.ts b/projects/coreui-angular/src/lib/placeholder/placeholder.directive.spec.ts index 15b97e31..48b76b00 100644 --- a/projects/coreui-angular/src/lib/placeholder/placeholder.directive.spec.ts +++ b/projects/coreui-angular/src/lib/placeholder/placeholder.directive.spec.ts @@ -1,11 +1,63 @@ -import { TestBed } from '@angular/core/testing'; +import { Component, ComponentRef, DebugElement, input } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; import { PlaceholderDirective } from './placeholder.directive'; +import { PlaceholderAnimationDirective } from './placeholder-animation.directive'; + +@Component({ + template: ` +

    + +

    + `, + imports: [PlaceholderDirective, PlaceholderAnimationDirective] +}) +class TestComponent { + readonly visible = input(true); + readonly animation = input<'glow' | 'wave' | undefined>(undefined); +} describe('PlaceholderDirective', () => { + let component: TestComponent; + let componentRef: ComponentRef; + let fixture: ComponentFixture; + let debugElement: DebugElement; + let wrapperElement: DebugElement; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [TestComponent] + }).compileComponents(); + + fixture = TestBed.createComponent(TestComponent); + component = fixture.componentInstance; + componentRef = fixture.componentRef; + debugElement = fixture.debugElement.query(By.directive(PlaceholderDirective)); + wrapperElement = fixture.debugElement.query(By.directive(PlaceholderAnimationDirective)); + }); + it('should create an instance', () => { TestBed.runInInjectionContext(() => { const directive = new PlaceholderDirective(); expect(directive).toBeTruthy(); }); }); + + it('should toggle visibility for the placeholder', () => { + componentRef.setInput('visible', true); + fixture.detectChanges(); + expect(debugElement.nativeElement).toHaveClass('placeholder'); + expect(debugElement.nativeElement).toHaveClass('placeholder-sm'); + componentRef.setInput('visible', false); + fixture.detectChanges(); + expect(debugElement.nativeElement.getAttribute('aria-hidden')).toBe('true'); + expect(debugElement.nativeElement).not.toHaveClass('placeholder'); + }); + + it('should toggle animation for the placeholder', () => { + expect(wrapperElement.nativeElement).not.toHaveClass('placeholder-glow'); + componentRef.setInput('animation', 'glow'); + fixture.detectChanges(); + expect(wrapperElement.nativeElement).toHaveClass('placeholder-glow'); + }); }); diff --git a/projects/coreui-angular/src/lib/popover/popover.directive.spec.ts b/projects/coreui-angular/src/lib/popover/popover.directive.spec.ts index 8b6ab282..74857979 100644 --- a/projects/coreui-angular/src/lib/popover/popover.directive.spec.ts +++ b/projects/coreui-angular/src/lib/popover/popover.directive.spec.ts @@ -1,25 +1,97 @@ -import { ChangeDetectorRef, ElementRef, Renderer2, ViewContainerRef } from '@angular/core'; -import { TestBed } from '@angular/core/testing'; -import { IntersectionService, ListenersService } from '../services'; +import { DOCUMENT } from '@angular/common'; +import { + ChangeDetectorRef, + Component, + ComponentRef, + DebugElement, + ElementRef, + Renderer2, + ViewContainerRef +} from '@angular/core'; +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { ListenersService } from '../services'; import { PopoverDirective } from './popover.directive'; +import { Triggers } from '../coreui.types'; + +@Component({ + template: '', + imports: [PopoverDirective] +}) +export class TestComponent { + content = 'Test'; + visible = false; + trigger: Triggers[] = ['hover', 'click']; +} class MockElementRef extends ElementRef {} describe('PopoverDirective', () => { - it('should create an instance', () => { + let component: TestComponent; + let componentRef: ComponentRef; + let fixture: ComponentFixture; + let debugElement: DebugElement; + let document: Document; + + beforeEach(() => { TestBed.configureTestingModule({ + imports: [TestComponent], providers: [ - IntersectionService, + // IntersectionService, Renderer2, ListenersService, { provide: ElementRef, useClass: MockElementRef }, ViewContainerRef, ChangeDetectorRef ] - }); + }).compileComponents(); + document = TestBed.inject(DOCUMENT); + fixture = TestBed.createComponent(TestComponent); + component = fixture.componentInstance; + debugElement = fixture.debugElement.query(By.directive(PopoverDirective)); + fixture.autoDetectChanges(); + }); + + it('should create an instance', () => { TestBed.runInInjectionContext(() => { const directive = new PopoverDirective(); expect(directive).toBeTruthy(); }); }); + + it('should have css classes', fakeAsync(() => { + expect(document.querySelector('.popover.show')).toBeNull(); + component.visible = true; + fixture.detectChanges(); + tick(500); + expect(document.querySelector('.popover.show')).toBeTruthy(); + component.visible = false; + fixture.detectChanges(); + tick(500); + expect(document.querySelector('.popover.show')).toBeNull(); + })); + + it('should set popover on and off', fakeAsync(() => { + fixture.autoDetectChanges(); + component.visible = false; + expect(document.querySelector('.popover.show')).toBeNull(); + debugElement.nativeElement.dispatchEvent(new Event('mouseenter')); + tick(500); + expect(document.querySelector('.popover.show')).toBeTruthy(); + debugElement.nativeElement.dispatchEvent(new Event('mouseleave')); + tick(500); + expect(document.querySelector('.popover.show')).toBeNull(); + })); + + it('should toggle popover', fakeAsync(() => { + fixture.autoDetectChanges(); + component.visible = false; + expect(document.querySelector('.popover.show')).toBeNull(); + debugElement.nativeElement.dispatchEvent(new Event('click')); + tick(500); + expect(document.querySelector('.popover.show')).toBeTruthy(); + debugElement.nativeElement.dispatchEvent(new Event('click')); + tick(500); + expect(document.querySelector('.popover.show')).toBeNull(); + })); }); diff --git a/projects/coreui-angular/src/lib/popover/popover.directive.ts b/projects/coreui-angular/src/lib/popover/popover.directive.ts index 541e0f30..a0370f4e 100644 --- a/projects/coreui-angular/src/lib/popover/popover.directive.ts +++ b/projects/coreui-angular/src/lib/popover/popover.directive.ts @@ -44,7 +44,7 @@ export class PopoverDirective implements OnDestroy, OnInit, AfterViewInit { /** * Content of popover - * @type {string | TemplateRef} + * @return {string | TemplateRef} */ readonly content = input | undefined>(undefined, { alias: 'cPopover' }); @@ -74,14 +74,14 @@ export class PopoverDirective implements OnDestroy, OnInit, AfterViewInit { /** * Describes the placement of your component after Popper.js has applied all the modifiers that may have flipped or altered the originally provided placement property. - * @type: 'top' | 'bottom' | 'left' | 'right' + * @return: 'top' | 'bottom' | 'left' | 'right' * @default: 'top' */ readonly placement = input<'top' | 'bottom' | 'left' | 'right'>('top', { alias: 'cPopoverPlacement' }); /** * ElementRefDirective for positioning the tooltip on reference element - * @type: ElementRefDirective + * @return: ElementRefDirective * @default: undefined */ readonly reference = input(undefined, { alias: 'cTooltipRef' }); @@ -90,13 +90,13 @@ export class PopoverDirective implements OnDestroy, OnInit, AfterViewInit { /** * Sets which event handlers you’d like provided to your toggle prop. You can specify one trigger or an array of them. - * @type: 'Triggers | Triggers[] + * @return: Triggers | Triggers[] */ readonly trigger = input('hover', { alias: 'cPopoverTrigger' }); /** * Toggle the visibility of popover component. - * @type boolean + * @return boolean */ readonly visible = model(false, { alias: 'cPopoverVisible' }); @@ -142,7 +142,7 @@ export class PopoverDirective implements OnDestroy, OnInit, AfterViewInit { hostElement: this.#hostElement, trigger: this.trigger(), callbackToggle: () => { - this.visible.set(!this.visible()); + this.visible.update((visible) => !visible); }, callbackOff: () => { this.visible.set(false); diff --git a/projects/coreui-angular/src/lib/popover/popover/popover.component.ts b/projects/coreui-angular/src/lib/popover/popover/popover.component.ts index f2ca1893..70175099 100644 --- a/projects/coreui-angular/src/lib/popover/popover/popover.component.ts +++ b/projects/coreui-angular/src/lib/popover/popover/popover.component.ts @@ -29,7 +29,7 @@ export class PopoverComponent implements OnDestroy { /** * Content of popover - * @type {string | TemplateRef} + * @return {string | TemplateRef} */ readonly content = input>(''); @@ -39,7 +39,7 @@ export class PopoverComponent implements OnDestroy { /** * Toggle the visibility of popover component. - * @type boolean + * @return boolean */ readonly visible = input(false, { transform: booleanAttribute }); readonly id = input(); diff --git a/projects/coreui-angular/src/lib/shared/element-ref.directive.spec.ts b/projects/coreui-angular/src/lib/shared/element-ref.directive.spec.ts index cc24347f..2360e527 100644 --- a/projects/coreui-angular/src/lib/shared/element-ref.directive.spec.ts +++ b/projects/coreui-angular/src/lib/shared/element-ref.directive.spec.ts @@ -7,7 +7,7 @@ class MockElementRef extends ElementRef {} describe('ElementRefDirective', () => { beforeEach(() => { TestBed.configureTestingModule({ - providers: [{ provide: ElementRef, useClass: MockElementRef }], + providers: [{ provide: ElementRef, useClass: MockElementRef }] }); }); it('should create an instance', () => { @@ -16,4 +16,11 @@ describe('ElementRefDirective', () => { expect(directive).toBeTruthy(); }); }); + + it('should expose elementRef', () => { + TestBed.runInInjectionContext(() => { + const directive = new ElementRefDirective(); + expect(directive.elementRef).toBeInstanceOf(ElementRef); + }); + }); }); diff --git a/projects/coreui-angular/src/lib/shared/theme.directive.spec.ts b/projects/coreui-angular/src/lib/shared/theme.directive.spec.ts index 1f844651..41a8f1e0 100644 --- a/projects/coreui-angular/src/lib/shared/theme.directive.spec.ts +++ b/projects/coreui-angular/src/lib/shared/theme.directive.spec.ts @@ -1,15 +1,29 @@ -import { ElementRef, Renderer2 } from '@angular/core'; -import { TestBed } from '@angular/core/testing'; +import { Component, DebugElement, ElementRef, Renderer2 } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ThemeDirective } from './theme.directive'; +import { By } from '@angular/platform-browser'; + +@Component({ + imports: [ThemeDirective], + template: '
    ' +}) +export class TestComponent { + theme!: 'dark' | 'light' | undefined; +} class MockElementRef extends ElementRef {} describe('ThemeDirective', () => { + let fixture: ComponentFixture; + let debugElement: DebugElement; beforeEach(() => { TestBed.configureTestingModule({ + imports: [TestComponent], providers: [{ provide: ElementRef, useClass: MockElementRef }, Renderer2] }); + fixture = TestBed.createComponent(TestComponent); + debugElement = fixture.debugElement.query(By.css('div')); }); it('should create an instance', () => { @@ -18,4 +32,18 @@ describe('ThemeDirective', () => { expect(directive).toBeTruthy(); }); }); + + it('should set data-coreui-theme attribute', () => { + fixture.detectChanges(); + expect(debugElement.nativeElement.getAttribute('data-coreui-theme')).toBeNull(); + fixture.componentInstance.theme = 'dark'; + fixture.detectChanges(); + expect(debugElement.nativeElement.getAttribute('data-coreui-theme')).toBe('dark'); + fixture.componentInstance.theme = 'light'; + fixture.detectChanges(); + expect(debugElement.nativeElement.getAttribute('data-coreui-theme')).toBe('light'); + fixture.componentInstance.theme = undefined; + fixture.detectChanges(); + expect(debugElement.nativeElement.getAttribute('data-coreui-theme')).toBeNull(); + }); }); diff --git a/projects/coreui-angular/src/lib/shared/theme.directive.ts b/projects/coreui-angular/src/lib/shared/theme.directive.ts index 9bf26cc1..3ad57826 100644 --- a/projects/coreui-angular/src/lib/shared/theme.directive.ts +++ b/projects/coreui-angular/src/lib/shared/theme.directive.ts @@ -1,7 +1,8 @@ -import { booleanAttribute, Directive, ElementRef, inject, Input, Renderer2 } from '@angular/core'; +import { booleanAttribute, Directive, effect, ElementRef, inject, input, Renderer2 } from '@angular/core'; @Directive({ - selector: '[cTheme]' + selector: '[cTheme]', + exportAs: 'cTheme' }) export class ThemeDirective { readonly #hostElement = inject(ElementRef); @@ -9,20 +10,21 @@ export class ThemeDirective { /** * Add dark theme attribute. - * @type 'dark' | 'light' | undefined + * @return 'dark' | 'light' | undefined */ - @Input() set colorScheme(scheme: 'dark' | 'light' | undefined) { - !!scheme ? this.setTheme(scheme) : this.unsetTheme(); - } + readonly colorScheme = input<'dark' | 'light'>(); - /** - * Add dark theme attribute. - * @type boolean - */ - @Input({ transform: booleanAttribute }) - set dark(darkTheme: boolean) { + readonly #colorSchemeChange = effect(() => { + const colorScheme = this.colorScheme(); + colorScheme ? this.setTheme(colorScheme) : this.unsetTheme(); + }); + + readonly dark = input(false, { transform: booleanAttribute }); + + readonly #darkChange = effect(() => { + const darkTheme = this.dark(); darkTheme ? this.setTheme('dark') : this.unsetTheme(); - } + }); setTheme(theme?: string): void { if (theme) { diff --git a/projects/coreui-angular/src/lib/tooltip/tooltip.directive.spec.ts b/projects/coreui-angular/src/lib/tooltip/tooltip.directive.spec.ts index 52a478be..4a826ac1 100644 --- a/projects/coreui-angular/src/lib/tooltip/tooltip.directive.spec.ts +++ b/projects/coreui-angular/src/lib/tooltip/tooltip.directive.spec.ts @@ -1,26 +1,97 @@ -import { ChangeDetectorRef, ElementRef, Renderer2, ViewContainerRef } from '@angular/core'; -import { TestBed } from '@angular/core/testing'; -import { IntersectionService, ListenersService } from '../services'; +import { DOCUMENT } from '@angular/common'; +import { + ChangeDetectorRef, + Component, + ComponentRef, + DebugElement, + ElementRef, + Renderer2, + ViewContainerRef +} from '@angular/core'; +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; import { TooltipDirective } from './tooltip.directive'; +import { Triggers } from '../coreui.types'; +import { ListenersService } from '../services'; + +@Component({ + template: '', + imports: [TooltipDirective] +}) +export class TestComponent { + content = 'Test'; + visible = false; + trigger: Triggers[] = ['hover', 'click']; +} class MockElementRef extends ElementRef {} describe('TooltipDirective', () => { - it('should create an instance', () => { + let component: TestComponent; + let componentRef: ComponentRef; + let fixture: ComponentFixture; + let debugElement: DebugElement; + let document: Document; + + beforeEach(() => { TestBed.configureTestingModule({ + imports: [TestComponent], providers: [ - IntersectionService, + // IntersectionService, Renderer2, ListenersService, { provide: ElementRef, useClass: MockElementRef }, ViewContainerRef, ChangeDetectorRef ] - }); + }).compileComponents(); + document = TestBed.inject(DOCUMENT); + fixture = TestBed.createComponent(TestComponent); + component = fixture.componentInstance; + debugElement = fixture.debugElement.query(By.directive(TooltipDirective)); + fixture.autoDetectChanges(); + }); + it('should create an instance', () => { TestBed.runInInjectionContext(() => { const directive = new TooltipDirective(); expect(directive).toBeTruthy(); }); }); + + it('should have css classes', fakeAsync(() => { + expect(document.querySelector('.tooltip.show')).toBeNull(); + component.visible = true; + fixture.detectChanges(); + tick(500); + expect(document.querySelector('.tooltip.show')).toBeTruthy(); + component.visible = false; + fixture.detectChanges(); + tick(500); + expect(document.querySelector('.tooltip.show')).toBeNull(); + })); + + it('should set popover on and off', fakeAsync(() => { + fixture.autoDetectChanges(); + component.visible = false; + expect(document.querySelector('.tooltip.show')).toBeNull(); + debugElement.nativeElement.dispatchEvent(new Event('mouseenter')); + tick(500); + expect(document.querySelector('.tooltip.show')).toBeTruthy(); + debugElement.nativeElement.dispatchEvent(new Event('mouseleave')); + tick(500); + expect(document.querySelector('.tooltip.show')).toBeNull(); + })); + + it('should toggle popover', fakeAsync(() => { + fixture.autoDetectChanges(); + component.visible = false; + expect(document.querySelector('.tooltip.show')).toBeNull(); + debugElement.nativeElement.dispatchEvent(new Event('click')); + tick(500); + expect(document.querySelector('.tooltip.show')).toBeTruthy(); + debugElement.nativeElement.dispatchEvent(new Event('click')); + tick(500); + expect(document.querySelector('.tooltip.show')).toBeNull(); + })); }); diff --git a/projects/coreui-angular/src/lib/tooltip/tooltip.directive.ts b/projects/coreui-angular/src/lib/tooltip/tooltip.directive.ts index 9457db9e..baa4672d 100644 --- a/projects/coreui-angular/src/lib/tooltip/tooltip.directive.ts +++ b/projects/coreui-angular/src/lib/tooltip/tooltip.directive.ts @@ -44,7 +44,7 @@ export class TooltipDirective implements OnDestroy, OnInit, AfterViewInit { /** * Content of tooltip - * @type {string | TemplateRef} + * @return {string | TemplateRef} */ readonly content = input | undefined>(undefined, { alias: 'cTooltip' }); @@ -56,7 +56,7 @@ export class TooltipDirective implements OnDestroy, OnInit, AfterViewInit { /** * Optional popper Options object, takes precedence over cPopoverPlacement prop - * @type Partial + * @return Partial */ readonly popperOptions = input>({}, { alias: 'cTooltipOptions' }); @@ -74,14 +74,14 @@ export class TooltipDirective implements OnDestroy, OnInit, AfterViewInit { /** * Describes the placement of your component after Popper.js has applied all the modifiers that may have flipped or altered the originally provided placement property. - * @type: 'top' | 'bottom' | 'left' | 'right' + * @return: 'top' | 'bottom' | 'left' | 'right' * @default: 'top' */ readonly placement = input<'top' | 'bottom' | 'left' | 'right'>('top', { alias: 'cTooltipPlacement' }); /** * ElementRefDirective for positioning the tooltip on reference element - * @type: ElementRefDirective + * @return: ElementRefDirective * @default: undefined */ readonly reference = input(undefined, { alias: 'cTooltipRef' }); @@ -90,13 +90,13 @@ export class TooltipDirective implements OnDestroy, OnInit, AfterViewInit { /** * Sets which event handlers you’d like provided to your toggle prop. You can specify one trigger or an array of them. - * @type: 'Triggers | Triggers[] + * @return: 'Triggers | Triggers[] */ readonly trigger = input('hover', { alias: 'cTooltipTrigger' }); /** * Toggle the visibility of tooltip component. - * @type boolean + * @return boolean */ readonly visible = model(false, { alias: 'cTooltipVisible' }); @@ -142,7 +142,7 @@ export class TooltipDirective implements OnDestroy, OnInit, AfterViewInit { hostElement: this.#hostElement, trigger: this.trigger(), callbackToggle: () => { - this.visible.set(!this.visible()); + this.visible.update((value) => !value); }, callbackOff: () => { this.visible.set(false); diff --git a/projects/coreui-icons-angular/package.json b/projects/coreui-icons-angular/package.json index 3899ccc0..e491c7a6 100644 --- a/projects/coreui-icons-angular/package.json +++ b/projects/coreui-icons-angular/package.json @@ -1,6 +1,6 @@ { "name": "@coreui/icons-angular", - "version": "5.3.8", + "version": "5.3.9", "description": "CoreUI Icons Angular component and service", "copyright": "Copyright 2025 creativeLabs Łukasz Holeczek", "license": "MIT", @@ -25,9 +25,9 @@ "url": "https://github.com/coreui/coreui-angular/issues" }, "peerDependencies": { - "@angular/common": "^19.1.1", - "@angular/core": "^19.1.1", - "@angular/platform-browser": "^19.1.1" + "@angular/common": "^19.1.4", + "@angular/core": "^19.1.4", + "@angular/platform-browser": "^19.1.4" }, "dependencies": { "tslib": "^2.3.0"