diff --git a/.github/workflows/build-deploy-storybook.yml b/.github/workflows/build-deploy-storybook.yml
index ecb149f..e59021b 100644
--- a/.github/workflows/build-deploy-storybook.yml
+++ b/.github/workflows/build-deploy-storybook.yml
@@ -23,7 +23,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
- node-version: 24.x
+ node-version-file: .nvmrc
- name: Install dependencies
run: npm ci
diff --git a/.github/workflows/test-and-coverage.yml b/.github/workflows/test-and-coverage.yml
new file mode 100644
index 0000000..575c89f
--- /dev/null
+++ b/.github/workflows/test-and-coverage.yml
@@ -0,0 +1,34 @@
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ types: [opened, synchronize, reopened]
+
+name: Test and coverage
+jobs:
+ test_and_coverage:
+ name: Test and coverage
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checking out
+ uses: actions/checkout@master
+ with:
+ # Disabling shallow clone is recommended for improving relevancy of reporting
+ fetch-depth: 0
+ - name: Setup Node.js
+ uses: actions/setup-node@v6
+ with:
+ node-version-file: .nvmrc
+ - name: Install dependencies
+ run: npm ci
+ env:
+ GITHUB_TOKEN: ${{ secrets.BEP_GITHUB_TOKEN }}
+ - name: Test and coverage
+ run: npm run test:coverage
+ - name: Verify coverage report
+ run: test -s coverage/lcov.info
+ - name: SonarQube Scan
+ uses: SonarSource/sonarqube-scan-action@v7
+ env:
+ SONAR_TOKEN: ${{ secrets.SONARCLOUD_TOKEN }}
diff --git a/README.md b/README.md
index 29caf44..f925c7f 100644
--- a/README.md
+++ b/README.md
@@ -1,59 +1,120 @@
-# BepowerUiKit
+# BePower UI Kit
-This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 22.0.4.
+[](https://sonarcloud.io/summary/new_code?id=BePower_ui-kit)
-## Development server
+Angular and Ionic component library implementing the BePower design system. Components are
+standalone, use native Shadow DOM encapsulation, and expose shared foundations for colors,
+typography, spacing, sizes, and shadows.
-To start a local development server, run:
+- [Library and consumer documentation](./projects/ui-kit/README.md)
+- [Component documentation](./projects/ui-kit/docs/ui-elements.md)
+- [Interactive Storybook](https://app-ui-kit.bepower.tech)
+
+## Requirements
+
+- Node.js `v24.17.0`, defined in [`.nvmrc`](./.nvmrc)
+- npm `11.13.0`, defined by the `packageManager` field in `package.json`
+
+GitHub Actions also reads `.nvmrc`, so local development and CI use the same Node.js version.
+
+## Setup
+
+```bash
+nvm use
+npm ci
+```
+
+## Storybook
+
+Run the interactive component documentation locally:
+
+```bash
+npm run storybook
+```
+
+Storybook imports the library sources directly and does not require a build of `dist/ui-kit`.
+
+Generate the static Storybook website with:
```bash
-ng serve
+npm run storybook:build
```
-Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
+The output is written to `storybook-static/ui-kit`.
+
+## Unit tests
-## Code scaffolding
+Run the complete Vitest suite once:
+
+```bash
+npm test
+```
-Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
+Additional commands:
```bash
-ng generate component component-name
+npm run test:watch
+npm run test:coverage
+npm run test:typecheck
```
-For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
+The tests run directly against the library sources in memory and do not build or modify
+`dist/ui-kit`. Coverage reports are generated in `coverage/`; the configured thresholds require
+100% statements, branches, functions, and lines for the measured runtime code.
+
+## Library development
+
+Build the publishable package:
```bash
-ng generate --help
+npm run build
```
-## Building
+The package is written to `dist/ui-kit`.
-To build the project run:
+Use watch mode when a local consumer is linked to the generated package:
```bash
-ng build
+npm run watch
```
-This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
+Watch mode continuously rebuilds `dist/ui-kit`, so it should not be run at the same time as tasks
+that expect the linked output to remain unchanged.
-## Running unit tests
+## Icons
-To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
+Source SVG files are stored in `icons/`. Import or update the generated TypeScript icon registry
+with:
```bash
-ng test
+npm run icons:import
```
-## Running end-to-end tests
+See the [icon importing guide](./projects/ui-kit/src/lib/components/icon/IMPORTING_ICONS.md) for
+file naming and variant conventions.
+
+## Quality analysis
+
+Pull requests run unit tests, generate the LCOV report, and execute the SonarQube Cloud scanner
+through [the test and coverage workflow](./.github/workflows/test-and-coverage.yml).
+
+The CI scanner reads `sonar-project.properties`, which defines the source scope, analysis
+exclusions, and the LCOV report generated in `coverage/lcov.info`. Automatic Analysis must remain
+disabled in SonarQube Cloud while this workflow is active.
+
+## Publishing
-For end-to-end (e2e) testing, run:
+Build the library, then publish the generated package:
```bash
-ng e2e
+npm run build
+npm publish ./dist/ui-kit
```
-Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
+Keep the leading `./` in the package path; otherwise npm may interpret `dist/ui-kit` as a remote
+package reference.
-## Additional Resources
+## Continuous integration
-For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
+- Pull requests and pushes to `main` run tests, coverage, and SonarQube Cloud checks.
+- Pushes to `main` build and deploy Storybook to GitHub Pages.
diff --git a/package-lock.json b/package-lock.json
index eea9125..aa3bcbf 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -26,6 +26,7 @@
"tslib": "^2.3.0"
},
"devDependencies": {
+ "@analogjs/vite-plugin-angular": "2.7.1",
"@angular-devkit/build-angular": "21.2.18",
"@angular/animations": "21.2.17",
"@angular/build": "^21.0.0",
@@ -39,13 +40,14 @@
"@storybook/addon-a11y": "10.5.10",
"@storybook/addon-docs": "10.5.10",
"@storybook/angular-vite": "10.5.10",
+ "@vitest/coverage-v8": "4.1.9",
"jsdom": "^28.0.0",
"ng-packagr": "^21.0.0",
"prettier": "^3.8.1",
"storybook": "10.5.10",
"typescript": ">=5.9.0 <6.0.0",
"vite": "8.2.2",
- "vitest": "^4.0.8"
+ "vitest": "4.1.9"
}
},
"node_modules/@acemir/cssom": {
@@ -298,7 +300,6 @@
"integrity": "sha512-avRNF3ryDkWqBNege4kIuEalGYI7XnmFJXf/IT/hDjSeHObi+NNXLYfYBcZifjOYuChTRcr7FhR2Ogu4EToiBg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"magic-string": "^0.30.21",
"obug": "^2.1.1",
@@ -3151,6 +3152,16 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@bcoe/v8-coverage": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
+ "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
@@ -6456,7 +6467,6 @@
"os": [
"android"
],
- "peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@@ -6474,7 +6484,6 @@
"os": [
"android"
],
- "peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@@ -6492,7 +6501,6 @@
"os": [
"darwin"
],
- "peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@@ -6510,7 +6518,6 @@
"os": [
"darwin"
],
- "peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@@ -6528,7 +6535,6 @@
"os": [
"freebsd"
],
- "peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@@ -6546,7 +6552,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@@ -6564,7 +6569,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@@ -6585,7 +6589,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@@ -6606,7 +6609,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@@ -6627,7 +6629,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@@ -6648,7 +6649,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@@ -6669,7 +6669,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@@ -6690,7 +6689,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@@ -6711,7 +6709,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@@ -6732,7 +6729,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@@ -6750,7 +6746,6 @@
"os": [
"openharmony"
],
- "peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@@ -6765,7 +6760,6 @@
"dev": true,
"license": "MIT",
"optional": true,
- "peer": true,
"dependencies": {
"@napi-rs/wasm-runtime": "^1.1.1"
},
@@ -6786,7 +6780,6 @@
"os": [
"win32"
],
- "peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@@ -6804,7 +6797,6 @@
"os": [
"win32"
],
- "peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@@ -6822,7 +6814,6 @@
"os": [
"win32"
],
- "peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@@ -9357,6 +9348,37 @@
"@types/node": "*"
}
},
+ "node_modules/@vitest/coverage-v8": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz",
+ "integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@bcoe/v8-coverage": "^1.0.2",
+ "@vitest/utils": "4.1.9",
+ "ast-v8-to-istanbul": "^1.0.0",
+ "istanbul-lib-coverage": "^3.2.2",
+ "istanbul-lib-report": "^3.0.1",
+ "istanbul-reports": "^3.2.0",
+ "magicast": "^0.5.2",
+ "obug": "^2.1.1",
+ "std-env": "^4.0.0-rc.1",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@vitest/browser": "4.1.9",
+ "vitest": "4.1.9"
+ },
+ "peerDependenciesMeta": {
+ "@vitest/browser": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@vitest/expect": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz",
@@ -10016,6 +10038,35 @@
"node": ">=4"
}
},
+ "node_modules/ast-v8-to-istanbul": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz",
+ "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.31",
+ "estree-walker": "^3.0.3",
+ "js-tokens": "^10.0.0"
+ }
+ },
+ "node_modules/ast-v8-to-istanbul/node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
+ "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/autoprefixer": {
"version": "10.4.27",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz",
@@ -12978,6 +13029,13 @@
],
"license": "MIT"
},
+ "node_modules/html-escaper": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
+ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/htmlparser2": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
@@ -13680,6 +13738,64 @@
"node": ">=10"
}
},
+ "node_modules/istanbul-lib-report": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
+ "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "istanbul-lib-coverage": "^3.0.0",
+ "make-dir": "^4.0.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-lib-report/node_modules/make-dir": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
+ "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/istanbul-lib-report/node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/istanbul-reports": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
+ "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "html-escaper": "^2.0.0",
+ "istanbul-lib-report": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/jackspeak": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz",
@@ -14595,6 +14711,18 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
+ "node_modules/magicast": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.4.tgz",
+ "integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "source-map-js": "^1.2.1"
+ }
+ },
"node_modules/make-dir": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-5.1.0.tgz",
@@ -17949,7 +18077,6 @@
"integrity": "sha512-ek9o58+SCv6AV7nchiAcUJy1DNE2CC5WRdBcO0mF+W4oRjNQfPO7b3pLjTHSFECpHkKGOZSQxx3hk8viIL5YCg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@oxc-project/types": "^0.121.0"
},
@@ -17988,7 +18115,6 @@
"integrity": "sha512-CGtOARQb9tyv7ECgdAlFxi0Fv7lmzvmlm2rpD/RdijOO9rfk/JvB1CjT8EnoD+tjna/IYgKKw3IV7objRb+aYw==",
"dev": true,
"license": "MIT",
- "peer": true,
"funding": {
"url": "https://github.com/sponsors/Boshen"
}
diff --git a/package.json b/package.json
index 67ca8ac..305f866 100644
--- a/package.json
+++ b/package.json
@@ -6,7 +6,10 @@
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
- "test": "ng test",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "test:coverage": "vitest run --coverage",
+ "test:typecheck": "tsc --project projects/ui-kit/tsconfig.spec.json --noEmit",
"storybook": "ng run ui-kit:storybook",
"storybook:build": "ng run ui-kit:build-storybook",
"icons:import": "node ./scripts/import-icons.mjs",
@@ -37,6 +40,7 @@
"tslib": "^2.3.0"
},
"devDependencies": {
+ "@analogjs/vite-plugin-angular": "2.7.1",
"@angular-devkit/build-angular": "21.2.18",
"@angular/animations": "21.2.17",
"@angular/build": "^21.0.0",
@@ -50,12 +54,13 @@
"@storybook/addon-a11y": "10.5.10",
"@storybook/addon-docs": "10.5.10",
"@storybook/angular-vite": "10.5.10",
+ "@vitest/coverage-v8": "4.1.9",
"jsdom": "^28.0.0",
"ng-packagr": "^21.0.0",
"prettier": "^3.8.1",
"storybook": "10.5.10",
"typescript": ">=5.9.0 <6.0.0",
"vite": "8.2.2",
- "vitest": "^4.0.8"
+ "vitest": "4.1.9"
}
}
diff --git a/projects/ui-kit/package.json b/projects/ui-kit/package.json
index 7b71025..eebb26f 100644
--- a/projects/ui-kit/package.json
+++ b/projects/ui-kit/package.json
@@ -1,6 +1,6 @@
{
"name": "@bepower/ui-kit",
- "version": "0.0.2",
+ "version": "0.0.3",
"peerDependencies": {
"@angular/common": "^21.0.0",
"@angular/core": "^21.0.0",
diff --git a/projects/ui-kit/src/lib/components/button/ui-button.spec.ts b/projects/ui-kit/src/lib/components/button/ui-button.spec.ts
new file mode 100644
index 0000000..fe8bdd9
--- /dev/null
+++ b/projects/ui-kit/src/lib/components/button/ui-button.spec.ts
@@ -0,0 +1,178 @@
+import { Component } from '@angular/core';
+import { TestBed } from '@angular/core/testing';
+import { configureComponent, render, setInputs, shadow } from '../../../../../../testing/helpers';
+import { UIButton } from './ui-button';
+
+@Component({
+ imports: [UIButton],
+ template: `Read more`,
+})
+class InlineHost {
+ disabled = false;
+ hidden = false;
+ clicked = vi.fn();
+}
+
+describe('UIButton', () => {
+ it('defaults to a primary, solid button and forwards its accessible name', () => {
+ const fixture = render(UIButton, { ariaLabel: 'Continue' });
+ const button = shadow(fixture).querySelector('ion-button')!;
+ expect(button.color).toBe('primary');
+ expect(button.fill).toBe('solid');
+ expect(button.disabled).toBe(false);
+ expect(button.getAttribute('aria-label')).toBe('Continue');
+ expect(button.hasAttribute('aria-hidden')).toBe(false);
+ expect(button.hasAttribute('tabindex')).toBe(false);
+ expect(fixture.nativeElement.classList.contains('primary')).toBe(true);
+ });
+
+ it.each([
+ ['primary', 'primary', 'solid'],
+ ['secondary', 'default-grey', 'clear'],
+ ['outlined', 'default-grey', 'outline'],
+ ])('maps %s to Ionic color %s and fill %s', (variant, color, fill) => {
+ const fixture = render(UIButton, { ariaLabel: 'Action', variant });
+ const button = shadow(fixture).querySelector('ion-button')!;
+ expect(button.color).toBe(color);
+ expect(button.fill).toBe(fill);
+ for (const name of ['primary', 'secondary', 'outlined']) {
+ expect(fixture.nativeElement.classList.contains(name)).toBe(name === variant);
+ }
+ setInputs(fixture, { variant: 'primary' });
+ fixture.detectChanges();
+ expect(button.fill).toBe('solid');
+ });
+
+ it.each(['icon', 'min-icon'])('exposes the %s size and expand classes', (size) => {
+ const fixture = render(UIButton, { ariaLabel: 'Action', size, expand: '' });
+ expect(fixture.nativeElement.classList.contains(size)).toBe(true);
+ expect(fixture.nativeElement.classList.contains('expand')).toBe(true);
+ setInputs(fixture, { size: 'default', expand: 'false' });
+ fixture.detectChanges();
+ expect(fixture.nativeElement.classList.contains(size)).toBe(false);
+ expect(fixture.nativeElement.classList.contains('expand')).toBe(false);
+ });
+
+ it.each([undefined, '', ' '])('rejects an unlabelled regular button (%j)', (ariaLabel) => {
+ configureComponent(UIButton);
+ const fixture = TestBed.createComponent(UIButton);
+ setInputs(fixture, { ariaLabel });
+ expect(() => fixture.detectChanges()).toThrow('[UIButton] ariaLabel is required');
+ });
+
+ it('allows an unlabelled decorative button and removes it from keyboard navigation', () => {
+ const fixture = render(UIButton, { ariaHidden: '' });
+ const button = shadow(fixture).querySelector('ion-button')!;
+ expect(button.getAttribute('aria-hidden')).toBe('true');
+ expect(button.getAttribute('tabindex')).toBe('-1');
+ expect(button.disabled).toBe(false);
+ expect(button.hasAttribute('inert')).toBe(false);
+ });
+
+ it.each(['primary', 'outlined'])('shows a spinner and disables %s while loading', (variant) => {
+ const fixture = render(UIButton, { ariaLabel: 'Action', variant, isLoading: '' });
+ expect(shadow(fixture).querySelector('ion-spinner')).not.toBeNull();
+ expect(shadow(fixture).querySelector('ion-button')!.disabled).toBe(true);
+ expect(fixture.componentInstance.isDisabled()).toBe(true);
+ setInputs(fixture, { isLoading: 'false' });
+ fixture.detectChanges();
+ expect(shadow(fixture).querySelector('ion-spinner')).toBeNull();
+ expect(shadow(fixture).querySelector('ion-button')!.disabled).toBe(false);
+ });
+
+ it('ignores loading on secondary buttons but still respects disabled', () => {
+ const fixture = render(UIButton, {
+ ariaLabel: 'Action',
+ variant: 'secondary',
+ isLoading: true,
+ });
+ const button = shadow(fixture).querySelector('ion-button')!;
+ expect(shadow(fixture).querySelector('ion-spinner')).toBeNull();
+ expect(button.disabled).toBe(false);
+ setInputs(fixture, { disabled: '' });
+ fixture.detectChanges();
+ expect(button.disabled).toBe(true);
+ });
+
+ it.each([
+ [{ size: 'inline', variant: 'primary' }, 'can only be used'],
+ [{ size: 'inline', variant: 'outlined' }, 'can only be used'],
+ [{ size: 'inline', variant: 'secondary', expand: true }, 'cannot be used with expand'],
+ ])('rejects incompatible inline inputs %j', (inputs, message) => {
+ configureComponent(UIButton);
+ const fixture = TestBed.createComponent(UIButton);
+ setInputs(fixture, inputs);
+ expect(() => fixture.detectChanges()).toThrow(message);
+ });
+
+ describe('inline interaction', () => {
+ function setup(disabled = false, hidden = false) {
+ configureComponent(InlineHost);
+ const fixture = TestBed.createComponent(InlineHost);
+ Object.assign(fixture.componentInstance, { disabled, hidden });
+ fixture.detectChanges();
+ const host = fixture.nativeElement.querySelector('ui-button') as HTMLElement;
+ const action = host.shadowRoot!.querySelector('span')!;
+ return { fixture, host, action, clicked: fixture.componentInstance.clicked };
+ }
+
+ it('uses projected content as the accessible name and keeps inline semantic markup', () => {
+ const { action, host } = setup();
+ expect(action.getAttribute('role')).toBe('button');
+ expect(action.getAttribute('tabindex')).toBe('0');
+ expect(action.hasAttribute('aria-label')).toBe(false);
+ expect(action.textContent).toContain('Read more');
+ expect(action.querySelector('strong')?.textContent).toBe('more');
+ expect(host.classList.contains('inline')).toBe(true);
+ expect(host.shadowRoot!.querySelector('ion-button')).toBeNull();
+ expect(host.shadowRoot!.querySelector('ion-text')!.color).toBe('default-grey');
+ });
+
+ it.each([false, true])('delivers pointer clicks even when ariaHidden=%s', (hidden) => {
+ const { action, clicked } = setup(false, hidden);
+ action.click();
+ expect(clicked).toHaveBeenCalledTimes(1);
+ expect(action.hasAttribute('inert')).toBe(false);
+ expect(action.getAttribute('aria-hidden')).toBe(hidden ? 'true' : null);
+ expect(action.tabIndex).toBe(hidden ? -1 : 0);
+ });
+
+ it('blocks a disabled click before it reaches the consumer', () => {
+ const { action, clicked } = setup(true);
+ const event = new MouseEvent('click', { bubbles: true, composed: true, cancelable: true });
+ expect(action.dispatchEvent(event)).toBe(false);
+ expect(clicked).not.toHaveBeenCalled();
+ expect(action.getAttribute('aria-disabled')).toBe('true');
+ expect(action.tabIndex).toBe(-1);
+ });
+
+ it.each(['Enter', ' ', 'Spacebar', 'Escape'])(
+ 'supports the activation phase for keyboard key %j',
+ (key) => {
+ const { action, clicked } = setup();
+ const down = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true });
+ action.dispatchEvent(down);
+ expect(clicked).toHaveBeenCalledTimes(key === 'Enter' ? 1 : 0);
+ expect(down.defaultPrevented).toBe(key !== 'Escape');
+ const up = new KeyboardEvent('keyup', { key, bubbles: true, cancelable: true });
+ action.dispatchEvent(up);
+ expect(clicked).toHaveBeenCalledTimes(key === 'Escape' ? 0 : 1);
+ expect(up.defaultPrevented).toBe(key === ' ' || key === 'Spacebar');
+ },
+ );
+
+ it.each(['Enter', ' ', 'Spacebar'])('does not activate a disabled action on %j', (key) => {
+ const { action, clicked } = setup(true);
+ action.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
+ action.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));
+ expect(clicked).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/projects/ui-kit/src/lib/components/col/ui-col.spec.ts b/projects/ui-kit/src/lib/components/col/ui-col.spec.ts
new file mode 100644
index 0000000..4e9ef63
--- /dev/null
+++ b/projects/ui-kit/src/lib/components/col/ui-col.spec.ts
@@ -0,0 +1,60 @@
+import { Component } from '@angular/core';
+import { render, setInputs, shadow } from '../../../../../../testing/helpers';
+import { UICol } from './ui-col';
+
+@Component({ imports: [UICol], template: 'Content' })
+class ColHost {}
+
+@Component({ imports: [UICol], template: 'Legacy' })
+class LegacyColHost {}
+
+describe('UICol', () => {
+ it('defaults to start alignment, no spacing and no explicit size', () => {
+ const fixture = render(UICol);
+ const col = shadow(fixture).querySelector('ion-col')!;
+ expect(col.classList).toContain('ion-display-flex');
+ expect(col.classList).toContain('ion-align-items-start');
+ expect(col.classList).toContain('ion-justify-content-start');
+ expect(col.getAttribute('data-horizontal')).toBe('none');
+ expect(col.size).toBeUndefined();
+ });
+
+ it.each(['start', 'center', 'end'])('applies align and justify value %s exclusively', (value) => {
+ const fixture = render(UICol, { align: value, justify: value });
+ const col = shadow(fixture).querySelector('ion-col')!;
+ for (const candidate of ['start', 'center', 'end']) {
+ expect(col.classList.contains(`ion-align-items-${candidate}`)).toBe(candidate === value);
+ expect(col.classList.contains(`ion-justify-content-${candidate}`)).toBe(candidate === value);
+ }
+ });
+
+ it.each([
+ [6, '6'],
+ ['12', '12'],
+ ['auto', 'auto'],
+ ])('normalizes size %j to %s', (size, expected) => {
+ const fixture = render(UICol, { size, horizontal: 's' });
+ const col = shadow(fixture).querySelector('ion-col')!;
+ expect(col.size).toBe(expected);
+ expect(col.getAttribute('data-horizontal')).toBe('s');
+ setInputs(fixture, { size: 4 });
+ fixture.detectChanges();
+ expect(col.size).toBe('4');
+ });
+
+ it.each([
+ [ColHost, false, 'Content'],
+ [LegacyColHost, true, 'Legacy'],
+ ])('supports isolated and native-slot projection', (hostType, legacy, text) => {
+ const fixture = render(hostType);
+ const host = fixture.nativeElement.querySelector('ui-col') as HTMLElement;
+ const root: ParentNode = legacy ? host : host.shadowRoot!;
+ const content = root.querySelector('span')!;
+ expect(content.textContent).toBe(text);
+ expect(host.shadowRoot!.querySelector('slot') !== null).toBe(legacy);
+ if (legacy)
+ expect((host.shadowRoot!.querySelector('slot') as HTMLSlotElement).assignedNodes()).toContain(
+ content,
+ );
+ });
+});
diff --git a/projects/ui-kit/src/lib/components/grid/ui-grid.spec.ts b/projects/ui-kit/src/lib/components/grid/ui-grid.spec.ts
new file mode 100644
index 0000000..4f5b207
--- /dev/null
+++ b/projects/ui-kit/src/lib/components/grid/ui-grid.spec.ts
@@ -0,0 +1,57 @@
+import { Component } from '@angular/core';
+import { TestBed } from '@angular/core/testing';
+import { configureComponent, render, setInputs, shadow } from '../../../../../../testing/helpers';
+import { UIGrid } from './ui-grid';
+
+@Component({ imports: [UIGrid], template: '' })
+class GridHost {}
+
+@Component({
+ imports: [UIGrid],
+ template: '',
+})
+class LegacyGridHost {}
+
+describe('UIGrid', () => {
+ it('forwards its layout inputs to ion-grid and updates them', () => {
+ const fixture = render(UIGrid);
+ const grid = shadow(fixture).querySelector('ion-grid')!;
+ expect(grid.getAttribute('data-horizontal')).toBe('xs');
+ expect(grid.fixed).toBe(false);
+ setInputs(fixture, { horizontal: 'm', fixed: '' });
+ fixture.detectChanges();
+ expect(grid.getAttribute('data-horizontal')).toBe('m');
+ expect(grid.fixed).toBe(true);
+ setInputs(fixture, { fixed: 'false' });
+ fixture.detectChanges();
+ expect(grid.fixed).toBe(false);
+ });
+
+ it('isolates projected content in its shadow root by default', () => {
+ const fixture = render(GridHost);
+ const host = fixture.nativeElement.querySelector('ui-grid') as HTMLElement;
+ expect(host.querySelector('button')).toBeNull();
+ expect(host.shadowRoot!.querySelector('ion-grid > button')?.textContent).toBe('Content');
+ expect(host.shadowRoot!.querySelector('slot')).toBeNull();
+ });
+
+ it('keeps legacy content in light DOM and assigns it to a native slot', () => {
+ const fixture = render(LegacyGridHost);
+ const host = fixture.nativeElement.querySelector('ui-grid') as HTMLElement;
+ const button = host.querySelector('button')!;
+ const slot = host.shadowRoot!.querySelector('slot') as HTMLSlotElement;
+ expect(button.textContent).toBe('Legacy');
+ expect(slot.assignedNodes()).toContain(button);
+ });
+
+ it('reads legacyContent only at initialization', () => {
+ configureComponent(UIGrid);
+ const fixture = TestBed.createComponent(UIGrid);
+ setInputs(fixture, { legacyContent: false });
+ fixture.detectChanges();
+ expect(shadow(fixture).querySelector('slot')).toBeNull();
+ setInputs(fixture, { legacyContent: true });
+ fixture.detectChanges();
+ expect(shadow(fixture).querySelector('slot')).toBeNull();
+ });
+});
diff --git a/projects/ui-kit/src/lib/components/icon/icon-map.spec.ts b/projects/ui-kit/src/lib/components/icon/icon-map.spec.ts
new file mode 100644
index 0000000..cc98e83
--- /dev/null
+++ b/projects/ui-kit/src/lib/components/icon/icon-map.spec.ts
@@ -0,0 +1,16 @@
+import { iconMap } from './icon-map';
+
+describe('iconMap', () => {
+ it('contains only callable variants that resolve to encoded SVG assets', async () => {
+ expect(Object.keys(iconMap).length).toBeGreaterThan(40);
+ const variants = Object.entries(iconMap).flatMap(([name, icons]) =>
+ Object.entries(icons).map(async ([variant, load]) => {
+ expect(['fill', 'no-fill']).toContain(variant);
+ expect(load, `${name}/${variant}`).toBeTypeOf('function');
+ const asset = await load!();
+ expect(asset, `${name}/${variant}`).toMatch(/^data:image\/svg\+xml/);
+ }),
+ );
+ await Promise.all(variants);
+ });
+});
diff --git a/projects/ui-kit/src/lib/components/icon/ui-icon.spec.ts b/projects/ui-kit/src/lib/components/icon/ui-icon.spec.ts
new file mode 100644
index 0000000..ae8b662
--- /dev/null
+++ b/projects/ui-kit/src/lib/components/icon/ui-icon.spec.ts
@@ -0,0 +1,110 @@
+import { TestBed } from '@angular/core/testing';
+import { configureComponent, deferred, setInputs, shadow } from '../../../../../../testing/helpers';
+
+const loaders = vi.hoisted(() => ({
+ fill: vi.fn<() => Promise>(),
+ noFill: vi.fn<() => Promise>(),
+}));
+
+vi.mock('./icon-map', () => ({
+ iconMap: {
+ arrow: { fill: loaders.fill, 'no-fill': loaders.noFill },
+ 'arrow-left': { 'no-fill': loaders.noFill },
+ },
+}));
+
+import { UIIcon } from './ui-icon';
+
+async function flush(): Promise {
+ await Promise.resolve();
+ await Promise.resolve();
+}
+
+describe('UIIcon', () => {
+ beforeEach(() => {
+ loaders.fill.mockReset();
+ loaders.noFill.mockReset();
+ loaders.fill.mockResolvedValue('data:image/svg+xml,fill');
+ loaders.noFill.mockResolvedValue('data:image/svg+xml,no-fill');
+ });
+
+ it('requires an icon name', () => {
+ configureComponent(UIIcon);
+ const fixture = TestBed.createComponent(UIIcon);
+ expect(() => fixture.detectChanges()).toThrow();
+ });
+
+ it('loads the requested asset, hiding the icon until it resolves', async () => {
+ const pending = deferred();
+ loaders.noFill.mockReturnValueOnce(pending.promise);
+ configureComponent(UIIcon);
+ const fixture = TestBed.createComponent(UIIcon);
+ setInputs(fixture, { name: 'arrow', fill: 'no-fill', size: '40', color: 'green' });
+ fixture.detectChanges();
+ expect(shadow(fixture).querySelector('ion-icon')).toBeNull();
+ pending.resolve('data:image/svg+xml,arrow');
+ await flush();
+ fixture.detectChanges();
+ const icon = shadow(fixture).querySelector('ion-icon')!;
+ expect(icon.src).toBe('data:image/svg+xml,arrow');
+ expect(icon.color).toBe('green');
+ expect(icon.classList).toContain('size-40');
+ expect(icon.getAttribute('aria-hidden')).toBe('true');
+ });
+
+ it('ignores a stale async response after inputs change', async () => {
+ const first = deferred();
+ loaders.fill.mockReturnValueOnce(first.promise);
+ loaders.noFill.mockResolvedValueOnce('data:image/svg+xml,current');
+ configureComponent(UIIcon);
+ const fixture = TestBed.createComponent(UIIcon);
+ setInputs(fixture, { name: 'arrow', fill: 'fill' });
+ fixture.detectChanges();
+ setInputs(fixture, { fill: 'no-fill' });
+ fixture.detectChanges();
+ await flush();
+ fixture.detectChanges();
+ expect(shadow(fixture).querySelector('ion-icon')?.src).toBe('data:image/svg+xml,current');
+ first.resolve('data:image/svg+xml,stale');
+ await flush();
+ fixture.detectChanges();
+ expect(shadow(fixture).querySelector('ion-icon')?.src).toBe('data:image/svg+xml,current');
+ });
+
+ it('reports unsupported variants without rendering an icon', () => {
+ const error = vi.spyOn(console, 'error').mockImplementation(() => undefined);
+ configureComponent(UIIcon);
+ const fixture = TestBed.createComponent(UIIcon);
+ setInputs(fixture, { name: 'arrow-left', fill: 'fill' });
+ fixture.detectChanges();
+ expect(error).toHaveBeenCalledWith('[UI ICON] - icon not found, arrow-left fill');
+ expect(shadow(fixture).querySelector('ion-icon')).toBeNull();
+ });
+
+ it('reports loader failures and keeps the icon hidden', async () => {
+ const failure = new Error('network');
+ loaders.fill.mockRejectedValueOnce(failure);
+ const error = vi.spyOn(console, 'error').mockImplementation(() => undefined);
+ configureComponent(UIIcon);
+ const fixture = TestBed.createComponent(UIIcon);
+ setInputs(fixture, { name: 'arrow', fill: 'fill' });
+ fixture.detectChanges();
+ await flush();
+ expect(error).toHaveBeenCalledWith('[UI ICON] - failed to load icon, arrow fill', failure);
+ expect(shadow(fixture).querySelector('ion-icon')).toBeNull();
+ });
+
+ it('does not report a rejected request after the component is destroyed', async () => {
+ const pending = deferred();
+ loaders.fill.mockReturnValueOnce(pending.promise);
+ const error = vi.spyOn(console, 'error').mockImplementation(() => undefined);
+ configureComponent(UIIcon);
+ const fixture = TestBed.createComponent(UIIcon);
+ setInputs(fixture, { name: 'arrow', fill: 'fill' });
+ fixture.detectChanges();
+ fixture.destroy();
+ pending.reject(new Error('late'));
+ await flush();
+ expect(error).not.toHaveBeenCalled();
+ });
+});
diff --git a/projects/ui-kit/src/lib/components/p/ui-p.spec.ts b/projects/ui-kit/src/lib/components/p/ui-p.spec.ts
new file mode 100644
index 0000000..830297d
--- /dev/null
+++ b/projects/ui-kit/src/lib/components/p/ui-p.spec.ts
@@ -0,0 +1,34 @@
+import { Component } from '@angular/core';
+import { render, setInputs, shadow } from '../../../../../../testing/helpers';
+import { UIP } from './ui-p';
+
+@Component({
+ imports: [UIP],
+ template: 'Hello world',
+})
+class ParagraphHost {}
+
+describe('UIP', () => {
+ it('projects text and inline markup into a semantic paragraph without Ionic wrappers', () => {
+ const fixture = render(ParagraphHost);
+ const root = fixture.nativeElement.querySelector('ui-p').shadowRoot as ShadowRoot;
+ expect(root.querySelector('p')?.textContent).toContain('Hello world');
+ expect(root.querySelector('p > strong')?.textContent).toBe('world');
+ expect(root.querySelector('ion-text')).toBeNull();
+ expect(root.querySelector('p')?.hasAttribute('aria-hidden')).toBe(false);
+ });
+
+ it.each([true, '', false, 'false'])(
+ 'coerces ariaHidden=%j and updates the semantic element',
+ (ariaHidden) => {
+ const fixture = render(UIP, { ariaHidden });
+ const paragraph = shadow(fixture).querySelector('p')!;
+ expect(paragraph.getAttribute('aria-hidden')).toBe(
+ ariaHidden === true || ariaHidden === '' ? 'true' : null,
+ );
+ setInputs(fixture, { ariaHidden: false });
+ fixture.detectChanges();
+ expect(paragraph.hasAttribute('aria-hidden')).toBe(false);
+ },
+ );
+});
diff --git a/projects/ui-kit/src/lib/components/page/ui-page.spec.ts b/projects/ui-kit/src/lib/components/page/ui-page.spec.ts
new file mode 100644
index 0000000..b926562
--- /dev/null
+++ b/projects/ui-kit/src/lib/components/page/ui-page.spec.ts
@@ -0,0 +1,526 @@
+import { Component, PLATFORM_ID, signal } from '@angular/core';
+import { TestBed, type ComponentFixture } from '@angular/core/testing';
+import { provideRouter } from '@angular/router';
+import { IonContent, IonRouterOutlet, provideIonicAngular } from '@ionic/angular/standalone';
+import { provideZonelessChangeDetection } from '@angular/core';
+import { setInputs } from '../../../../../../testing/helpers';
+import { ScreenReaderService } from '../../services/screen-reader.service';
+import { StatusBarService } from '../../services/status-bar.service';
+import { UIPage, type HeaderStep } from './ui-page';
+
+@Component({
+ imports: [UIPage],
+ template: '',
+})
+class PageHost {}
+
+@Component({
+ imports: [UIPage],
+ template: `
+
+ `,
+})
+class FooterPageHost {}
+
+@Component({
+ imports: [UIPage],
+ template: `
+
+ `,
+})
+class LegacyPageHost {}
+
+const validStep: HeaderStep = {
+ currentStep: 2,
+ totalSteps: 4,
+ label: 'of',
+ ariaLabel: 'Registration progress',
+};
+
+function createScrollable() {
+ const element = document.createElement('div');
+ const state = { clientHeight: 100, scrollHeight: 300, scrollTop: 0 };
+ for (const property of ['clientHeight', 'scrollHeight', 'scrollTop'] as const) {
+ Object.defineProperty(element, property, {
+ configurable: true,
+ get: () => state[property],
+ set: (value: number) => (state[property] = value),
+ });
+ }
+ return { element, state };
+}
+
+function createServices() {
+ const screenReader = {
+ screenState: signal(false),
+ screenStateKnown: signal(true),
+ focus: vi.fn(() => vi.fn()),
+ };
+ const statusBar = {
+ setStatusBarStyleLight: vi.fn(async () => undefined),
+ setStatusBarStyleDark: vi.fn(async () => undefined),
+ };
+ return { screenReader, statusBar };
+}
+
+function configurePage(
+ services = createServices(),
+ routerOutlet?: { canGoBack: ReturnType },
+): typeof services {
+ TestBed.configureTestingModule({
+ imports: [UIPage],
+ providers: [
+ provideZonelessChangeDetection(),
+ provideRouter([]),
+ provideIonicAngular({ animated: false }),
+ { provide: ScreenReaderService, useValue: services.screenReader },
+ { provide: StatusBarService, useValue: services.statusBar },
+ ...(routerOutlet ? [{ provide: IonRouterOutlet, useValue: routerOutlet }] : []),
+ ],
+ });
+ return services;
+}
+
+function createPage(
+ inputs: Record,
+ services = createServices(),
+ routerOutlet?: { canGoBack: ReturnType },
+): { fixture: ComponentFixture; services: typeof services; root: ShadowRoot } {
+ configurePage(services, routerOutlet);
+ const fixture = TestBed.createComponent(UIPage);
+ setInputs(fixture, inputs);
+ fixture.detectChanges();
+ return { fixture, services, root: fixture.nativeElement.shadowRoot as ShadowRoot };
+}
+
+async function flush(): Promise {
+ await Promise.resolve();
+ await Promise.resolve();
+}
+
+describe('UIPage', () => {
+ beforeEach(() => {
+ vi.spyOn(IonContent.prototype, 'getScrollElement').mockResolvedValue(
+ document.createElement('div'),
+ );
+ });
+
+ describe('input validation', () => {
+ it.each([
+ [{}, '[UIPage] screenReaderTitle is required when hasHeader is false.'],
+ [
+ { screenReaderTitle: ' ' },
+ '[UIPage] screenReaderTitle is required when hasHeader is false.',
+ ],
+ [{ hasHeader: true }, '[UIPage] A title or screenReaderTitle is required.'],
+ [{ hasHeader: true, title: ' ' }, '[UIPage] A title or screenReaderTitle is required.'],
+ [{ hasHeader: true, title: 'Title', headerType: 'step' }, 'headerStep is required'],
+ [
+ {
+ hasHeader: true,
+ title: 'Title',
+ headerType: 'step',
+ headerStep: { ...validStep, label: ' ' },
+ },
+ 'headerStep.label is required',
+ ],
+ [
+ {
+ hasHeader: true,
+ title: 'Title',
+ headerType: 'step',
+ headerStep: { ...validStep, ariaLabel: '' },
+ },
+ 'headerStep.ariaLabel is required',
+ ],
+ [
+ {
+ hasHeader: true,
+ title: 'Title',
+ headerType: 'step',
+ headerStep: { ...validStep, totalSteps: 0 },
+ },
+ 'headerStep.totalSteps must be a positive integer',
+ ],
+ [
+ {
+ hasHeader: true,
+ title: 'Title',
+ headerType: 'step',
+ headerStep: { ...validStep, totalSteps: 2.5 },
+ },
+ 'headerStep.totalSteps must be a positive integer',
+ ],
+ [
+ {
+ hasHeader: true,
+ title: 'Title',
+ headerType: 'step',
+ headerStep: { ...validStep, currentStep: 0 },
+ },
+ 'headerStep.currentStep must be an integer',
+ ],
+ [
+ {
+ hasHeader: true,
+ title: 'Title',
+ headerType: 'step',
+ headerStep: { ...validStep, currentStep: 2.5 },
+ },
+ 'headerStep.currentStep must be an integer',
+ ],
+ [
+ {
+ hasHeader: true,
+ title: 'Title',
+ headerType: 'step',
+ headerStep: { ...validStep, currentStep: 5 },
+ },
+ 'headerStep.currentStep must be an integer',
+ ],
+ ])('rejects invalid configuration %#', (inputs, message) => {
+ configurePage();
+ const fixture = TestBed.createComponent(UIPage);
+ setInputs(fixture, inputs);
+ expect(() => fixture.detectChanges()).toThrow(message);
+ });
+
+ it('allows a screen-reader-only title with or without a visible header title', () => {
+ const { fixture, root } = createPage({
+ hasHeader: true,
+ screenReaderTitle: 'Accessible only',
+ });
+ expect(root.querySelector('ion-title')).toBeNull();
+ expect(root.querySelector('h1.screen-reader-only')?.textContent).toBe('Accessible only');
+ fixture.destroy();
+ });
+ });
+
+ it('renders a visible title while using screenReaderTitle as its accessible name', () => {
+ const { root } = createPage({
+ hasHeader: true,
+ title: 'Short title',
+ screenReaderTitle: 'Full page title',
+ });
+ const title = root.querySelector('ion-title span')!;
+ expect(title.textContent).toBe('Short title');
+ expect(title.getAttribute('role')).toBe('heading');
+ expect(title.getAttribute('aria-level')).toBe('1');
+ expect(title.getAttribute('aria-label')).toBe('Full page title');
+ expect(title.getAttribute('tabindex')).toBe('-1');
+ expect(root.querySelector('h1.screen-reader-only')).toBeNull();
+ });
+
+ it('forwards page, header, content and back-button configuration', () => {
+ const { fixture, root } = createPage({
+ hasHeader: true,
+ hasFooter: true,
+ title: 'Title',
+ background: 'yellow',
+ translucent: true,
+ forceOverscroll: true,
+ backButtonDefaultHref: '/home',
+ });
+ const header = root.querySelector('ion-header')!;
+ const content = root.querySelector('ion-content')!;
+ const back = root.querySelector('ion-back-button')!;
+ expect(header.translucent).toBe(true);
+ expect(root.querySelector('ion-toolbar')?.classList).toContain('background-yellow');
+ expect(content.forceOverscroll).toBe(true);
+ expect(content.fullscreen).toBe(true);
+ expect(content.scrollEvents).toBe(true);
+ expect(back.defaultHref).toBe('/home');
+ expect(back.text).toBe('');
+ expect(back.icon).toBe(fixture.componentInstance.backButtonIcon);
+ expect(fixture.nativeElement.classList.contains('no-header')).toBe(false);
+ expect(fixture.nativeElement.classList.contains('no-footer')).toBe(false);
+ });
+
+ it('exposes safe computed defaults when no header and no footer are present', () => {
+ const { fixture } = createPage({ screenReaderTitle: 'Page' });
+ expect(fixture.componentInstance.headerStepValue()).toBe(0);
+ expect(fixture.componentInstance.headerStepAriaLabel()).toBe('');
+ expect(fixture.componentInstance.shouldObserveScroll()).toBe(false);
+ expect(fixture.nativeElement.classList.contains('no-header')).toBe(true);
+ expect(fixture.nativeElement.classList.contains('no-footer')).toBe(true);
+ });
+
+ it.each([
+ ['white', 'default-grey', 'green'],
+ ['tab', 'green', 'green'],
+ ['green', 'white', 'white'],
+ ['step', 'default-grey', 'green'],
+ ])('maps %s header colors to title=%s and icon=%s', (headerType, titleColor, iconColor) => {
+ const inputs: Record = { hasHeader: true, title: 'Title', headerType };
+ if (headerType === 'step') inputs['headerStep'] = validStep;
+ const { fixture } = createPage(inputs);
+ expect(fixture.componentInstance.ionicHeaderColor()).toBe(titleColor);
+ expect(fixture.componentInstance.ionicHeaderIconColor()).toBe(iconColor);
+ });
+
+ it('renders and reactively updates a step header', () => {
+ const { fixture, root } = createPage({
+ hasHeader: true,
+ title: 'Registration',
+ headerType: 'step',
+ headerStep: validStep,
+ });
+ expect(fixture.componentInstance.isStepHeader()).toBe(true);
+ expect(fixture.componentInstance.headerStepValue()).toBe(0.5);
+ expect(fixture.componentInstance.headerStepAriaLabel()).toBe('2 of 4');
+ const progressHost = root.querySelector('ui-progress-bar') as HTMLElement;
+ const progress = progressHost.shadowRoot!.querySelector('ion-progress-bar')!;
+ expect(progress.value).toBe(0.5);
+ expect(progress.getAttribute('aria-label')).toBe('Registration progress');
+ expect(progress.getAttribute('aria-valuetext')).toBe('2 of 4');
+ const stepTexts = (root.querySelector('ui-p') as HTMLElement).shadowRoot!.querySelectorAll(
+ 'ui-text',
+ );
+ expect((stepTexts[0] as HTMLElement).shadowRoot?.textContent).toContain('2');
+ expect((stepTexts[1] as HTMLElement).shadowRoot?.textContent).toContain('of 4');
+ setInputs(fixture, { headerStep: { ...validStep, currentStep: 4 } });
+ fixture.detectChanges();
+ expect(progress.value).toBe(1);
+ expect(progress.getAttribute('aria-valuetext')).toBe('4 of 4');
+ });
+
+ it('enables condensed header only when it is safe for accessibility', () => {
+ const services = createServices();
+ const { fixture } = createPage(
+ { hasHeader: true, title: 'Title', condensedHeader: true },
+ services,
+ );
+ expect(fixture.componentInstance.isCondensedHeaderEnabled()).toBe(true);
+ expect(fixture.componentInstance.hasCondensedHeader()).toBe(true);
+ services.screenReader.screenState.set(true);
+ expect(fixture.componentInstance.isCondensedHeaderEnabled()).toBe(false);
+ services.screenReader.screenState.set(false);
+ services.screenReader.screenStateKnown.set(false);
+ expect(fixture.componentInstance.isCondensedHeaderEnabled()).toBe(false);
+ services.screenReader.screenStateKnown.set(true);
+ setInputs(fixture, { headerType: 'step', headerStep: validStep });
+ fixture.detectChanges();
+ expect(fixture.componentInstance.isCondensedHeaderEnabled()).toBe(false);
+ });
+
+ it('updates back-stack state on Ionic enter hooks', () => {
+ const canGoBack = vi.fn().mockReturnValueOnce(false).mockReturnValue(true);
+ const { fixture } = createPage({ screenReaderTitle: 'Page' }, createServices(), { canGoBack });
+ fixture.componentInstance.ionViewWillEnter();
+ expect(fixture.componentInstance.canGoBack()).toBe(false);
+ fixture.componentInstance.ionViewDidEnter();
+ expect(fixture.componentInstance.canGoBack()).toBe(true);
+ fixture.detectChanges();
+ expect(fixture.nativeElement.classList.contains('can-go-back')).toBe(true);
+ });
+
+ it.each([
+ [{ screenReaderTitle: 'Page' }, 'light'],
+ [{ hasHeader: true, title: 'Title', headerType: 'white' }, 'light'],
+ [{ hasHeader: true, title: 'Title', headerType: 'tab' }, 'light'],
+ [{ hasHeader: true, title: 'Title', headerType: 'green' }, 'dark'],
+ [{ hasHeader: true, title: 'Title', headerType: 'step', headerStep: validStep }, 'light'],
+ ])('applies the %s status bar policy when entering', (inputs, expected) => {
+ const services = createServices();
+ const { fixture } = createPage(inputs, services);
+ fixture.componentInstance.ionViewDidEnter();
+ expect(services.statusBar.setStatusBarStyleLight).toHaveBeenCalledTimes(
+ expected === 'light' ? 1 : 0,
+ );
+ expect(services.statusBar.setStatusBarStyleDark).toHaveBeenCalledTimes(
+ expected === 'dark' ? 1 : 0,
+ );
+ });
+
+ it('focuses the title on entry and cancels pending requests on re-entry, leave and destroy', () => {
+ const cancelFirst = vi.fn();
+ const cancelSecond = vi.fn();
+ const services = createServices();
+ services.screenReader.focus.mockReturnValueOnce(cancelFirst).mockReturnValueOnce(cancelSecond);
+ const { fixture, root } = createPage({ hasHeader: true, title: 'Title' }, services);
+ fixture.componentInstance.ionViewDidEnter();
+ expect(services.screenReader.focus).toHaveBeenCalledWith(root.querySelector('ion-title span'));
+ fixture.componentInstance.ionViewDidEnter();
+ expect(cancelFirst).toHaveBeenCalledOnce();
+ fixture.componentInstance.ionViewWillLeave();
+ expect(cancelSecond).toHaveBeenCalledOnce();
+ fixture.componentInstance.ionViewDidLeave();
+ fixture.destroy();
+ });
+
+ it('handles an enter hook before the view title is available', () => {
+ const services = createServices();
+ configurePage(services);
+ const fixture = TestBed.createComponent(UIPage);
+ setInputs(fixture, { screenReaderTitle: 'Page' });
+ expect(() => fixture.componentInstance.ionViewDidEnter()).not.toThrow();
+ expect(services.screenReader.focus).not.toHaveBeenCalled();
+ expect(services.statusBar.setStatusBarStyleLight).toHaveBeenCalledOnce();
+ });
+
+ it('executes configured header actions and forwards disabled state', () => {
+ const callback = vi.fn();
+ const { root } = createPage({
+ hasHeader: true,
+ title: 'Title',
+ headerButtons: [
+ { name: 'arrow-left', fill: 'no-fill', ariaLabel: 'Action', callback },
+ {
+ name: 'arrow-left',
+ fill: 'no-fill',
+ ariaLabel: 'Disabled',
+ disabled: true,
+ callback: vi.fn(),
+ },
+ ],
+ });
+ const buttons = root.querySelectorAll('ui-button');
+ expect((buttons[1] as HTMLElement).shadowRoot!.querySelector('ion-button')!.disabled).toBe(
+ true,
+ );
+ (buttons[0] as HTMLElement).click();
+ expect(callback).toHaveBeenCalledOnce();
+ });
+
+ describe('scroll elevations', () => {
+ it('adds and removes header/footer elevation according to scroll position', async () => {
+ const { element, state } = createScrollable();
+ vi.mocked(IonContent.prototype.getScrollElement).mockResolvedValue(element);
+ const { fixture } = createPage({ hasHeader: true, title: 'Title', hasFooter: true });
+ await flush();
+ expect(fixture.componentInstance.isHeaderElevated()).toBe(false);
+ expect(fixture.componentInstance.isFooterElevated()).toBe(true);
+ state.scrollTop = 50;
+ fixture.componentInstance.onContentScroll();
+ expect(fixture.componentInstance.isHeaderElevated()).toBe(true);
+ expect(fixture.componentInstance.isFooterElevated()).toBe(true);
+ state.scrollTop = 200;
+ fixture.componentInstance.onContentScroll();
+ expect(fixture.componentInstance.isHeaderElevated()).toBe(true);
+ expect(fixture.componentInstance.isFooterElevated()).toBe(false);
+ state.scrollHeight = 100;
+ state.scrollTop = 0;
+ fixture.componentInstance.onContentScroll();
+ expect(fixture.componentInstance.isHeaderElevated()).toBe(false);
+ expect(fixture.componentInstance.isFooterElevated()).toBe(false);
+ });
+
+ it('keeps elevation off before the asynchronous scroll element is available', () => {
+ const pending = new Promise(() => undefined);
+ vi.mocked(IonContent.prototype.getScrollElement).mockReturnValue(pending);
+ const { fixture } = createPage({ hasHeader: true, title: 'Title', hasFooter: true });
+ fixture.componentInstance.isHeaderElevated.set(true);
+ fixture.componentInstance.isFooterElevated.set(true);
+ fixture.componentInstance.onContentScroll();
+ expect(fixture.componentInstance.isHeaderElevated()).toBe(false);
+ expect(fixture.componentInstance.isFooterElevated()).toBe(false);
+ });
+
+ it('observes both scroll viewport and content surface, then disconnects', async () => {
+ const { element, state } = createScrollable();
+ vi.mocked(IonContent.prototype.getScrollElement).mockResolvedValue(element);
+ const observe = vi.fn();
+ const disconnect = vi.fn();
+ let notifyResize!: () => void;
+ vi.stubGlobal(
+ 'ResizeObserver',
+ class {
+ constructor(callback: () => void) {
+ notifyResize = callback;
+ }
+ observe = observe;
+ disconnect = disconnect;
+ },
+ );
+ const { fixture, root } = createPage({ screenReaderTitle: 'Page' });
+ await flush();
+ expect(observe).toHaveBeenCalledWith(element);
+ expect(observe).toHaveBeenCalledWith(root.querySelector('.page-content-surface'));
+ state.scrollTop = 10;
+ notifyResize();
+ expect(fixture.componentInstance.isHeaderElevated()).toBe(false);
+ fixture.destroy();
+ expect(disconnect).toHaveBeenCalledOnce();
+ });
+
+ it('stops initialization safely when the page is destroyed before content resolves', async () => {
+ let resolveScroll!: (element: HTMLElement) => void;
+ const pending = new Promise((resolve) => (resolveScroll = resolve));
+ vi.mocked(IonContent.prototype.getScrollElement).mockReturnValue(pending);
+ const { fixture } = createPage({ screenReaderTitle: 'Page' });
+ fixture.destroy();
+ resolveScroll(document.createElement('div'));
+ await flush();
+ expect(fixture.componentInstance.isHeaderElevated()).toBe(false);
+ expect(fixture.componentInstance.isFooterElevated()).toBe(false);
+ });
+ });
+
+ it.each([
+ [PageHost, false, 'Question'],
+ [FooterPageHost, false, 'Question'],
+ [LegacyPageHost, true, 'Question'],
+ ])('supports isolated and native-slot body projection', async (hostType, legacy, text) => {
+ TestBed.configureTestingModule({
+ imports: [hostType],
+ providers: [
+ provideZonelessChangeDetection(),
+ provideRouter([]),
+ provideIonicAngular({ animated: false }),
+ ],
+ });
+ const fixture = TestBed.createComponent(hostType);
+ fixture.detectChanges();
+ const page = fixture.nativeElement.querySelector('ui-page') as HTMLElement;
+ const root: ParentNode = legacy ? page : page.shadowRoot!;
+ expect(root.querySelector('.body')?.textContent).toBe(text);
+ expect(page.shadowRoot!.querySelector('slot') !== null).toBe(legacy);
+ if (hostType !== PageHost) {
+ expect(page.shadowRoot!.querySelector('ion-footer [footer]')?.textContent).toBe('Footer');
+ }
+ if (legacy) {
+ expect((page.shadowRoot!.querySelector('slot') as HTMLSlotElement).assignedNodes()).toContain(
+ page.querySelector('.body'),
+ );
+ }
+ await flush();
+ });
+
+ it('binds Ionic lifecycle events to the routed wrapper, including through a shadow root', () => {
+ const services = createServices();
+ configurePage(services);
+ const fixture = TestBed.createComponent(UIPage);
+ setInputs(fixture, { screenReaderTitle: 'Page' });
+ const nav = document.createElement('ion-nav');
+ const routedView = document.createElement('section');
+ const shadowHost = document.createElement('article');
+ const nestedRoot = shadowHost.attachShadow({ mode: 'open' });
+ nestedRoot.append(fixture.nativeElement);
+ routedView.append(shadowHost);
+ nav.append(routedView);
+ document.body.append(nav);
+ fixture.detectChanges();
+ routedView.dispatchEvent(new CustomEvent('ionViewDidEnter'));
+ expect(services.statusBar.setStatusBarStyleLight).toHaveBeenCalledOnce();
+ fixture.destroy();
+ nav.remove();
+ });
+
+ it('skips browser lifecycle binding on the server platform', () => {
+ const services = createServices();
+ TestBed.configureTestingModule({
+ imports: [UIPage],
+ providers: [
+ provideZonelessChangeDetection(),
+ provideRouter([]),
+ provideIonicAngular({ animated: false }),
+ { provide: PLATFORM_ID, useValue: 'server' },
+ { provide: ScreenReaderService, useValue: services.screenReader },
+ { provide: StatusBarService, useValue: services.statusBar },
+ ],
+ });
+ const fixture = TestBed.createComponent(UIPage);
+ setInputs(fixture, { screenReaderTitle: 'Server page' });
+ expect(() => fixture.detectChanges()).not.toThrow();
+ });
+});
diff --git a/projects/ui-kit/src/lib/components/progress-bar/ui-progress-bar.spec.ts b/projects/ui-kit/src/lib/components/progress-bar/ui-progress-bar.spec.ts
new file mode 100644
index 0000000..aa372f5
--- /dev/null
+++ b/projects/ui-kit/src/lib/components/progress-bar/ui-progress-bar.spec.ts
@@ -0,0 +1,47 @@
+import { TestBed } from '@angular/core/testing';
+import { configureComponent, render, setInputs, shadow } from '../../../../../../testing/helpers';
+import { UIProgressBar } from './ui-progress-bar';
+
+describe('UIProgressBar', () => {
+ it('defaults to zero progress and exposes the accessible name', () => {
+ const fixture = render(UIProgressBar, { ariaLabel: 'Registration' });
+ const progress = shadow(fixture).querySelector('ion-progress-bar')!;
+ expect(progress.value).toBe(0);
+ expect(progress.color).toBe('tertiary');
+ expect(progress.getAttribute('aria-label')).toBe('Registration');
+ expect(progress.getAttribute('aria-hidden')).toBe('false');
+ expect(progress.hasAttribute('aria-valuetext')).toBe(false);
+ });
+
+ it('forwards changing values and readable step descriptions', () => {
+ const fixture = render(UIProgressBar, {
+ ariaLabel: 'Registration',
+ value: 0.5,
+ ariaValueText: '2 of 4',
+ });
+ const progress = shadow(fixture).querySelector('ion-progress-bar')!;
+ expect(progress.value).toBe(0.5);
+ expect(progress.getAttribute('aria-valuetext')).toBe('2 of 4');
+ setInputs(fixture, { value: 1, ariaValueText: '4 of 4', ariaLabel: 'Complete' });
+ fixture.detectChanges();
+ expect(progress.value).toBe(1);
+ expect(progress.getAttribute('aria-valuetext')).toBe('4 of 4');
+ expect(progress.getAttribute('aria-label')).toBe('Complete');
+ });
+
+ it.each([undefined, '', ' '])('rejects missing accessible labels (%j)', (ariaLabel) => {
+ configureComponent(UIProgressBar);
+ const fixture = TestBed.createComponent(UIProgressBar);
+ setInputs(fixture, { ariaLabel });
+ expect(() => fixture.detectChanges()).toThrow('[UIProgressBar] ariaLabel is required');
+ });
+
+ it('allows decorative progress without a label and coerces ariaHidden', () => {
+ const fixture = render(UIProgressBar, { ariaHidden: '' });
+ const progress = shadow(fixture).querySelector('ion-progress-bar')!;
+ expect(progress.getAttribute('aria-hidden')).toBe('true');
+ setInputs(fixture, { ariaHidden: 'false', ariaLabel: 'Progress' });
+ fixture.detectChanges();
+ expect(progress.getAttribute('aria-hidden')).toBe('false');
+ });
+});
diff --git a/projects/ui-kit/src/lib/components/row/ui-row.spec.ts b/projects/ui-kit/src/lib/components/row/ui-row.spec.ts
new file mode 100644
index 0000000..9365dfe
--- /dev/null
+++ b/projects/ui-kit/src/lib/components/row/ui-row.spec.ts
@@ -0,0 +1,40 @@
+import { Component } from '@angular/core';
+import { render, setInputs, shadow } from '../../../../../../testing/helpers';
+import { UIRow } from './ui-row';
+
+@Component({ imports: [UIRow], template: 'Content' })
+class RowHost {}
+
+@Component({ imports: [UIRow], template: 'Legacy' })
+class LegacyRowHost {}
+
+describe('UIRow', () => {
+ it('exposes default and changing spacing tokens as data attributes', () => {
+ const fixture = render(UIRow);
+ const row = shadow(fixture).querySelector('ion-row')!;
+ expect(row.getAttribute('data-top')).toBe('none');
+ expect(row.getAttribute('data-bottom')).toBe('none');
+ expect(row.getAttribute('data-gap')).toBe('none');
+ setInputs(fixture, { top: 'l', bottom: 'm', gap: 'xxxl' });
+ fixture.detectChanges();
+ expect(row.getAttribute('data-top')).toBe('l');
+ expect(row.getAttribute('data-bottom')).toBe('m');
+ expect(row.getAttribute('data-gap')).toBe('xxxl');
+ });
+
+ it.each([
+ [RowHost, false, 'Content'],
+ [LegacyRowHost, true, 'Legacy'],
+ ])('supports isolated and native-slot projection', (hostType, legacy, text) => {
+ const fixture = render(hostType);
+ const host = fixture.nativeElement.querySelector('ui-row') as HTMLElement;
+ const root: ParentNode = legacy ? host : host.shadowRoot!;
+ const content = root.querySelector('span')!;
+ expect(content.textContent).toBe(text);
+ expect(host.shadowRoot!.querySelector('slot') !== null).toBe(legacy);
+ if (legacy)
+ expect((host.shadowRoot!.querySelector('slot') as HTMLSlotElement).assignedNodes()).toContain(
+ content,
+ );
+ });
+});
diff --git a/projects/ui-kit/src/lib/components/text/ui-text.spec.ts b/projects/ui-kit/src/lib/components/text/ui-text.spec.ts
new file mode 100644
index 0000000..2afa75c
--- /dev/null
+++ b/projects/ui-kit/src/lib/components/text/ui-text.spec.ts
@@ -0,0 +1,31 @@
+import { Component } from '@angular/core';
+import { render, setInputs, shadow } from '../../../../../../testing/helpers';
+import { UIText } from './ui-text';
+
+@Component({ imports: [UIText], template: 'Hello world' })
+class TextHost {}
+
+describe('UIText', () => {
+ it('defaults to inherited typography and default-grey without losing projected markup', () => {
+ const fixture = render(TextHost);
+ const host = fixture.nativeElement.querySelector('ui-text') as HTMLElement;
+ expect(host.getAttribute('data-variant')).toBe('inherit');
+ const text = host.shadowRoot!.querySelector('ion-text')!;
+ expect(text.color).toBe('default-grey');
+ expect(text.textContent).toBe('Hello world');
+ expect(text.querySelector('strong')?.textContent).toBe('world');
+ });
+
+ it.each(['inherit', 'body-l', 'body-m', 'body-s', 'label-l', 'label-m', 'label-s'])(
+ 'exposes %s for styling and reactively forwards color',
+ (variant) => {
+ const fixture = render(UIText, { variant, color: 'green' });
+ expect(fixture.nativeElement.getAttribute('data-variant')).toBe(variant);
+ expect(shadow(fixture).querySelector('ion-text')!.color).toBe('green');
+ setInputs(fixture, { variant: 'inherit', color: 'white' });
+ fixture.detectChanges();
+ expect(fixture.nativeElement.getAttribute('data-variant')).toBe('inherit');
+ expect(shadow(fixture).querySelector('ion-text')!.color).toBe('white');
+ },
+ );
+});
diff --git a/projects/ui-kit/src/lib/components/title/ui-title.spec.ts b/projects/ui-kit/src/lib/components/title/ui-title.spec.ts
new file mode 100644
index 0000000..7cad90a
--- /dev/null
+++ b/projects/ui-kit/src/lib/components/title/ui-title.spec.ts
@@ -0,0 +1,55 @@
+import { Component } from '@angular/core';
+import { TestBed } from '@angular/core/testing';
+import { configureComponent, render, setInputs, shadow } from '../../../../../../testing/helpers';
+import { UITitle } from './ui-title';
+
+@Component({ imports: [UITitle], template: 'Section title' })
+class TitleHost {}
+
+describe('UITitle', () => {
+ it('defaults to h2 with projected content and default-grey text', () => {
+ const fixture = render(TitleHost);
+ const root = fixture.nativeElement.querySelector('ui-title').shadowRoot as ShadowRoot;
+ expect(root.querySelector('h2')?.textContent).toContain('Section title');
+ expect(root.querySelector('h2 ion-text')?.getAttribute('mode')).toBe('ios');
+ expect(root.querySelector('ion-text')!.color).toBe('default-grey');
+ expect(root.querySelector('em')?.textContent).toBe('title');
+ });
+
+ it.each([
+ ['xl', 'h1'],
+ ['l', 'h2'],
+ ['m', 'h3'],
+ ['s', 'h4'],
+ ])('maps size %s to %s and forwards color and a trimmed accessible name', (size, tag) => {
+ const fixture = render(UITitle, { size, color: 'green', ariaLabel: ' Accessible title ' });
+ const heading = shadow(fixture).querySelector(tag)!;
+ expect(heading.getAttribute('aria-label')).toBe('Accessible title');
+ expect(heading.hasAttribute('aria-hidden')).toBe(false);
+ expect(shadow(fixture).querySelector('ion-text')!.color).toBe('green');
+ setInputs(fixture, { size: 's', ariaLabel: ' ' });
+ fixture.detectChanges();
+ expect(shadow(fixture).querySelector('h4')!.hasAttribute('aria-label')).toBe(false);
+ });
+
+ it.each([undefined, '', ' '])('does not impose a redundant label (%j)', (ariaLabel) => {
+ const fixture = render(UITitle, { ariaLabel });
+ expect(shadow(fixture).querySelector('h2')!.hasAttribute('aria-label')).toBe(false);
+ });
+
+ it('coerces ariaHidden and updates the heading accessibility', () => {
+ const fixture = render(UITitle, { ariaHidden: '' });
+ expect(shadow(fixture).querySelector('h2')!.getAttribute('aria-hidden')).toBe('true');
+ setInputs(fixture, { ariaHidden: 'false', ariaLabel: 'New title' });
+ fixture.detectChanges();
+ expect(shadow(fixture).querySelector('h2')!.hasAttribute('aria-hidden')).toBe(false);
+ expect(shadow(fixture).querySelector('h2')!.getAttribute('aria-label')).toBe('New title');
+ });
+
+ it('rejects a hidden heading with a nonempty accessible label', () => {
+ configureComponent(UITitle);
+ const fixture = TestBed.createComponent(UITitle);
+ setInputs(fixture, { ariaHidden: true, ariaLabel: 'Title' });
+ expect(() => fixture.detectChanges()).toThrow('[UITitle] ariaLabel cannot be used');
+ });
+});
diff --git a/projects/ui-kit/src/lib/services/screen-reader.service.spec.ts b/projects/ui-kit/src/lib/services/screen-reader.service.spec.ts
new file mode 100644
index 0000000..c7317f9
--- /dev/null
+++ b/projects/ui-kit/src/lib/services/screen-reader.service.spec.ts
@@ -0,0 +1,160 @@
+import { Capacitor } from '@capacitor/core';
+import type { PluginListenerHandle } from '@capacitor/core';
+import { deferred } from '../../../../../testing/helpers';
+import { ScreenReaderService } from './screen-reader.service';
+
+const screenReaderMocks = vi.hoisted(() => ({ isEnabled: vi.fn(), addListener: vi.fn() }));
+
+vi.mock('@capacitor/screen-reader', () => ({ ScreenReader: screenReaderMocks }));
+
+const listenerHandle: PluginListenerHandle = { remove: vi.fn(async () => undefined) };
+
+async function flush(): Promise {
+ await Promise.resolve();
+ await Promise.resolve();
+}
+
+describe('ScreenReaderService', () => {
+ beforeEach(() => {
+ screenReaderMocks.isEnabled.mockReset();
+ screenReaderMocks.addListener.mockReset();
+ });
+ it('starts in a known disabled state and skips native observation on web', async () => {
+ vi.spyOn(Capacitor, 'getPlatform').mockReturnValue('web');
+ const service = new ScreenReaderService();
+ expect(service.screenState()).toBe(false);
+ expect(service.screenStateKnown()).toBe(true);
+ expect(screenReaderMocks.isEnabled).not.toHaveBeenCalled();
+ expect(screenReaderMocks.addListener).not.toHaveBeenCalled();
+
+ const element = document.createElement('h1');
+ const focus = vi.spyOn(element, 'focus');
+ service.focus(element);
+ await flush();
+ expect(focus).not.toHaveBeenCalled();
+ });
+
+ it('loads the initial native state and follows stateChange events', async () => {
+ vi.spyOn(Capacitor, 'getPlatform').mockReturnValue('ios');
+ screenReaderMocks.isEnabled.mockResolvedValue({ value: true });
+ let stateChange: ((event: { value: boolean }) => void) | undefined;
+ screenReaderMocks.addListener.mockImplementation(
+ (_event: string, listener: (event: { value: boolean }) => void) => {
+ stateChange = listener;
+ return Promise.resolve(listenerHandle);
+ },
+ );
+ const service = new ScreenReaderService();
+ expect(service.screenStateKnown()).toBe(false);
+ await flush();
+ expect(service.screenState()).toBe(true);
+ expect(service.screenStateKnown()).toBe(true);
+ stateChange?.({ value: false });
+ expect(service.screenState()).toBe(false);
+ expect(service.screenStateKnown()).toBe(true);
+ });
+
+ it('keeps a newer listener state when the initial request resolves late', async () => {
+ vi.spyOn(Capacitor, 'getPlatform').mockReturnValue('android');
+ const initial = deferred<{ value: boolean }>();
+ screenReaderMocks.isEnabled.mockReturnValue(initial.promise);
+ screenReaderMocks.addListener.mockImplementation(
+ (_event: string, listener: (event: { value: boolean }) => void) => {
+ listener({ value: true });
+ return Promise.resolve(listenerHandle);
+ },
+ );
+ const service = new ScreenReaderService();
+ expect(service.screenState()).toBe(true);
+ initial.resolve({ value: false });
+ await flush();
+ expect(service.screenState()).toBe(true);
+ });
+
+ it('does not mark state unknown when an obsolete initial request rejects', async () => {
+ vi.spyOn(Capacitor, 'getPlatform').mockReturnValue('android');
+ const initial = deferred<{ value: boolean }>();
+ screenReaderMocks.isEnabled.mockReturnValue(initial.promise);
+ screenReaderMocks.addListener.mockImplementation(
+ (_event: string, listener: (event: { value: boolean }) => void) => {
+ listener({ value: true });
+ return Promise.resolve(listenerHandle);
+ },
+ );
+ const service = new ScreenReaderService();
+ initial.reject(new Error('stale failure'));
+ await flush();
+ expect(service.screenState()).toBe(true);
+ expect(service.screenStateKnown()).toBe(true);
+ });
+
+ it('leaves the state unknown when initial native detection fails', async () => {
+ vi.spyOn(Capacitor, 'getPlatform').mockReturnValue('ios');
+ screenReaderMocks.isEnabled.mockRejectedValue(new Error('unavailable'));
+ screenReaderMocks.addListener.mockResolvedValue(listenerHandle);
+ const service = new ScreenReaderService();
+ await flush();
+ expect(service.screenState()).toBe(false);
+ expect(service.screenStateKnown()).toBe(false);
+ });
+
+ it('continues using the initial state when listener registration fails', async () => {
+ vi.spyOn(Capacitor, 'getPlatform').mockReturnValue('ios');
+ screenReaderMocks.isEnabled.mockResolvedValue({ value: true });
+ screenReaderMocks.addListener.mockRejectedValue(new Error('unavailable'));
+ const service = new ScreenReaderService();
+ await flush();
+ expect(service.screenState()).toBe(true);
+ expect(service.screenStateKnown()).toBe(true);
+ });
+
+ it('focuses a connected element without scrolling when screen reader is enabled', async () => {
+ vi.spyOn(Capacitor, 'getPlatform').mockReturnValue('ios');
+ screenReaderMocks.isEnabled.mockResolvedValue({ value: true });
+ screenReaderMocks.addListener.mockResolvedValue(listenerHandle);
+ const service = new ScreenReaderService();
+ const element = document.createElement('h1');
+ document.body.append(element);
+ const focus = vi.spyOn(element, 'focus');
+ service.focus(element);
+ await flush();
+ expect(focus).toHaveBeenCalledWith({ preventScroll: true });
+ element.remove();
+ });
+
+ it.each([false, true])(
+ 'does not focus when cancellation=%s prevents the request',
+ async (cancel) => {
+ vi.spyOn(Capacitor, 'getPlatform').mockReturnValue('ios');
+ const focusState = deferred<{ value: boolean }>();
+ screenReaderMocks.isEnabled
+ .mockResolvedValueOnce({ value: false })
+ .mockReturnValueOnce(focusState.promise);
+ screenReaderMocks.addListener.mockResolvedValue(listenerHandle);
+ const service = new ScreenReaderService();
+ const element = document.createElement('h1');
+ if (cancel) document.body.append(element);
+ const focus = vi.spyOn(element, 'focus');
+ const cancelRequest = service.focus(element);
+ if (cancel) cancelRequest();
+ focusState.resolve({ value: true });
+ await flush();
+ expect(focus).not.toHaveBeenCalled();
+ element.remove();
+ },
+ );
+
+ it('does not focus when native state lookup fails', async () => {
+ vi.spyOn(Capacitor, 'getPlatform').mockReturnValue('ios');
+ screenReaderMocks.isEnabled.mockRejectedValue(new Error('unavailable'));
+ screenReaderMocks.addListener.mockResolvedValue(listenerHandle);
+ const service = new ScreenReaderService();
+ const element = document.createElement('h1');
+ document.body.append(element);
+ const focus = vi.spyOn(element, 'focus');
+ service.focus(element);
+ await flush();
+ expect(focus).not.toHaveBeenCalled();
+ element.remove();
+ });
+});
diff --git a/projects/ui-kit/src/lib/services/status-bar.service.spec.ts b/projects/ui-kit/src/lib/services/status-bar.service.spec.ts
new file mode 100644
index 0000000..c042207
--- /dev/null
+++ b/projects/ui-kit/src/lib/services/status-bar.service.spec.ts
@@ -0,0 +1,63 @@
+import { Capacitor } from '@capacitor/core';
+import { Style } from '@capacitor/status-bar';
+import { StatusBarService } from './status-bar.service';
+
+const statusBarMocks = vi.hoisted(() => ({
+ setStyle: vi.fn(),
+ getInfo: vi.fn(),
+ hide: vi.fn(),
+ show: vi.fn(),
+}));
+
+vi.mock('@capacitor/status-bar', () => ({
+ Style: { Dark: 'DARK', Light: 'LIGHT' },
+ StatusBar: statusBarMocks,
+}));
+
+describe('StatusBarService', () => {
+ beforeEach(() => {
+ Object.values(statusBarMocks).forEach((mock) => mock.mockReset());
+ });
+ it('does not call native APIs on the web', async () => {
+ vi.spyOn(Capacitor, 'getPlatform').mockReturnValue('web');
+ statusBarMocks.setStyle.mockResolvedValue(undefined);
+ const service = new StatusBarService();
+
+ await service.setStatusBarStyleDark();
+ await service.setStatusBarStyleLight();
+ await service.hideStatusBar();
+ await service.showStatusBar();
+ expect(await service.getInfo()).toEqual({});
+ expect(statusBarMocks.setStyle).not.toHaveBeenCalled();
+ expect(statusBarMocks.getInfo).not.toHaveBeenCalled();
+ expect(statusBarMocks.hide).not.toHaveBeenCalled();
+ expect(statusBarMocks.show).not.toHaveBeenCalled();
+ });
+
+ it('delegates every operation to the Capacitor plugin on native platforms', async () => {
+ vi.spyOn(Capacitor, 'getPlatform').mockReturnValue('ios');
+ statusBarMocks.setStyle.mockResolvedValue(undefined);
+ const info = { visible: true, style: Style.Dark, color: '#000000', overlays: false };
+ statusBarMocks.getInfo.mockResolvedValue(info);
+ statusBarMocks.hide.mockResolvedValue(undefined);
+ statusBarMocks.show.mockResolvedValue(undefined);
+ const service = new StatusBarService();
+
+ await service.setStatusBarStyleDark();
+ await service.setStatusBarStyleLight();
+ await service.hideStatusBar();
+ await service.showStatusBar();
+ expect(await service.getInfo()).toBe(info);
+ expect(statusBarMocks.setStyle).toHaveBeenNthCalledWith(1, { style: Style.Dark });
+ expect(statusBarMocks.setStyle).toHaveBeenNthCalledWith(2, { style: Style.Light });
+ expect(statusBarMocks.hide).toHaveBeenCalledOnce();
+ expect(statusBarMocks.show).toHaveBeenCalledOnce();
+ expect(statusBarMocks.getInfo).toHaveBeenCalledOnce();
+ });
+
+ it('propagates native failures to the caller', async () => {
+ vi.spyOn(Capacitor, 'getPlatform').mockReturnValue('android');
+ statusBarMocks.setStyle.mockRejectedValue(new Error('unavailable'));
+ await expect(new StatusBarService().setStatusBarStyleDark()).rejects.toThrow('unavailable');
+ });
+});
diff --git a/projects/ui-kit/tsconfig.spec.json b/projects/ui-kit/tsconfig.spec.json
index 48fcc2f..f9ed9de 100644
--- a/projects/ui-kit/tsconfig.spec.json
+++ b/projects/ui-kit/tsconfig.spec.json
@@ -4,7 +4,8 @@
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "../../out-tsc/spec",
- "types": ["vitest/globals"]
+ "types": ["vitest/globals", "node"],
+ "sourceMap": true
},
- "include": ["src/**/*.d.ts", "src/**/*.spec.ts"]
+ "include": ["src/**/*.d.ts", "src/**/*.spec.ts", "../../testing/**/*.ts"]
}
diff --git a/sonar-project.properties b/sonar-project.properties
new file mode 100644
index 0000000..cf77319
--- /dev/null
+++ b/sonar-project.properties
@@ -0,0 +1,6 @@
+sonar.projectKey=BePower_ui-kit
+sonar.organization=bepower
+sonar.sources=projects/ui-kit/src
+sonar.exclusions=**/*.spec.ts,projects/ui-kit/src/lib/components/icon/icons/**
+sonar.javascript.lcov.reportPaths=coverage/lcov.info
+sonar.sourceEncoding=UTF-8
diff --git a/testing/helpers.ts b/testing/helpers.ts
new file mode 100644
index 0000000..77542a5
--- /dev/null
+++ b/testing/helpers.ts
@@ -0,0 +1,46 @@
+import { type Type, provideZonelessChangeDetection } from '@angular/core';
+import { TestBed, type ComponentFixture } from '@angular/core/testing';
+import { provideRouter } from '@angular/router';
+import { provideIonicAngular } from '@ionic/angular/standalone';
+
+export function configureComponent(component: Type): void {
+ TestBed.configureTestingModule({
+ imports: [component],
+ providers: [
+ provideZonelessChangeDetection(),
+ provideRouter([]),
+ provideIonicAngular({ animated: false }),
+ ],
+ });
+}
+
+export function setInputs(fixture: ComponentFixture, inputs: Record): void {
+ for (const [name, value] of Object.entries(inputs)) {
+ fixture.componentRef.setInput(name, value);
+ }
+}
+
+export function render(
+ component: Type,
+ inputs: Record = {},
+): ComponentFixture {
+ configureComponent(component);
+ const fixture = TestBed.createComponent(component);
+ setInputs(fixture, inputs);
+ fixture.detectChanges();
+ return fixture;
+}
+
+export function shadow(fixture: ComponentFixture): ShadowRoot {
+ return (fixture.nativeElement as HTMLElement).shadowRoot!;
+}
+
+export function deferred() {
+ let resolve!: (value: T) => void;
+ let reject!: (reason?: unknown) => void;
+ const promise = new Promise((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise;
+ reject = rejectPromise;
+ });
+ return { promise, resolve, reject };
+}
diff --git a/testing/setup.ts b/testing/setup.ts
new file mode 100644
index 0000000..b032b2d
--- /dev/null
+++ b/testing/setup.ts
@@ -0,0 +1,27 @@
+import '@angular/compiler';
+
+import { TestBed } from '@angular/core/testing';
+import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
+
+TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting(), {
+ teardown: { destroyAfterEach: true },
+ errorOnUnknownElements: true,
+ errorOnUnknownProperties: true,
+});
+
+beforeEach(() => {
+ vi.stubGlobal('matchMedia', (query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ }));
+});
+
+afterEach(() => {
+ TestBed.resetTestingModule();
+});
diff --git a/vitest.config.mts b/vitest.config.mts
new file mode 100644
index 0000000..7532701
--- /dev/null
+++ b/vitest.config.mts
@@ -0,0 +1,36 @@
+import angular from '@analogjs/vite-plugin-angular';
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ // Transform source files for TestBed in memory. Never build or resolve dist/ui-kit.
+ plugins: [angular({ tsconfig: 'projects/ui-kit/tsconfig.spec.json', jit: true })],
+ css: {
+ preprocessorOptions: {
+ scss: { loadPaths: ['projects/ui-kit/src/lib'] },
+ },
+ },
+ test: {
+ environment: 'jsdom',
+ globals: true,
+ setupFiles: ['testing/setup.ts'],
+ include: ['projects/ui-kit/src/**/*.spec.ts'],
+ restoreMocks: true,
+ unstubGlobals: true,
+ maxWorkers: 2,
+ server: { deps: { inline: [/@ionic\//, /@stencil\//] } },
+ coverage: {
+ provider: 'v8',
+ include: ['projects/ui-kit/src/lib/**/*.ts'],
+ exclude: [
+ '**/*.spec.ts',
+ '**/*.stories.ts',
+ '**/interfaces/**',
+ '**/*.interface.ts',
+ '**/icons/**',
+ ],
+ reporter: ['text', 'html', 'lcov', 'json-summary'],
+ reportsDirectory: 'coverage',
+ thresholds: { lines: 100, statements: 100, functions: 100, branches: 100 },
+ },
+ },
+});