diff --git a/.github/workflows/cps-shared-ui-checkers.yml b/.github/workflows/cps-shared-ui-checkers.yml index dfcf7c47b..499c9dcee 100644 --- a/.github/workflows/cps-shared-ui-checkers.yml +++ b/.github/workflows/cps-shared-ui-checkers.yml @@ -155,6 +155,24 @@ jobs: job-summary: true custom-info: ${{ steps.artifact-links.outputs.msg }} + pa11y: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22.x + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run pa11y accessibility checks + run: npm run test:pa11y:ci + generate-api-data: runs-on: ubuntu-latest steps: diff --git a/.pa11yci b/.pa11yci index d104faa6c..0ee741d75 100644 --- a/.pa11yci +++ b/.pa11yci @@ -3,8 +3,19 @@ "standard": "WCAG2AA", "timeout": 30000, "wait": 1000, - "runners": ["axe"], - "hideElements": ".cps-loader", + "runners": [ + "axe" + ], + "rules": [ + "target-size" + ], + "chromeLaunchConfig": { + "args": [ + "--no-sandbox", + "--disable-setuid-sandbox" + ] + }, + "hideElements": ".cps-loader-overlay-content-text", "reporter": "cli", "level": "error" }, diff --git a/README.md b/README.md index 7702e225d..732655b30 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,10 @@ This repository consists of two projects: - `cps-ui-kit` - shared components library itself - `composition` - application for previewing compositions of components consumed from the library +#### Accessibility + +This library's components and the composition app are tested for WCAG 2.2 AA compliance both with automated tooling ([axe-core](https://github.com/dequelabs/axe-core), via a Playwright test suite and pa11y-ci) and manual accessibility review. See [Run accessibility tests](#run-accessibility-tests) for how to run the automated checks yourself. + #### Available components - Autocomplete @@ -104,13 +108,13 @@ Versioning and the changelog are driven by [Conventional Commits](https://www.co **Type** determines the version bump and changelog section: -| Type | Bump | Changelog | -| ------------------------------------------------------------- | --------- | -------------------------- | -| `feat` | **minor** | Features | -| `fix` | **patch** | Bug Fixes | -| `perf` | **patch** | Performance Improvements | -| `feat!` / `fix!` / `BREAKING CHANGE:` footer | **major** | Breaking section | -| `docs`, `style`, `refactor`, `test`, `build`, `ci`, `chore` | none | hidden | +| Type | Bump | Changelog | +| ----------------------------------------------------------- | --------- | ------------------------ | +| `feat` | **minor** | Features | +| `fix` | **patch** | Bug Fixes | +| `perf` | **patch** | Performance Improvements | +| `feat!` / `fix!` / `BREAKING CHANGE:` footer | **major** | Breaking section | +| `docs`, `style`, `refactor`, `test`, `build`, `ci`, `chore` | none | hidden | **Scope** _(optional)_ is the component the change targets, written as the component name **without the `cps-` prefix**. It only affects how the entry is grouped/labelled in the changelog (this is a single package, so the scope does not change versioning). Use the component name from the list above: @@ -140,9 +144,21 @@ The current released version is tracked in [`.release-please-manifest.json`](.re #### Run accessibility tests -The project uses [pa11y-ci](https://github.com/pa11y/pa11y-ci) to test all components for WCAG 2.0 AA compliance. +Accessibility is covered by two complementary tools, run separately and by different CI jobs: + +**Playwright + axe-core** — scans individual `cps-ui-kit` components (in isolation) and the full `composition` app (pages, shell, interactive states) against WCAG 2.0/2.1/2.2 A/AA + best-practice rules. + +```bash +npm run test:playwright:accessibility # everything (cps-ui-kit + composition) +npm run test:playwright:cps-ui-kit:accessibility # cps-ui-kit components only +npm run test:playwright:composition:accessibility # composition app only +``` + +These run as part of the `playwright` CI job. See [playwright/README.md](playwright/README.md) for full details, including how component/page entries are structured. + +**pa11y-ci** — scans all 33 composition demo pages (one per component) against WCAG 2.0 AA, using axe-core as its underlying test engine. This is a separate, independent check from the Playwright one above and runs as its own `pa11y` CI job. -To run accessibility tests: +To run it manually: 1. Start the development server: @@ -152,22 +168,20 @@ To run accessibility tests: 2. In a separate terminal, run the accessibility tests: ```bash - npm run test:a11y + npm run test:pa11y ``` -Alternatively, use the combined script that starts the server and runs tests: +Alternatively, use the combined script that starts the server and shows a colorful summary with statistics: ```bash -npm run test:a11y:local +npm run test:pa11y:local ``` -For a colorful summary with statistics: +`npm run test:pa11y:summary` produces that same summary, but assumes the server is already running. -```bash -npm run test:a11y:summary -``` +`npm run test:pa11y:ci` is the CI equivalent of `test:pa11y:local` — it starts the server and runs the plain `test:pa11y` reporter (full per-URL violation output) instead of the summary. This is what the `pa11y` CI job runs. -This will display: +The summary variants display: - Total URLs tested with pass/fail ratio - Total accessibility errors found @@ -175,4 +189,4 @@ This will display: - Test engine (axe-core via pa11y-ci) - Top 10 components with the most issues -The tests will check all 33 components for accessibility issues and report any violations found. +Both `test:pa11y` and `test:pa11y:ci` fail (non-zero exit code) if any accessibility error is found on any page. diff --git a/package.json b/package.json index 82cae83a3..9c16fc5bd 100644 --- a/package.json +++ b/package.json @@ -15,9 +15,10 @@ "lint": "eslint \"**/*.ts\"", "typecheck": "tsc --noEmit", "generate-json-api": "node ./api-generator/api-generator.js", - "test:a11y": "pa11y-ci --threshold 1000", - "test:a11y:summary": "pa11y-ci --threshold 1000 --reporter=json > .pa11y-temp.json 2>/dev/null && jq -C '{\"Total URLs tested\": .total, \"Passed\": \"\\(.passes)/\\(.total)\", \"Total errors found\": .errors, \"Standard\": \"WCAG 2.0 AA\", \"Test engine\": \"axe-core via pa11y-ci\", \"Top 10 components with errors\": (.results | to_entries | map({component: (.key | split(\"/\") | .[-2]), errors: .value | length}) | sort_by(-.errors) | .[0:10])}' .pa11y-temp.json && rm -f .pa11y-temp.json", - "test:a11y:local": "npm run start & sleep 10 && npm run test:a11y:summary && kill %1", + "test:pa11y": "pa11y-ci", + "test:pa11y:ci": "npm run start & SERVER_PID=$!; for i in $(seq 1 60); do curl -sf http://localhost:4200 > /dev/null && break; sleep 1; done; npm run test:pa11y; TEST_EXIT=$?; kill $SERVER_PID; exit $TEST_EXIT", + "test:pa11y:summary": "pa11y-ci --reporter=json > .pa11y-temp.json 2>/dev/null && jq -C '{\"Total URLs tested\": .total, \"Passed\": \"\\(.passes)/\\(.total)\", \"Total errors found\": .errors, \"Standard\": \"WCAG 2.0 AA\", \"Test engine\": \"axe-core via pa11y-ci\", \"Top 10 components with errors\": (.results | to_entries | map({component: (.key | split(\"/\") | .[-2]), errors: .value | length}) | map(select(.errors > 0)) | sort_by(-.errors) | .[0:10])}' .pa11y-temp.json && rm -f .pa11y-temp.json", + "test:pa11y:local": "npm run start & SERVER_PID=$!; for i in $(seq 1 60); do curl -sf http://localhost:4200 > /dev/null && break; sleep 1; done; npm run test:pa11y:summary; TEST_EXIT=$?; kill $SERVER_PID; exit $TEST_EXIT", "test:playwright": "playwright test", "test:playwright:headed": "playwright test --headed", "test:playwright:interactive": "playwright test --ui", @@ -27,8 +28,11 @@ "test:playwright:webkit": "playwright test --project=webkit", "test:playwright:webkit:headed": "playwright test --project=webkit --headed", "test:playwright:accessibility": "playwright test --project=accessibility", - "test:playwright:accessibility:matrix": "node playwright/a11y-matrix-generator.js", - "test:playwright:accessibility:matrix:from-cache": "node playwright/a11y-matrix-generator.js --from-file playwright-report/a11y-report.json", + "test:playwright:composition:all": "playwright test playwright/composition --project=chromium --project=webkit --project=accessibility", + "test:playwright:composition:accessibility": "playwright test playwright/composition/accessibility-composition.spec.ts --project=accessibility", + "test:playwright:cps-ui-kit:all": "playwright test playwright/cps-ui-kit --project=chromium --project=webkit --project=accessibility", + "test:playwright:cps-ui-kit:accessibility": "playwright test playwright/cps-ui-kit/accessibility-cps-ui-kit.spec.ts --project=accessibility", + "test:playwright:cps-ui-kit:components": "playwright test playwright/cps-ui-kit/components --project=chromium --project=webkit", "format": "prettier --write \"**/*.{ts,html}\"" }, "private": true, diff --git a/playwright.config.ts b/playwright.config.ts index dbb999369..6a787a1ea 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -45,17 +45,17 @@ export default defineConfig({ projects: [ { name: 'chromium', - testIgnore: /cps-accessibility/, + testIgnore: /accessibility/, use: { ...devices['Desktop Chrome'] } }, { name: 'webkit', - testIgnore: /cps-accessibility/, + testIgnore: /accessibility/, use: { ...devices['Desktop Safari'] } }, { name: 'accessibility', - testMatch: /cps-accessibility/, + testMatch: /accessibility/, use: { ...devices['Desktop Chrome'] } } ], diff --git a/playwright/README.md b/playwright/README.md index 602279d83..bd6328cce 100644 --- a/playwright/README.md +++ b/playwright/README.md @@ -1,36 +1,60 @@ # Playwright E2E Tests -End-to-end tests for the CPS UI Kit components, using [Playwright](https://playwright.dev/). +End-to-end and accessibility tests for the CPS UI Kit components and the composition app, using [Playwright](https://playwright.dev/). ## Structure ``` playwright/ -├── cps-xxx.spec.ts # Components tests -├── fixtures/ # Test fixture files (e.g. expected xlsx exports) -└── tsconfig.json # TypeScript config for editor support +├── cps-ui-kit/ +│ ├── accessibility-cps-ui-kit.spec.ts # axe-core scans, per cps-ui-kit component +│ └── components/ # Functional/behavioral component tests +│ ├── cps-autocomplete.spec.ts +│ ├── cps-scheduler.spec.ts +│ └── cps-table.spec.ts +├── composition/ +│ └── accessibility-composition.spec.ts # axe-core scans across composition app routes +├── fixtures/ +│ ├── axe-helpers.ts # Shared axe-core Playwright fixture + assertion helpers +│ ├── composition-components.ts # Component/page registry used to drive scans +│ └── table_6_fixture.xlsx # Test data (e.g. expected xlsx exports) +└── tsconfig.json # TypeScript config for editor support ``` Tests run against the composition app (`ng serve`) which showcases every component. +Accessibility coverage is split from functional coverage by filename: any spec file with `accessibility` in its name is routed to the dedicated `accessibility` project (see [Browser projects](#browser-projects)); everything else runs under `chromium`/`webkit`. See the root [README's "Run accessibility tests" section](../README.md#run-accessibility-tests) for how this relates to the separate `pa11y-ci` check. + ## Running Tests ```bash -# Headless (all browsers) +# Headless (all projects: chromium, webkit, accessibility) npm run test:playwright -# Single browser +# Single browser (functional tests only, accessibility spec files excluded) npm run test:playwright:chromium npm run test:playwright:webkit # Headed (see the browser) npm run test:playwright:headed +npm run test:playwright:chromium:headed +npm run test:playwright:webkit:headed # Interactive UI mode npm run test:playwright:interactive # View last HTML report npm run test:playwright:report + +# Accessibility scans only (Desktop Chrome, both cps-ui-kit and composition) +npm run test:playwright:accessibility + +# Scoped slices +npm run test:playwright:cps-ui-kit:all # cps-ui-kit: components + accessibility, chromium+webkit+accessibility +npm run test:playwright:cps-ui-kit:accessibility # cps-ui-kit accessibility scans only +npm run test:playwright:cps-ui-kit:components # cps-ui-kit functional component tests only, chromium+webkit +npm run test:playwright:composition:all # composition: accessibility, chromium+webkit+accessibility +npm run test:playwright:composition:accessibility # composition accessibility scans only ``` Playwright auto-starts the dev server (`npm run start`) if it isn't already running. If you already have `ng serve` running on port 4200, it will reuse that. @@ -53,9 +77,9 @@ All config lives in [`playwright.config.ts`](../playwright.config.ts) at the pro | Scope | Value | | ---------------------------------------- | ----- | | Test (`timeout`) | 30 s | -| Action (`actionTimeout`) | 30 s | +| Action (`actionTimeout`) | 5 s | | Assertion (`expect.timeout`) | 5 s | -| Navigation (`navigationTimeout`) | 30 s | +| Navigation (`navigationTimeout`) | 5 s | | Dev server startup (`webServer.timeout`) | 120 s | ### Failure artifacts @@ -72,17 +96,25 @@ These are written to `test-results/` and the HTML report goes to `playwright-rep The GitHub Actions workflow uploads artifacts and posts a PR comment summarising results: -| Artifact / action | Condition | Retention | -| -------------------------- | --------- | --------- | -| HTML report | Always | 10 days | -| Test results (screenshots, videos, traces) | On failure | 10 days | -| PR comment (via `daun/playwright-report-summary`) | Always | — | +| Artifact / action | Condition | Retention | +| ------------------------------------------------- | ---------- | --------- | +| HTML report | Always | 10 days | +| Test results (screenshots, videos, traces) | On failure | 10 days | +| PR comment (via `daun/playwright-report-summary`) | Always | — | The PR comment includes direct download links to the uploaded artifacts. ### Browser projects -Two browser projects are configured: **Chromium** and **WebKit**. Running `npm run test:playwright` executes against both. Use `--project=chromium` (or the npm script shortcuts) to target one. +Three projects are configured in `playwright.config.ts`: + +| Project | Browser | Runs | +| --------------- | -------------- | --------------------------------------------------- | +| `chromium` | Desktop Chrome | Any spec file _without_ `accessibility` in its name | +| `webkit` | Desktop Safari | Any spec file _without_ `accessibility` in its name | +| `accessibility` | Desktop Chrome | Any spec file _with_ `accessibility` in its name | + +Running `npm run test:playwright` executes all three. Accessibility (axe-core) scans only run once, on Chrome — there's no need for cross-browser variation there since axe evaluates rendered DOM/ARIA state, not browser-specific rendering quirks. Functional/behavioral tests run on both `chromium` and `webkit`. Use `--project=` (or the npm script shortcuts above) to target one. ### Web server @@ -111,9 +143,11 @@ Install the [Playwright Test for VS Code](https://marketplace.visualstudio.com/i ## CI -Playwright tests run as a separate job in the GitHub Actions workflow (`.github/workflows/cps-shared-ui-checkers.yml`). On failure, the HTML report and test artifacts (screenshots, videos) are uploaded as workflow artifacts. +Playwright tests run as a separate job in the GitHub Actions workflow (`.github/workflows/cps-shared-ui-checkers.yml`). On failure, the HTML report and test artifacts (screenshots, videos) are uploaded as workflow artifacts. This is a separate job from the `pa11y` one, which runs `pa11y-ci` independently — see the root [README](../README.md#run-accessibility-tests) for how the two relate. ## Writing Tests -- Test files go in `playwright/` and must end with `.spec.ts` +- Functional/behavioral test files go in `playwright/cps-ui-kit/components/` or alongside the relevant app area, and must end with `.spec.ts` +- Accessibility scan files must have `accessibility` in their filename so they're routed to the `accessibility` project — see `accessibility-cps-ui-kit.spec.ts` and `accessibility-composition.spec.ts` for the existing pattern - Put test data files in `playwright/fixtures/` +- Use the `makeAxeBuilder` fixture from `playwright/fixtures/axe-helpers.ts` for any new axe-core scan, so the WCAG tag set (`wcag2a`, `wcag2aa`, `wcag21a`, `wcag21aa`, `wcag22a`, `wcag22aa`, `best-practice`) stays consistent across the suite diff --git a/playwright/a11y-matrix-generator.js b/playwright/a11y-matrix-generator.js deleted file mode 100644 index 95d0f2728..000000000 --- a/playwright/a11y-matrix-generator.js +++ /dev/null @@ -1,240 +0,0 @@ -#!/usr/bin/env node -/* eslint-disable @typescript-eslint/no-var-requires */ - -/** - * Accessibility Test Matrix Generator - * - * Runs Playwright accessibility tests and produces a summary matrix table. - * - * Usage: - * node playwright/a11y-matrix-generator.js # run tests + print table - * node playwright/a11y-matrix-generator.js --from-file # use existing JSON report - * node playwright/a11y-matrix-generator.js --help - */ - -const { execSync } = require('child_process'); -const fs = require('fs'); -const path = require('path'); - -// --------------------------------------------------------------------------- -// CLI -// --------------------------------------------------------------------------- - -const args = process.argv.slice(2); - -if (args.includes('--help') || args.includes('-h')) { - console.log(` -Usage: - node playwright/a11y-matrix-generator.js Run tests and print matrix - node playwright/a11y-matrix-generator.js --from-file Build matrix from existing JSON report - -Options: - --from-file Path to a Playwright JSON report (skips running tests) - --help, -h Show this help message -`); - process.exit(0); -} - -let jsonPath; - -const fromFileIdx = args.indexOf('--from-file'); -if (fromFileIdx !== -1) { - jsonPath = args[fromFileIdx + 1]; - if (!jsonPath || !fs.existsSync(jsonPath)) { - console.error(`Error: file not found – ${jsonPath}`); - process.exit(1); - } -} else { - // Run the tests and capture the JSON report - const reportDir = path.resolve(__dirname, '..', 'playwright-report'); - if (!fs.existsSync(reportDir)) { - fs.mkdirSync(reportDir, { recursive: true }); - } - const tmpReport = path.join(reportDir, 'a11y-report.json'); - console.log('Running accessibility tests …\n'); - try { - execSync(`npx playwright test --project=accessibility --reporter=json`, { - cwd: path.resolve(__dirname, '..'), - stdio: ['inherit', fs.openSync(tmpReport, 'w'), 'pipe'] - }); - } catch { - // Tests may exit non-zero when there are failures — that's expected - } - console.log(`Report saved to ${tmpReport}\n`); - jsonPath = tmpReport; -} - -// --------------------------------------------------------------------------- -// Parse -// --------------------------------------------------------------------------- - -const data = JSON.parse(fs.readFileSync(jsonPath, 'utf8')); - -function flattenSuites(suites) { - const out = []; - for (const suite of suites) { - for (const spec of suite.specs || []) { - const result = spec.tests?.[0]?.results?.[0]; - out.push({ - suite: suite.title, - test: spec.title, - status: result?.status ?? 'unknown', - error: result?.error?.message ?? '', - axeResults: parseAxeAttachments(result) - }); - } - for (const sub of suite.suites || []) { - for (const spec of sub.specs || []) { - const result = spec.tests?.[0]?.results?.[0]; - out.push({ - suite: suite.title, - test: `${sub.title} - ${spec.title}`, - status: result?.status ?? 'unknown', - error: result?.error?.message ?? '', - axeResults: parseAxeAttachments(result) - }); - } - } - } - return out; -} - -/** Extract all axe-core results objects from the test attachments (one per state) */ -function parseAxeAttachments(result) { - if (!result?.attachments) return []; - return result.attachments - .filter((a) => a.name.endsWith('-accessibility-scan') && a.body) - .map((a) => { - try { - return JSON.parse(Buffer.from(a.body, 'base64').toString()); - } catch { - return null; - } - }) - .filter(Boolean); -} - -const tests = flattenSuites(data.suites?.[0]?.suites ?? []); - -// --------------------------------------------------------------------------- -// Extract violations and applicability per component -// --------------------------------------------------------------------------- - -const failedRules = {}; // { "Component|||rule-id": true } -const applicableRules = {}; // { "Component|||rule-id": true } -const allComponents = new Set(); -const allRules = new Set(); // only rules that have at least one violation - -function extractViolationIds(errorMsg) { - return [ - ...new Set( - (errorMsg.match(/] ([a-z][a-z0-9-]+):/g) || []).map((m) => m.slice(2, -1)) - ) - ]; -} - -function addResults(comp, status, errorMsg, axeResultsArr) { - allComponents.add(comp); - - // Use axe attachments for accurate applicability data (one per state scan) - if (axeResultsArr.length > 0) { - for (const axeResults of axeResultsArr) { - for (const v of axeResults.violations || []) { - failedRules[`${comp}|||${v.id}`] = true; - applicableRules[`${comp}|||${v.id}`] = true; - allRules.add(v.id); - } - for (const p of axeResults.passes || []) { - applicableRules[`${comp}|||${p.id}`] = true; - } - for (const inc of axeResults.incomplete || []) { - applicableRules[`${comp}|||${inc.id}`] = true; - } - } - return; - } - - // Fallback: parse from error message (no attachment available) - if (status !== 'passed') { - const ids = extractViolationIds(errorMsg); - for (const id of ids) { - allRules.add(id); - failedRules[`${comp}|||${id}`] = true; - applicableRules[`${comp}|||${id}`] = true; - } - } -} - -for (const t of tests) { - const s = t.suite; - const n = t.test; - - if ( - s === 'Accessibility - axe scan' || - s === 'Accessibility - responsive axe scan' - ) { - addResults(n.replace(/ should.*/, ''), t.status, t.error, t.axeResults); - continue; - } -} - -// --------------------------------------------------------------------------- -// Render -// --------------------------------------------------------------------------- - -const cols = [...allRules].sort(); -const components = [...allComponents].sort(); - -function cell(comp, rule) { - const key = `${comp}|||${rule}`; - if (failedRules[key]) return '❌'; - if (applicableRules[key]) return '✅'; - return '-'; -} - -const COL_WIDTHS = [22, ...cols.map((c) => Math.max(c.length, 6))]; -const padEnd = (s, w) => s + ' '.repeat(Math.max(0, w - s.length)); - -function row(cells) { - return cells.map((c, i) => padEnd(c, COL_WIDTHS[i])).join(' | '); -} - -console.log(); -console.log(row(['Component', ...cols])); -console.log(row(COL_WIDTHS.map((w) => '-'.repeat(w)))); - -let totalPass = 0; -let totalFail = 0; -let totalNA = 0; - -for (const comp of components) { - const cells = cols.map((rule) => { - const v = cell(comp, rule); - if (v === '✅') totalPass++; - else if (v === '❌') totalFail++; - else totalNA++; - return v; - }); - console.log(row([comp, ...cells])); -} - -// Summary row -const summaries = cols.map((rule) => { - let fail = 0; - let applicable = 0; - for (const comp of components) { - const key = `${comp}|||${rule}`; - if (applicableRules[key]) { - applicable++; - if (failedRules[key]) fail++; - } - } - return `${applicable - fail}/${applicable}`; -}); - -console.log(row(COL_WIDTHS.map((w) => '-'.repeat(w)))); -console.log(row(['PASS / TESTED', ...summaries])); - -console.log(`\nCells: ${totalPass} PASS, ${totalFail} FAIL, ${totalNA} N/A`); -console.log(`Components: ${components.length}`); -console.log(`Rules with violations: ${cols.length}`); diff --git a/playwright/composition/accessibility-composition.spec.ts b/playwright/composition/accessibility-composition.spec.ts new file mode 100644 index 000000000..02acb3917 --- /dev/null +++ b/playwright/composition/accessibility-composition.spec.ts @@ -0,0 +1,221 @@ +import { type Page, type TestInfo, expect } from '@playwright/test'; +import type AxeBuilder from '@axe-core/playwright'; +import { pageRoutes } from '../fixtures/composition-components'; +import { + test, + expectNoViolations, + waitForAnimationsToFinish +} from '../fixtures/axe-helpers'; + +const toggle = (page: Page) => + page.getByRole('button', { name: 'Toggle navigation sidebar' }); +const nav = (page: Page) => + page.getByRole('navigation', { name: 'Main navigation' }); +const mainContent = (page: Page) => page.locator('#main-content'); +const backdrop = (page: Page) => page.locator('.sidebar-backdrop'); + +async function runFullPageScan( + page: Page, + makeAxeBuilder: () => AxeBuilder, + label: string, + testInfo: TestInfo +) { + await page.waitForSelector('#main-content'); + await waitForAnimationsToFinish(page); + // No .include() — scans the whole rendered document (header + sidebar + main), + // unlike accessibility-cps-ui-kit.spec.ts which scopes scans to individual components. + const results = await makeAxeBuilder().analyze(); + await testInfo.attach(`${label}-full-page-accessibility-scan`, { + body: JSON.stringify(results, null, 2), + contentType: 'application/json' + }); + expectNoViolations(results.violations); +} + +// ============================================================================ +// axe-core WCAG AA — full page scans, one per composition app route +// ============================================================================ + +test.describe('Accessibility - full page axe scan', () => { + for (const route of pageRoutes) { + test(`${route} should have no violations (full page)`, async ({ + page, + makeAxeBuilder + }, testInfo) => { + await page.goto(route); + await runFullPageScan(page, makeAxeBuilder, route, testInfo); + }); + } +}); + +// ============================================================================ +// axe-core at mobile viewport +// ============================================================================ + +test.describe('Accessibility - responsive full page axe scan', () => { + for (const route of pageRoutes) { + test(`${route} should have no violations at mobile width (full page)`, async ({ + page, + makeAxeBuilder + }, testInfo) => { + await page.setViewportSize({ width: 375, height: 812 }); + await page.goto(route); + await runFullPageScan(page, makeAxeBuilder, route, testInfo); + }); + } +}); + +// ============================================================================ +// app shell interactive states not covered by the resting-state scans above +// ============================================================================ + +test.describe('Accessibility - app shell interactive states', () => { + test('sidebar collapsed (desktop) has no violations', async ({ + page, + makeAxeBuilder + }, testInfo) => { + await page.goto('/colors'); + await page + .getByRole('button', { name: 'Toggle navigation sidebar' }) + .click(); + await runFullPageScan( + page, + makeAxeBuilder, + 'sidebar-collapsed-desktop', + testInfo + ); + }); + + test('sidebar open on mobile (with backdrop) has no violations', async ({ + page, + makeAxeBuilder + }, testInfo) => { + await page.setViewportSize({ width: 375, height: 812 }); + await page.goto('/colors'); + await page + .getByRole('button', { name: 'Toggle navigation sidebar' }) + .click(); + await page.waitForSelector('.sidebar-backdrop'); + await runFullPageScan( + page, + makeAxeBuilder, + 'sidebar-open-mobile', + testInfo + ); + }); + + test('sidebar collapsed/inert on mobile has no violations', async ({ + page, + makeAxeBuilder + }, testInfo) => { + await page.setViewportSize({ width: 375, height: 812 }); + await page.goto('/colors'); + await runFullPageScan( + page, + makeAxeBuilder, + 'sidebar-collapsed-mobile', + testInfo + ); + }); +}); + +// ============================================================================ +// app shell focus flows not covered by the resting-state scans above +// ============================================================================ + +test.describe('Composition app shell - sidebar & focus flows', () => { + test.describe('desktop viewport', () => { + test('sidebar toggle has correct aria-expanded and the nav landmark exists', async ({ + page + }) => { + await page.goto('/colors'); + await expect(nav(page)).toBeVisible(); + await expect(toggle(page)).toHaveAttribute('aria-expanded', 'true'); + await toggle(page).click(); + await expect(toggle(page)).toHaveAttribute('aria-expanded', 'false'); + await expect(nav(page)).toHaveAttribute('inert', ''); + await toggle(page).click(); + await expect(toggle(page)).toHaveAttribute('aria-expanded', 'true'); + }); + + test('focus moves to #main-content after direct navigation', async ({ + page + }) => { + await page.goto('/button'); + await expect(mainContent(page)).toBeFocused(); + }); + + test('focus moves to #main-content after clicking a sidebar nav link', async ({ + page + }) => { + await page.goto('/colors'); + await nav(page) + .getByRole('link', { name: 'Button', exact: true }) + .click(); + await expect(page).toHaveURL(/\/button(\/examples)?$/); + await expect(mainContent(page)).toBeFocused(); + }); + + test('active nav link has aria-current="page"', async ({ page }) => { + await page.goto('/button'); + await expect( + nav(page).getByRole('link', { name: 'Button', exact: true }) + ).toHaveAttribute('aria-current', 'page'); + }); + + test('Shift+Tab from main content redirects focus to the active nav link', async ({ + page + }) => { + await page.goto('/button'); + await expect(mainContent(page)).toBeFocused(); + await page.keyboard.press('Shift+Tab'); + await expect( + nav(page).getByRole('link', { name: 'Button', exact: true }) + ).toBeFocused(); + }); + }); + + test.describe('mobile viewport (375x812)', () => { + test.beforeEach(async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }); + }); + + test('sidebar starts closed on mobile', async ({ page }) => { + await page.goto('/colors'); + await expect(toggle(page)).toHaveAttribute('aria-expanded', 'false'); + await expect(backdrop(page)).toHaveCount(0); + }); + + test('opening the sidebar shows the backdrop', async ({ page }) => { + await page.goto('/colors'); + await toggle(page).click(); + await expect(backdrop(page)).toBeVisible(); + }); + + test('clicking a nav link closes the sidebar and focuses main content', async ({ + page + }) => { + await page.goto('/colors'); + await toggle(page).click(); + await nav(page) + .getByRole('link', { name: 'Button', exact: true }) + .click(); + await expect(page).toHaveURL(/\/button(\/examples)?$/); + await expect(toggle(page)).toHaveAttribute('aria-expanded', 'false'); + await expect(backdrop(page)).toHaveCount(0); + await expect(mainContent(page)).toBeFocused(); + }); + + test('Escape closes the sidebar and returns focus to the toggle button', async ({ + page + }) => { + await page.goto('/colors'); + await toggle(page).click(); + await expect(backdrop(page)).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(toggle(page)).toHaveAttribute('aria-expanded', 'false'); + await expect(backdrop(page)).toHaveCount(0); + await expect(toggle(page)).toBeFocused(); + }); + }); +}); diff --git a/playwright/cps-ui-kit/accessibility-cps-ui-kit.spec.ts b/playwright/cps-ui-kit/accessibility-cps-ui-kit.spec.ts new file mode 100644 index 000000000..6d98ae108 --- /dev/null +++ b/playwright/cps-ui-kit/accessibility-cps-ui-kit.spec.ts @@ -0,0 +1,137 @@ +import type { Page, TestInfo } from '@playwright/test'; +import type AxeBuilder from '@axe-core/playwright'; +import { + components, + type ComponentEntry +} from '../fixtures/composition-components'; +import { + test, + expectNoViolations, + waitForAnimationsToFinish +} from '../fixtures/axe-helpers'; + +function buildAxeWithSelectors( + makeAxeBuilder: () => AxeBuilder, + selector: string | string[] +) { + const selectors = Array.isArray(selector) ? selector : [selector]; + let builder = makeAxeBuilder(); + for (const s of selectors) { + builder = builder.include(s); + } + return builder; +} + +async function waitForSelectors(page: Page, selector: string | string[]) { + const selectors = Array.isArray(selector) ? selector : [selector]; + await Promise.all(selectors.map((s) => page.waitForSelector(s))); +} + +/** + * Groups entries sharing the same `group` so their tests can be + * nested under a shared test.describe block. + */ +function groupComponents(entries: ComponentEntry[]) { + const groups = new Map(); + const items: { group?: string; entries: ComponentEntry[] }[] = []; + + for (const entry of entries) { + if (entry.group) { + let bucket = groups.get(entry.group); + if (!bucket) { + bucket = []; + groups.set(entry.group, bucket); + items.push({ group: entry.group, entries: bucket }); + } + bucket.push(entry); + } else { + items.push({ entries: [entry] }); + } + } + + return items; +} + +function registerScanTests( + buildTitle: (name: string) => string, + registerTest: (entry: ComponentEntry, title: string) => void +) { + for (const item of groupComponents(components)) { + if (item.group) { + test.describe(item.group, () => { + for (const entry of item.entries) { + registerTest(entry, buildTitle(entry.name)); + } + }); + } else { + registerTest(item.entries[0], buildTitle(item.entries[0].name)); + } + } +} + +// ============================================================================ +// axe-core WCAG AA — all component pages +// ============================================================================ + +async function runScan( + page: Page, + makeAxeBuilder: () => AxeBuilder, + selector: string | string[], + label: string, + testInfo: TestInfo +) { + await waitForSelectors(page, selector); + await waitForAnimationsToFinish(page); + const results = await buildAxeWithSelectors( + makeAxeBuilder, + selector + ).analyze(); + await testInfo.attach(`${label}-accessibility-scan`, { + body: JSON.stringify(results, null, 2), + contentType: 'application/json' + }); + expectNoViolations(results.violations); +} + +test.describe('Accessibility - axe scan', () => { + registerScanTests( + (name) => `${name} should have no violations`, + (entry, title) => { + test(title, async ({ page, makeAxeBuilder }, testInfo) => { + await page.goto(entry.route); + if (entry.setup) await entry.setup(page); + await runScan( + page, + makeAxeBuilder, + entry.selector, + 'default', + testInfo + ); + }); + } + ); +}); + +// ============================================================================ +// axe-core at mobile viewport +// ============================================================================ + +test.describe('Accessibility - responsive axe scan', () => { + registerScanTests( + (name) => `${name} should have no violations at mobile width`, + (entry, title) => { + test(title, async ({ page, makeAxeBuilder }, testInfo) => { + await page.setViewportSize({ width: 375, height: 812 }); + await page.goto(entry.route); + if (entry.setup) await entry.setup(page); + await runScan( + page, + makeAxeBuilder, + entry.selector, + 'default', + testInfo + ); + }); + } + ); +}); diff --git a/playwright/cps-autocomplete.spec.ts b/playwright/cps-ui-kit/components/cps-autocomplete.spec.ts similarity index 100% rename from playwright/cps-autocomplete.spec.ts rename to playwright/cps-ui-kit/components/cps-autocomplete.spec.ts diff --git a/playwright/cps-scheduler.spec.ts b/playwright/cps-ui-kit/components/cps-scheduler.spec.ts similarity index 100% rename from playwright/cps-scheduler.spec.ts rename to playwright/cps-ui-kit/components/cps-scheduler.spec.ts diff --git a/playwright/cps-table.spec.ts b/playwright/cps-ui-kit/components/cps-table.spec.ts similarity index 97% rename from playwright/cps-table.spec.ts rename to playwright/cps-ui-kit/components/cps-table.spec.ts index ca27fcd12..a15c88b4a 100644 --- a/playwright/cps-table.spec.ts +++ b/playwright/cps-ui-kit/components/cps-table.spec.ts @@ -25,6 +25,8 @@ test.describe('cps-table page', () => { const downloadedContent = fs.readFileSync(downloadedPath!); const fixturePath = path.join( __dirname, + '..', + '..', 'fixtures', 'table_6_fixture.xlsx' ); diff --git a/playwright/fixtures/axe-helpers.ts b/playwright/fixtures/axe-helpers.ts new file mode 100644 index 000000000..d72913fcd --- /dev/null +++ b/playwright/fixtures/axe-helpers.ts @@ -0,0 +1,56 @@ +import { test as base, expect, type Page } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; + +type Violations = Awaited>['violations']; + +type AxeFixture = { + makeAxeBuilder: () => AxeBuilder; +}; + +export const test = base.extend({ + makeAxeBuilder: async ({ page }, use) => { + const makeAxeBuilder = () => + new AxeBuilder({ page }).withTags([ + 'wcag2a', + 'wcag2aa', + 'wcag21a', + 'wcag21aa', + 'wcag22a', + 'wcag22aa', + 'best-practice' + ]); + await use(makeAxeBuilder); + } +}); + +export function formatViolations(violations: Violations): string { + if (violations.length === 0) return ''; + return violations + .map((v) => { + const nodes = v.nodes + .map((n) => ` - ${n.html}\n ${n.failureSummary}`) + .join('\n'); + return `\n[${v.impact}] ${v.id}: ${v.description}\n Help: ${v.helpUrl}\n${nodes}`; + }) + .join('\n'); +} + +export function expectNoViolations(violations: Violations) { + expect(violations, formatViolations(violations)).toHaveLength(0); +} + +/** + * Wait for all animations/transitions to complete before scanning. + * Without this, axe may capture intermediate states (e.g. mid-transition + * background color) and report false-positive color contrast violations. + */ +export async function waitForAnimationsToFinish(page: Page) { + await page.evaluate(() => + Promise.all( + document + .getAnimations() + .filter((a) => a.effect?.getTiming().iterations !== Infinity) + .map((a) => a.finished.catch(() => {})) + ) + ); +} diff --git a/playwright/fixtures/axe-test.ts b/playwright/fixtures/axe-test.ts deleted file mode 100644 index 0bba0273a..000000000 --- a/playwright/fixtures/axe-test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { test as base } from '@playwright/test'; -import AxeBuilder from '@axe-core/playwright'; - -type AxeFixture = { - makeAxeBuilder: () => AxeBuilder; -}; - -export const test = base.extend({ - makeAxeBuilder: async ({ page }, use) => { - const makeAxeBuilder = () => - new AxeBuilder({ page }).withTags([ - 'wcag2a', - 'wcag2aa', - 'wcag21a', - 'wcag21aa', - 'wcag22a', - 'wcag22aa', - 'best-practice' - ]); - await use(makeAxeBuilder); - } -}); - -export { expect } from '@playwright/test'; diff --git a/playwright/cps-accessibility.spec.ts b/playwright/fixtures/composition-components.ts similarity index 57% rename from playwright/cps-accessibility.spec.ts rename to playwright/fixtures/composition-components.ts index dc39b72cc..15501f27b 100644 --- a/playwright/cps-accessibility.spec.ts +++ b/playwright/fixtures/composition-components.ts @@ -1,23 +1,18 @@ -import { test, expect } from './fixtures/axe-test'; -import type { Page, TestInfo } from '@playwright/test'; -import type AxeBuilder from '@axe-core/playwright'; +import type { Page } from '@playwright/test'; -interface ComponentState { - label: string; - setup: (page: Page) => Promise; -} - -interface ComponentEntry { +export interface ComponentEntry { route: string; name: string; selector: string | string[]; /** For overlay components: trigger them before scanning */ setup?: (page: Page) => Promise; - /** For components that need multiple scans (e.g., different states/tabs) */ - states?: ComponentState[]; + /** Nests this entry's test under a shared test.describe(group) with other + * entries that share the same group. + */ + group?: string; } -const components: ComponentEntry[] = [ +export const components: ComponentEntry[] = [ { route: '/autocomplete', name: 'Autocomplete', @@ -51,6 +46,7 @@ const components: ComponentEntry[] = [ route: '/dialog', name: 'Confirmation dialog', selector: '[role="dialog"]', + group: 'Dialog', setup: async (page) => { await page.waitForSelector('.example-content'); await page @@ -63,6 +59,7 @@ const components: ComponentEntry[] = [ route: '/dialog', name: 'Regular dialog', selector: '[role="dialog"]', + group: 'Dialog', setup: async (page) => { await page.waitForSelector('.example-content'); await page @@ -75,6 +72,7 @@ const components: ComponentEntry[] = [ route: '/dialog', name: 'Draggable dialog', selector: '[role="dialog"]', + group: 'Dialog', setup: async (page) => { await page.waitForSelector('.example-content'); await page @@ -87,6 +85,7 @@ const components: ComponentEntry[] = [ route: '/dialog', name: 'Resizable dialog', selector: '[role="dialog"]', + group: 'Dialog', setup: async (page) => { await page.waitForSelector('.example-content'); await page @@ -99,6 +98,7 @@ const components: ComponentEntry[] = [ route: '/dialog', name: 'Dialog with blurred background and container autofocus', selector: '[role="dialog"]', + group: 'Dialog', setup: async (page) => { await page.waitForSelector('.example-content'); await page @@ -121,11 +121,17 @@ const components: ComponentEntry[] = [ { route: '/icon', name: 'Icon', selector: '.example-content cps-icon' }, { route: '/info-circle', name: 'Info circle', selector: 'cps-info-circle' }, { route: '/input', name: 'Input', selector: '.example-content cps-input' }, - { route: '/loader', name: 'Loader', selector: '.example-content cps-loader' }, + { + route: '/loader', + name: 'Loader', + selector: '.example-content cps-loader', + group: 'Loader' + }, { route: '/loader', name: 'Loader fullscreen', selector: '.example-content cps-loader', + group: 'Loader', setup: async (page) => { await page.waitForSelector('.example-content'); await page @@ -141,6 +147,7 @@ const components: ComponentEntry[] = [ route: '/menu', name: 'Menu standard', selector: '.cps-menu-container', + group: 'Menu', setup: async (page) => { await page.waitForSelector('.example-content'); await page.locator('.example-content cps-button').first().click(); @@ -150,6 +157,7 @@ const components: ComponentEntry[] = [ route: '/menu', name: 'Menu compressed', selector: '.cps-menu-container', + group: 'Menu', setup: async (page) => { await page.waitForSelector('.example-content'); await page.locator('.example-content cps-button').nth(2).click(); @@ -182,26 +190,26 @@ const components: ComponentEntry[] = [ selector: 'cps-progress-linear' }, { route: '/radio-group', name: 'Radio', selector: 'cps-radio-group' }, - { - route: '/scheduler', - name: 'Scheduler', - selector: 'cps-scheduler', - states: [ - 'Minutes', - 'Hourly', - 'Daily', - 'Weekly', - 'Monthly', - 'Yearly', - 'Advanced' - ].map((tab) => ({ - label: tab, - setup: async (page: Page) => { + ...[ + 'Minutes', + 'Hourly', + 'Daily', + 'Weekly', + 'Monthly', + 'Yearly', + 'Advanced' + ].map( + (tab): ComponentEntry => ({ + route: '/scheduler', + name: tab, + selector: 'cps-scheduler', + group: 'Scheduler', + setup: async (page) => { await page.getByTestId('schedule-type-toggle').getByText(tab).click(); await page.waitForSelector('cps-scheduler'); } - })) - }, + }) + ), { route: '/select', name: 'Select', @@ -222,29 +230,29 @@ const components: ComponentEntry[] = [ name: 'Tabs', selector: '.example-content cps-tab-group' }, - { - route: '/table', - name: 'Table', - selector: 'cps-table', - states: [ - 'Table 1', - 'Table 2', - 'Table 3', - 'Table 4', - 'Table 5', - 'Table 6', - 'Table 7', - 'Table 8', - 'Table 9', - 'Table 10' - ].map((tab) => ({ - label: tab, - setup: async (page: Page) => { + ...[ + 'Table 1', + 'Table 2', + 'Table 3', + 'Table 4', + 'Table 5', + 'Table 6', + 'Table 7', + 'Table 8', + 'Table 9', + 'Table 10' + ].map( + (tab): ComponentEntry => ({ + route: '/table', + name: tab, + selector: 'cps-table', + group: 'Table', + setup: async (page) => { await page.getByRole('tab', { name: tab, exact: true }).click(); await page.waitForSelector('cps-table'); } - })) - }, + }) + ), { route: '/tag', name: 'Tag', selector: 'cps-tag' }, { route: '/textarea', name: 'Textarea', selector: 'cps-textarea' }, { @@ -283,143 +291,35 @@ const components: ComponentEntry[] = [ await page.locator('cps-tree-select').first().click(); } }, - { - route: '/tree-table', - name: 'Tree table', - selector: 'cps-tree-table', - states: [ - 'Tree table 1', - 'Tree table 2', - 'Tree table 3', - 'Tree table 4', - 'Tree table 5', - 'Tree table 6', - 'Tree table 7', - 'Tree table 8', - 'Tree table 9', - 'Tree table 10' - ].map((tab) => ({ - label: tab, - setup: async (page: Page) => { + ...[ + 'Tree table 1', + 'Tree table 2', + 'Tree table 3', + 'Tree table 4', + 'Tree table 5', + 'Tree table 6', + 'Tree table 7', + 'Tree table 8', + 'Tree table 9', + 'Tree table 10' + ].map( + (tab): ComponentEntry => ({ + route: '/tree-table', + name: tab, + selector: 'cps-tree-table', + group: 'Tree table', + setup: async (page) => { await page.getByRole('tab', { name: tab, exact: true }).click(); await page.waitForSelector('cps-tree-table'); } - })) - } -]; - -type Violations = Awaited>['violations']; - -function formatViolations(violations: Violations): string { - if (violations.length === 0) return ''; - return violations - .map((v) => { - const nodes = v.nodes - .map((n) => ` - ${n.html}\n ${n.failureSummary}`) - .join('\n'); - return `\n[${v.impact}] ${v.id}: ${v.description}\n Help: ${v.helpUrl}\n${nodes}`; }) - .join('\n'); -} - -function expectNoViolations(violations: Violations) { - expect(violations, formatViolations(violations)).toHaveLength(0); -} - -function buildAxeWithSelectors( - makeAxeBuilder: () => AxeBuilder, - selector: string | string[] -) { - const selectors = Array.isArray(selector) ? selector : [selector]; - let builder = makeAxeBuilder(); - for (const s of selectors) { - builder = builder.include(s); - } - return builder; -} - -async function waitForSelectors(page: Page, selector: string | string[]) { - const selectors = Array.isArray(selector) ? selector : [selector]; - await Promise.all(selectors.map((s) => page.waitForSelector(s))); -} - -// ============================================================================ -// axe-core WCAG AA — all component pages -// ============================================================================ - -async function runScan( - page: Page, - makeAxeBuilder: () => AxeBuilder, - selector: string | string[], - label: string, - testInfo: TestInfo -) { - await waitForSelectors(page, selector); - // Wait for all animations/transitions to complete before scanning. - // Without this, axe may capture intermediate states (e.g. mid-transition - // background color) and report false-positive color contrast violations. - await page.evaluate(() => - Promise.all( - document - .getAnimations() - .filter((a) => a.effect?.getTiming().iterations !== Infinity) - .map((a) => a.finished.catch(() => {})) - ) - ); - const results = await buildAxeWithSelectors( - makeAxeBuilder, - selector - ).analyze(); - await testInfo.attach(`${label}-accessibility-scan`, { - body: JSON.stringify(results, null, 2), - contentType: 'application/json' - }); - expectNoViolations(results.violations); -} - -test.describe('Accessibility - axe scan', () => { - for (const { route, name, selector, setup, states } of components) { - test(`${name} should have no violations`, async ({ - page, - makeAxeBuilder - }, testInfo) => { - await page.goto(route); - - if (states) { - for (const state of states) { - if (state.setup) await state.setup(page); - await runScan(page, makeAxeBuilder, selector, state.label, testInfo); - } - } else { - if (setup) await setup(page); - await runScan(page, makeAxeBuilder, selector, 'default', testInfo); - } - }); - } -}); - -// ============================================================================ -// axe-core at mobile viewport -// ============================================================================ + ) +]; -test.describe('Accessibility - responsive axe scan', () => { - for (const { route, name, selector, setup, states } of components) { - test(`${name} should have no violations at mobile width`, async ({ - page, - makeAxeBuilder - }, testInfo) => { - await page.setViewportSize({ width: 375, height: 812 }); - await page.goto(route); +const EXTRA_PAGE_ROUTES = [ + '/colors' // default `**` redirect target; has no entry in `components` +]; - if (states) { - for (const state of states) { - if (state.setup) await state.setup(page); - await runScan(page, makeAxeBuilder, selector, state.label, testInfo); - } - } else { - if (setup) await setup(page); - await runScan(page, makeAxeBuilder, selector, 'default', testInfo); - } - }); - } -}); +export const pageRoutes: string[] = [ + ...new Set([...components.map((c) => c.route), ...EXTRA_PAGE_ROUTES]) +].sort(); diff --git a/projects/composition/src/app/app.component.html b/projects/composition/src/app/app.component.html index 25a5a264d..5391b4cb1 100644 --- a/projects/composition/src/app/app.component.html +++ b/projects/composition/src/app/app.component.html @@ -42,7 +42,7 @@ (keydown.shift.tab)="focusActiveNavItem($event)">
- {{ componentTitle }} +

{{ componentTitle }}

diff --git a/projects/composition/src/app/app.component.scss b/projects/composition/src/app/app.component.scss index bef6f6a8b..f1c12003e 100644 --- a/projects/composition/src/app/app.component.scss +++ b/projects/composition/src/app/app.component.scss @@ -106,6 +106,8 @@ font-size: 1.25rem; .composition-body-toolbar-title { margin: 0 auto; + font-size: inherit; + font-weight: inherit; } .sidebar-toggle-placeholder { display: flex; diff --git a/projects/composition/src/app/components/code-example/code-example.component.html b/projects/composition/src/app/components/code-example/code-example.component.html index eec75a97e..e2417ce31 100644 --- a/projects/composition/src/app/components/code-example/code-example.component.html +++ b/projects/composition/src/app/components/code-example/code-example.component.html @@ -1,8 +1,8 @@
@if (label()) { -

+

{{ label() }} -

+ }
+ [tsCode]="examples.disabled.ts" + [isPreviewNonInteractive]="true"> - +