From db46e5d6fb7e541262d3b99c62bf44c7bf20e93f Mon Sep 17 00:00:00 2001 From: gmasci Date: Wed, 15 Jul 2026 20:28:47 +0200 Subject: [PATCH 1/5] feat(storybook): add monorepo Storybook and broaden accessibility verification Adds a single monorepo Storybook instance (npm run storybook / build-storybook) documenting every component in @ca-webstack/ng-components and @ca-webstack/ng-components-extra, compiled from source and hosted from a new root angular.json application project. Every component gets co-located stories covering its main use cases, controls, and autodocs. The same stories back an automated accessibility test suite that runs in CI on every change: - Structural checks via axe-core at WCAG 2.2 AA across every story (npm run test:a11y). - Keyboard-interaction checks driving real key events (Tab, Enter, Space, Escape, Arrow keys) through representative interactive widgets and asserting the resulting accessible state (npm run test:keyboard). - An automated colour-contrast audit across every rendered foreground/background combination. - Static accessibility lint (@angular-eslint) enforced as a build error in both packages. Both new gates use a standalone Playwright-based script rather than @storybook/test-runner, avoiding a dependency conflict with the monorepo's existing jest-based unit tests. Publishes ACCESSIBILITY.md, a conformance statement against WCAG 2.2 AA / EN 301 549 covering both packages: target and regulatory context, a Definition of Done for interactive components, the verification methods and tooling above, a per-component conformance record, and known limitations. Also includes a number of accessibility refinements found and fixed while building out the automated coverage: colour-contrast adjustments to several default design tokens, accessible-name wiring for a few composed/dynamic controls, keyboard target-size and scrollable-region adjustments, and related component-level fixes across both packages. --- .github/workflows/ci.yml | 12 + .gitignore | 3 + .storybook/a11y.mjs | 200 + .storybook/assets/sidebar.json | 1 + .storybook/contrast-audit.mjs | 58 + .storybook/host-main.ts | 4 + .storybook/index.html | 10 + .storybook/keyboard.mjs | 287 + .storybook/main.ts | 20 + .storybook/preview.ts | 86 + .storybook/styles.scss | 9 + .storybook/tsconfig.json | 34 + ACCESSIBILITY.md | 243 + CHANGELOG.md | 18 + angular.json | 58 + ca-ng-components-extra/eslint.config.mjs | 60 + .../app-header/app-header.component.html | 4 +- .../app-header/app-header.component.scss | 3 + .../app-header/app-header.component.ts | 9 +- .../app-header/app-header.stories.ts | 96 + .../app-sidebar/app-sidebar.component.scss | 3 + .../app-sidebar/app-sidebar.component.ts | 13 +- .../app-sidebar/app-sidebar.stories.ts | 86 + .../src/components/components.module.ts | 3 +- .../container/container.component.html | 13 +- .../container/container.component.scss | 32 + .../container/container.component.spec.ts | 3 +- .../container/container.component.ts | 35 +- .../components/container/container.stories.ts | 100 + .../side-menu-entry.component.html | 13 +- .../side-menu-entry.component.scss | 19 +- .../side-menu-entry.component.ts | 33 +- .../side-menu-entry.stories.ts | 120 + .../side-menu/side-menu.component.html | 14 +- .../side-menu/side-menu.component.scss | 4 + .../side-menu/side-menu.component.spec.ts | 3 +- .../side-menu/side-menu.component.ts | 16 + .../components/side-menu/side-menu.stories.ts | 155 + .../topbar-button.component.html | 2 +- .../topbar-button.component.scss | 4 + .../topbar-button/topbar-button.stories.ts | 78 + .../topbar-item/topbar-item.stories.ts | 91 + .../components/topbar/topbar.component.html | 2 +- .../components/topbar/topbar.component.ts | 7 + .../components/topbar/topbar.stories.ts | 158 + .../components/container/container.module.ts | 3 +- .../floating-commands.component.html | 11 +- .../floating-commands.component.scss | 7 + .../floating-commands.component.ts | 81 + .../floating-commands.stories.ts | 116 + .../form-control/form-control.component.html | 6 +- .../form-control/form-control.stories.ts | 88 + .../mock-text/mock-text.component.html | 3 + .../mock-text/mock-text.component.ts | 17 +- .../components/mock-text/mock-text.stories.ts | 85 + .../side-panel/side-panel.component.html | 19 +- .../side-panel/side-panel.component.scss | 10 + .../side-panel/side-panel.component.ts | 12 +- .../side-panel/side-panel.stories.ts | 187 + .../src/components/text/text.component.html | 5 + .../src/components/text/text.component.scss | 14 + .../src/components/text/text.component.ts | 18 +- .../src/components/text/text.stories.ts | 108 + .../styles/scss/utils/_variables.scss | 12 +- ca-ng-components/eslint.config.mjs | 33 +- ca-ng-components/package.json | 12 +- .../src/components/base/base.component.ts | 40 + .../breadcrumb/breadcrumb.component.html | 41 +- .../breadcrumb/breadcrumb.component.scss | 60 +- .../breadcrumb/breadcrumb.component.ts | 43 + .../breadcrumb/breadcrumb.stories.ts | 133 + .../components/button/button.component.html | 3 +- .../components/button/button.component.scss | 6 +- .../src/components/button/button.stories.ts | 101 + .../src/components/caption/caption.stories.ts | 69 + .../src/components/card/card.component.html | 21 +- .../src/components/card/card.component.scss | 7 +- .../src/components/card/card.stories.ts | 95 + .../checkbox/checkbox.component.html | 13 +- .../checkbox/checkbox.component.scss | 14 +- .../components/checkbox/checkbox.stories.ts | 67 + .../checkgroup/checkgroup.component.html | 20 +- .../checkgroup/checkgroup.component.scss | 10 + .../checkgroup/checkgroup.component.ts | 4 +- .../checkgroup/checkgroup.stories.ts | 106 + .../src/components/column/column.stories.ts | 223 + .../src/components/combo/combo.component.html | 24 +- .../src/components/combo/combo.component.ts | 21 + .../src/components/combo/combo.stories.ts | 122 + .../commands-bar/commands-bar.component.ts | 40 +- .../commands-bar/commands-bar.stories.ts | 152 + .../src/components/components.module.ts | 2 + .../components/container/container.stories.ts | 119 + .../context-menu-item.component.html | 6 +- .../context-menu-item.component.ts | 24 + .../context-menu-item.stories.ts | 69 + .../context-menu/context-menu.component.html | 4 +- .../context-menu/context-menu.component.ts | 20 +- .../context-menu/context-menu.stories.ts | 136 + .../src/components/date/date.component.html | 47 +- .../src/components/date/date.component.scss | 6 + .../src/components/date/date.component.ts | 27 +- .../src/components/date/date.stories.ts | 123 + .../flexible-content.stories.ts | 99 + .../form-array/form-array.stories.ts | 71 + .../form-control/form-control.component.ts | 19 +- .../form-control/form-control.stories.ts | 116 + .../form-group/form-group.component.html | 6 +- .../form-group/form-group.stories.ts | 73 + .../src/components/form/form.component.html | 6 +- .../src/components/form/form.stories.ts | 69 + .../components/header/header.component.html | 2 +- .../components/header/header.component.scss | 2 + .../src/components/header/header.component.ts | 8 +- .../src/components/header/header.stories.ts | 85 + .../src/components/icon/icon.component.html | 6 +- .../src/components/icon/icon.component.ts | 8 +- .../src/components/icon/icon.stories.ts | 61 + .../src/components/label/label.component.html | 9 +- .../src/components/label/label.stories.ts | 64 + .../src/components/mask/mask.component.html | 2 + .../src/components/mask/mask.stories.ts | 107 + .../src/components/modal/modal.component.html | 10 +- .../src/components/modal/modal.stories.ts | 155 + .../multiselect/multiselect.component.html | 26 +- .../multiselect/multiselect.component.ts | 111 + .../multiselect/multiselect.stories.ts | 101 + .../components/number/number.component.html | 2 + .../src/components/number/number.stories.ts | 79 + .../progress-bar/progress-bar.component.html | 8 +- .../progress-bar/progress-bar.stories.ts | 110 + .../src/components/radio/radio.component.html | 24 +- .../src/components/radio/radio.component.scss | 15 +- .../src/components/radio/radio.component.ts | 127 +- .../src/components/radio/radio.stories.ts | 106 + .../src/components/row/row.stories.ts | 177 + .../components/section/section.component.html | 17 +- .../components/section/section.component.scss | 13 +- .../components/section/section.component.ts | 28 + .../src/components/section/section.stories.ts | 96 + .../components/select/select.component.html | 23 +- .../src/components/select/select.component.ts | 52 + .../src/components/select/select.stories.ts | 116 + .../sidebar-item/sidebar-item.component.html | 22 +- .../sidebar-item/sidebar-item.component.scss | 24 +- .../sidebar-item/sidebar-item.component.ts | 41 + .../sidebar-item/sidebar-item.stories.ts | 109 + .../sidebar-search.component.html | 14 +- .../sidebar-search.component.scss | 22 +- .../sidebar-search/sidebar-search.stories.ts | 79 + .../components/sidebar/sidebar.component.html | 13 +- .../components/sidebar/sidebar.component.scss | 7 + .../src/components/sidebar/sidebar.stories.ts | 95 + .../components/slider/slider.component.html | 5 +- .../components/slider/slider.component.scss | 2 +- .../src/components/slider/slider.stories.ts | 78 + .../components/spinner/spinner.component.html | 6 +- .../components/spinner/spinner.component.scss | 9 + .../src/components/spinner/spinner.stories.ts | 100 + .../src/components/tabs/tabs.component.html | 15 +- .../src/components/tabs/tabs.component.scss | 11 +- .../src/components/tabs/tabs.component.ts | 121 +- .../src/components/tabs/tabs.stories.ts | 118 + .../src/components/text/text.component.html | 2 + .../src/components/text/text.stories.ts | 70 + .../textarea/textarea.component.html | 2 + .../components/textarea/textarea.stories.ts | 95 + .../src/components/timer/timer.component.html | 26 +- .../src/components/timer/timer.component.ts | 16 + .../src/components/timer/timer.stories.ts | 110 + .../components/toggle/toggle.component.html | 17 +- .../components/toggle/toggle.component.scss | 10 +- .../src/components/toggle/toggle.stories.ts | 66 + .../components/toolbar/toolbar.component.html | 7 +- .../components/toolbar/toolbar.component.scss | 2 +- .../components/toolbar/toolbar.component.ts | 27 +- .../src/components/toolbar/toolbar.stories.ts | 132 + .../validation-message.component.html | 2 +- .../validation-message.component.scss | 2 +- .../validation-message.stories.ts | 211 + ca-ng-components/styles/_a11y.scss | 54 + ca-ng-components/styles/_colors.scss | 7 + ca-ng-components/styles/_input.scss | 17 +- ca-ng-components/styles/index.scss | 2 + package-lock.json | 26860 +++++++++------- package.json | 16 +- 186 files changed, 23990 insertions(+), 11950 deletions(-) create mode 100644 .storybook/a11y.mjs create mode 100644 .storybook/assets/sidebar.json create mode 100644 .storybook/contrast-audit.mjs create mode 100644 .storybook/host-main.ts create mode 100644 .storybook/index.html create mode 100644 .storybook/keyboard.mjs create mode 100644 .storybook/main.ts create mode 100644 .storybook/preview.ts create mode 100644 .storybook/styles.scss create mode 100644 .storybook/tsconfig.json create mode 100644 ACCESSIBILITY.md create mode 100644 angular.json create mode 100644 ca-ng-components-extra/eslint.config.mjs create mode 100644 ca-ng-components-extra/src/components/app-header/app-header.stories.ts create mode 100644 ca-ng-components-extra/src/components/app-sidebar/app-sidebar.stories.ts create mode 100644 ca-ng-components-extra/src/components/container/components/container/container.stories.ts create mode 100644 ca-ng-components-extra/src/components/container/components/side-menu-entry/side-menu-entry.stories.ts create mode 100644 ca-ng-components-extra/src/components/container/components/side-menu/side-menu.stories.ts create mode 100644 ca-ng-components-extra/src/components/container/components/topbar-button/topbar-button.stories.ts create mode 100644 ca-ng-components-extra/src/components/container/components/topbar-item/topbar-item.stories.ts create mode 100644 ca-ng-components-extra/src/components/container/components/topbar/topbar.stories.ts create mode 100644 ca-ng-components-extra/src/components/floating-commands/floating-commands.stories.ts create mode 100644 ca-ng-components-extra/src/components/form-control/form-control.stories.ts create mode 100644 ca-ng-components-extra/src/components/mock-text/mock-text.stories.ts create mode 100644 ca-ng-components-extra/src/components/side-panel/side-panel.stories.ts create mode 100644 ca-ng-components-extra/src/components/text/text.stories.ts create mode 100644 ca-ng-components/src/components/breadcrumb/breadcrumb.stories.ts create mode 100644 ca-ng-components/src/components/button/button.stories.ts create mode 100644 ca-ng-components/src/components/caption/caption.stories.ts create mode 100644 ca-ng-components/src/components/card/card.stories.ts create mode 100644 ca-ng-components/src/components/checkbox/checkbox.stories.ts create mode 100644 ca-ng-components/src/components/checkgroup/checkgroup.stories.ts create mode 100644 ca-ng-components/src/components/column/column.stories.ts create mode 100644 ca-ng-components/src/components/combo/combo.stories.ts create mode 100644 ca-ng-components/src/components/commands-bar/commands-bar.stories.ts create mode 100644 ca-ng-components/src/components/container/container.stories.ts create mode 100644 ca-ng-components/src/components/context-menu-item/context-menu-item.stories.ts create mode 100644 ca-ng-components/src/components/context-menu/context-menu.stories.ts create mode 100644 ca-ng-components/src/components/date/date.stories.ts create mode 100644 ca-ng-components/src/components/flexible-content/flexible-content.stories.ts create mode 100644 ca-ng-components/src/components/form-array/form-array.stories.ts create mode 100644 ca-ng-components/src/components/form-control/form-control.stories.ts create mode 100644 ca-ng-components/src/components/form-group/form-group.stories.ts create mode 100644 ca-ng-components/src/components/form/form.stories.ts create mode 100644 ca-ng-components/src/components/header/header.stories.ts create mode 100644 ca-ng-components/src/components/icon/icon.stories.ts create mode 100644 ca-ng-components/src/components/label/label.stories.ts create mode 100644 ca-ng-components/src/components/mask/mask.stories.ts create mode 100644 ca-ng-components/src/components/modal/modal.stories.ts create mode 100644 ca-ng-components/src/components/multiselect/multiselect.stories.ts create mode 100644 ca-ng-components/src/components/number/number.stories.ts create mode 100644 ca-ng-components/src/components/progress-bar/progress-bar.stories.ts create mode 100644 ca-ng-components/src/components/radio/radio.stories.ts create mode 100644 ca-ng-components/src/components/row/row.stories.ts create mode 100644 ca-ng-components/src/components/section/section.stories.ts create mode 100644 ca-ng-components/src/components/select/select.stories.ts create mode 100644 ca-ng-components/src/components/sidebar-item/sidebar-item.stories.ts create mode 100644 ca-ng-components/src/components/sidebar-search/sidebar-search.stories.ts create mode 100644 ca-ng-components/src/components/sidebar/sidebar.stories.ts create mode 100644 ca-ng-components/src/components/slider/slider.stories.ts create mode 100644 ca-ng-components/src/components/spinner/spinner.stories.ts create mode 100644 ca-ng-components/src/components/tabs/tabs.stories.ts create mode 100644 ca-ng-components/src/components/text/text.stories.ts create mode 100644 ca-ng-components/src/components/textarea/textarea.stories.ts create mode 100644 ca-ng-components/src/components/timer/timer.stories.ts create mode 100644 ca-ng-components/src/components/toggle/toggle.stories.ts create mode 100644 ca-ng-components/src/components/toolbar/toolbar.stories.ts create mode 100644 ca-ng-components/src/components/validation-message/validation-message.stories.ts create mode 100644 ca-ng-components/styles/_a11y.scss diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20f4539..ad91dad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,18 @@ jobs: - name: Test all packages run: npm run test + - name: Accessibility lint (WCAG rules enforced at error) + run: npx turbo run lint --filter=@ca-webstack/ng-components --filter=@ca-webstack/ng-components-extra + + - name: Install Playwright (chromium) for the a11y gate + run: npx playwright install --with-deps chromium + + - name: Accessibility gate (Storybook + axe, WCAG 2.2 AA) + run: npm run test:a11y + + - name: Keyboard-interaction gate (Storybook + Playwright) + run: npm run test:keyboard + security: name: Security & SBOM runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 15587cf..17de715 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ coverage/ *.log npm-debug.log* .DS_Store + +# Storybook build output +storybook-static/ diff --git a/.storybook/a11y.mjs b/.storybook/a11y.mjs new file mode 100644 index 0000000..ec31902 --- /dev/null +++ b/.storybook/a11y.mjs @@ -0,0 +1,200 @@ +/** + * Headless accessibility gate for Storybook (WCAG 2.2 AA / EN 301 549 — see + * ACCESSIBILITY.md). For every story in the static build it (1) confirms the + * story renders without error and (2) runs axe-core, failing on any violation. + * + * Deliberately does NOT use @storybook/test-runner: that pulls jest 30, which + * conflicts with the monorepo's jest 29 unit tests. This uses Playwright + + * @axe-core/playwright directly (no jest), so the two test stacks never clash. + * + * Per-story exceptions are read from the story's own `parameters.a11y` + * (disable, or config.rules: [{ id, enabled:false }]) — the same annotations + * the interactive addon-a11y panel honours — so there is a single source of + * truth in the story file. + * + * Usage: build Storybook first, then `node .storybook/a11y.mjs` + * (wrapped by `npm run test:a11y`). + */ +import { spawn } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { chromium } from 'playwright'; +import { AxeBuilder } from '@axe-core/playwright'; + +const STATIC_DIR = 'storybook-static'; +const PORT = 6006; +const BASE = `http://127.0.0.1:${PORT}`; +const WCAG_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa']; + +function startServer() { + const proc = spawn( + 'npx', + ['http-server', STATIC_DIR, '-p', String(PORT), '-s', '-c-1'], + { stdio: 'ignore' } + ); + return proc; +} + +async function waitForServer(timeoutMs = 20000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const res = await fetch(`${BASE}/index.json`); + if (res.ok) return; + } catch { + /* not up yet */ + } + await new Promise((r) => setTimeout(r, 300)); + } + throw new Error(`Storybook static server did not start on ${BASE}`); +} + +const server = startServer(); +let exitCode = 0; +try { + await waitForServer(); + const index = JSON.parse(readFileSync(`${STATIC_DIR}/index.json`, 'utf8')); + const stories = Object.values(index.entries).filter((e) => e.type === 'story'); + + const browser = await chromium.launch(); + const context = await browser.newContext(); + const page = await context.newPage(); + const failures = []; + + // Capture runtime errors per story — a story that throws still leaves an empty + // #storybook-root that would otherwise "pass" axe, so treat throws as failures. + // Ignore known, harmless noise (the router matching iframe.html; missing assets). + let pageErrors = []; + const IGNORE_ERR = /NG04002|404|Failed to load resource|favicon/i; + page.on('pageerror', (e) => pageErrors.push(e.message.split('\n')[0])); + page.on('console', (m) => { + if (m.type() === 'error') pageErrors.push(m.text().split('\n')[0]); + }); + + const renderErrors = []; + for (const story of stories) { + pageErrors = []; + // A single broken story must NOT abort the whole survey — capture and move on. + try { + // 'networkidle' is unreliable here — icon-font 404s and the router keep + // the network busy — so wait for DOM + actual rendered content instead. + await page.goto(`${BASE}/iframe.html?id=${story.id}&viewMode=story`, { + waitUntil: 'domcontentloaded', + }); + // Rendered content can be inside #storybook-root OR an overlay portalled + // into (modal, context menu, side panel). + await page.waitForFunction( + () => { + const r = document.querySelector('#storybook-root'); + return (r && r.children.length > 0) || + !!document.querySelector('.modal-overlay, [role="dialog"], .ngx-contextmenu, cdk-dialog-container, .cdk-overlay-container *'); + }, + { timeout: 8000 } + ); + // Let change detection settle so any thrown runtime error surfaces. + await page.waitForTimeout(300); + const realErrors = pageErrors.filter((m) => !IGNORE_ERR.test(m)); + if (realErrors.length) { + throw new Error('runtime: ' + realErrors[0].slice(0, 140)); + } + // Detect EMPTY renders: the wrapper element can exist while the component + // rendered nothing (an internal @if is false → only a comment node), which + // still "passes" axe. Require actual laid-out content (or a body overlay). + // A story may legitimately render nothing (documenting a hidden/empty + // state); it opts out of the empty-render check with + // parameters.allowEmptyRender = true. + const allowEmpty = await page.evaluate(async (id) => { + try { + const s = await window.__STORYBOOK_PREVIEW__.storyStore.loadStory({ storyId: id }); + return s?.parameters?.allowEmptyRender === true; + } catch { + return false; + } + }, story.id); + const empty = allowEmpty ? false : await page.evaluate(() => { + const root = document.querySelector('#storybook-root'); + if (!root) return true; + const overlay = document.querySelector( + '.modal-overlay, [role="dialog"], .ngx-contextmenu, .cdk-overlay-container *' + ); + if (overlay) return false; + // The mount root is always full-width; a component that rendered nothing + // (internal @if false → only a comment) leaves ALL descendants with a + // zero box. Consider it rendered only if some descendant is laid out. + for (const el of root.querySelectorAll('*')) { + if (el.offsetHeight > 0 || el.offsetWidth > 0) return false; + } + return true; + }); + if (empty) { + throw new Error('rendered empty (component produced no visible content)'); + } + } catch (e) { + renderErrors.push({ id: story.id, reason: e.message.split('\n')[0] }); + console.error(` ✗ ${story.id} — DID NOT RENDER (${e.message.split('\n')[0]})`); + continue; + } + + // Read the story's own a11y parameters (single source of truth with the + // interactive addon), so documented exceptions are honoured here too. + const a11y = await page.evaluate(async (id) => { + try { + const preview = window.__STORYBOOK_PREVIEW__; + const story = await preview.storyStore.loadStory({ storyId: id }); + return story?.parameters?.a11y ?? null; + } catch { + return null; + } + }, story.id); + + if (a11y?.disable) { + console.log(` ⊘ ${story.id} (a11y disabled)`); + continue; + } + const disabledRules = process.env.A11Y_NO_SUPPRESS + ? [] + : (a11y?.config?.rules ?? []) + .filter((r) => r && r.enabled === false && r.id) + .map((r) => r.id); + + try { + let builder = new AxeBuilder({ page }).withTags(WCAG_TAGS); + if (disabledRules.length) builder = builder.disableRules(disabledRules); + const results = await builder.analyze(); + if (results.violations.length) { + failures.push({ id: story.id, violations: results.violations }); + console.error( + ` ✗ ${story.id} — ${results.violations + .map((v) => `${v.id} (${v.impact}, ${v.nodes.length})`) + .join(', ')}` + ); + } else { + const note = disabledRules.length ? ` [skipped: ${disabledRules.join(', ')}]` : ''; + console.log(` ✓ ${story.id}${note}`); + } + } catch (e) { + renderErrors.push({ id: story.id, reason: 'axe: ' + e.message.split('\n')[0] }); + console.error(` ✗ ${story.id} — axe error (${e.message.split('\n')[0]})`); + } + } + if (renderErrors.length) { + console.error(`\n${renderErrors.length} story/ies failed to render / axe-error:`); + for (const r of renderErrors) console.error(` - ${r.id}: ${r.reason}`); + failures.push(...renderErrors); + } + + await browser.close(); + + if (failures.length) { + console.error(`\n${failures.length} story/ies with WCAG 2.2 AA violations.`); + exitCode = 1; + } else { + console.log(`\nAll ${stories.length} stories pass WCAG 2.2 AA (axe-core).`); + } +} catch (err) { + console.error('a11y run failed:', err); + exitCode = 1; +} finally { + server.kill(); +} + +process.exit(exitCode); diff --git a/.storybook/assets/sidebar.json b/.storybook/assets/sidebar.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/.storybook/assets/sidebar.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/.storybook/contrast-audit.mjs b/.storybook/contrast-audit.mjs new file mode 100644 index 0000000..1ccc43a --- /dev/null +++ b/.storybook/contrast-audit.mjs @@ -0,0 +1,58 @@ +/** + * One-off contrast audit: runs axe color-contrast over every story (no per-story + * suppressions) and prints the unique failing foreground/background pairs with + * their ratio and the element, so the exact tokens to darken are known. + * Usage: node .storybook/contrast-audit.mjs (Storybook must be built) + */ +import { spawn } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { chromium } from 'playwright'; +import { AxeBuilder } from '@axe-core/playwright'; + +const PORT = 6023; +const BASE = `http://127.0.0.1:${PORT}`; +const server = spawn('npx', ['http-server', 'storybook-static', '-p', String(PORT), '-s', '-c-1'], { stdio: 'ignore' }); + +async function waitUp(t = 20000) { + const end = Date.now() + t; + while (Date.now() < end) { try { if ((await fetch(`${BASE}/index.json`)).ok) return; } catch {} await new Promise(r => setTimeout(r, 300)); } + throw new Error('server not up'); +} + +try { + await waitUp(); + const index = JSON.parse(readFileSync('storybook-static/index.json', 'utf8')); + const stories = Object.values(index.entries).filter(e => e.type === 'story'); + const browser = await chromium.launch(); + const page = await (await browser.newContext()).newPage(); + const pairs = new Map(); // key: fg|bg -> {fg,bg,ratio,need,stories:Set,sample} + + for (const s of stories) { + try { + await page.goto(`${BASE}/iframe.html?id=${s.id}&viewMode=story`, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(400); + const res = await new AxeBuilder({ page }).withTags(['wcag2aa', 'wcag21aa', 'wcag22aa']).options({ runOnly: ['color-contrast'] }).analyze(); + for (const v of res.violations) { + for (const n of v.nodes) { + const d = n.any?.[0]?.data || {}; + const key = `${d.fgColor}|${d.bgColor}`; + if (!pairs.has(key)) pairs.set(key, { fg: d.fgColor, bg: d.bgColor, ratio: d.contrastRatio, need: d.expectedContrastRatio, stories: new Set(), sample: (n.target || []).join(' ') }); + pairs.get(key).stories.add(s.id.split('--')[0]); + } + } + } catch { /* skip */ } + } + await browser.close(); + + const rows = [...pairs.values()].sort((a, b) => (a.ratio || 0) - (b.ratio || 0)); + console.log(`\n${rows.length} unique failing colour pairs:\n`); + for (const r of rows) { + console.log(`fg ${r.fg} on bg ${r.bg} = ${r.ratio}:1 (need ${r.need})`); + console.log(` components: ${[...r.stories].join(', ')}`); + console.log(` e.g. ${r.sample.slice(0, 80)}`); + } +} catch (e) { + console.error('audit failed:', e.message); +} finally { + server.kill(); +} diff --git a/.storybook/host-main.ts b/.storybook/host-main.ts new file mode 100644 index 0000000..504e4bd --- /dev/null +++ b/.storybook/host-main.ts @@ -0,0 +1,4 @@ +// Storybook provides its own application bootstrap. This file only exists to +// satisfy the Angular browser builder's `main` requirement for the build +// context (tsconfig, styles, scripts) that Storybook reuses. +export {}; diff --git a/.storybook/index.html b/.storybook/index.html new file mode 100644 index 0000000..1e981d3 --- /dev/null +++ b/.storybook/index.html @@ -0,0 +1,10 @@ + + + + + Storybook + + + + + diff --git a/.storybook/keyboard.mjs b/.storybook/keyboard.mjs new file mode 100644 index 0000000..21c3c2d --- /dev/null +++ b/.storybook/keyboard.mjs @@ -0,0 +1,287 @@ +/** + * Headless keyboard-interaction gate for Storybook (WCAG 2.1.1 keyboard, + * 2.1.2 no trap, 2.4.3/2.4.7 focus — see ACCESSIBILITY.md §4/§7). + * + * Unlike `.storybook/a11y.mjs` (structural axe rules run identically over + * every story), keyboard *behaviour* is control-specific: Tab/Enter/Esc/Arrow + * keys are expected to do different things on a switch vs. a combobox vs. a + * dialog. So this is a small, explicit table of {story, check(page)} cases — + * one (or a few) representative story per interaction pattern used across + * ca-ng-components / ca-ng-components-extra — rather than a generic sweep. + * + * Each check drives real keyboard events (`page.keyboard.press`, which + * dispatches trusted keydown/keyup — unlike `dispatchEvent`, this also + * triggers native behaviour on real form controls) and asserts the resulting + * DOM state (aria-*, focus, visibility). A case that throws is a failure. + * + * Same rationale as a11y.mjs for not using @storybook/test-runner: it pulls + * jest 30, which conflicts with the monorepo's jest 29 unit tests. + * + * Usage: build Storybook first, then `node .storybook/keyboard.mjs` + * (wrapped by `npm run test:keyboard`). + */ +import { spawn } from 'node:child_process'; +import { chromium } from 'playwright'; + +const STATIC_DIR = 'storybook-static'; +const PORT = 6007; +const BASE = `http://127.0.0.1:${PORT}`; + +function startServer() { + return spawn('npx', ['http-server', STATIC_DIR, '-p', String(PORT), '-s', '-c-1'], { stdio: 'ignore' }); +} + +async function waitForServer(timeoutMs = 20000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const res = await fetch(`${BASE}/index.json`); + if (res.ok) return; + } catch { /* not up yet */ } + await new Promise((r) => setTimeout(r, 300)); + } + throw new Error(`Storybook static server did not start on ${BASE}`); +} + +async function goto(page, id) { + await page.goto(`${BASE}/iframe.html?id=${id}&viewMode=story`, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => { + const r = document.querySelector('#storybook-root'); + return (r && r.children.length > 0) || !!document.querySelector('[role="dialog"], .cdk-overlay-container *'); + }, { timeout: 8000 }); + await page.waitForTimeout(300); +} + +// `page.keyboard.press()` resolves once the DOM event is dispatched, but +// Angular's change detection (zone flush) that reacts to it runs on a later +// task — reading DOM state immediately after can observe stale attributes. +// Every case presses through this helper instead of calling +// `page.keyboard.press` directly, so state reads are never racing CD. +async function press(page, key) { + await page.keyboard.press(key); + await page.waitForTimeout(150); +} + +async function focus(locator) { + await locator.focus(); + await locator.page().waitForTimeout(150); +} + +/** + * One check per interaction *pattern*, not per component — e.g. sh-combo + * inherits sh-select's Arrow/Enter/Escape handling, so only sh-select is + * covered explicitly; a divergence would already be caught by axe/unit tests. + */ +const CASES = [ + { + name: 'sh-toggle — Enter and Space both toggle the switch', + id: 'ng-components-toggle--default', + async run(page) { + const el = page.locator('.toggle[role="switch"]').first(); + await focus(el); + const before = await el.getAttribute('aria-checked'); + await press(page, 'Enter'); + const afterEnter = await el.getAttribute('aria-checked'); + if (afterEnter === before) throw new Error(`Enter did not toggle aria-checked (stayed ${before})`); + await press(page, ' '); + const afterSpace = await el.getAttribute('aria-checked'); + if (afterSpace === afterEnter) throw new Error(`Space did not toggle aria-checked back (stayed ${afterEnter})`); + }, + }, + { + name: 'sh-checkbox — Space toggles the native checkbox', + id: 'ng-components-checkbox--default', + async run(page) { + const input = page.locator('input[type="checkbox"]').first(); + await focus(input); + const before = await input.isChecked(); + await press(page, ' '); + const after = await input.isChecked(); + if (after === before) throw new Error(`Space did not toggle the checkbox (stayed ${before})`); + }, + }, + { + name: 'sh-radio — ArrowDown moves focus+selection, Space selects the focused option', + id: 'ng-components-radio--default', + async run(page) { + // The `.radio[role=radio]` divs carry no `id` (only their hidden native + // `` sibling does), so focus is tracked by element identity, not id. + const options = page.locator('.radio[role="radio"]'); + const count = await options.count(); + if (count < 2) throw new Error('story needs >=2 options to test Arrow navigation'); + await focus(options.nth(0)); + await press(page, 'ArrowDown'); + const focusedSecond = await options.nth(1).evaluate((el) => el === document.activeElement); + if (!focusedSecond) throw new Error('ArrowDown did not move focus to the next option'); + await press(page, ' '); + const checkedAfter = await options.nth(1).getAttribute('aria-checked'); + if (checkedAfter !== 'true') throw new Error(`Space did not select the focused option (aria-checked=${checkedAfter})`); + }, + }, + { + name: 'sh-checkgroup — Space toggles a single item without affecting siblings', + id: 'ng-components-checkgroup--default', + async run(page) { + const items = page.locator('.checkbox[role="checkbox"]'); + if (await items.count() < 1) throw new Error('no checkbox items rendered'); + const first = items.nth(0); + await focus(first); + const before = await first.getAttribute('aria-checked'); + await press(page, ' '); + const after = await first.getAttribute('aria-checked'); + if (after === before) throw new Error(`Space did not toggle the item (stayed ${before})`); + }, + }, + { + name: 'sh-tabs — ArrowRight moves to and selects the next tab, Home jumps to the first', + id: 'ng-components-tabs--default', + async run(page) { + const tabs = page.locator('[role="tab"]'); + const count = await tabs.count(); + if (count < 2) throw new Error('story needs >=2 tabs to test Arrow navigation'); + const focusable = page.locator('[role="tab"][tabindex="0"]').first(); + await focus(focusable); + await press(page, 'ArrowRight'); + const focusedId = await page.evaluate(() => document.activeElement?.id); + const selectedNow = await page.locator(`#${focusedId}`).getAttribute('aria-selected'); + if (selectedNow !== 'true') throw new Error(`ArrowRight did not select the newly-focused tab (aria-selected=${selectedNow})`); + await press(page, 'Home'); + const firstTabId = await tabs.nth(0).getAttribute('id'); + const focusedAfterHome = await page.evaluate(() => document.activeElement?.id); + if (focusedAfterHome !== firstTabId) throw new Error(`Home did not move focus to the first tab (focus=${focusedAfterHome})`); + }, + }, + { + name: 'sh-select — closed ArrowDown cycles the value, Alt+ArrowDown opens the listbox, Enter commits, Escape closes', + id: 'ng-components-select--default', + async run(page) { + const combobox = page.locator('[role="combobox"]').first(); + await focus(combobox); + // Closed + plain ArrowDown cycles the committed value directly (native + // OS ` is associated with its `