Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion ui/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,21 @@ module.exports = {
],
"@angular-eslint/template/prefer-control-flow": [
"off"
]
],
// Accessibility rules (see ui/docs/accessibility.md).
// A rule runs at "warn" while legacy templates still trigger it
// and is switched to "error" once the codebase is clean for it.
"@angular-eslint/template/alt-text": ["warn"],
"@angular-eslint/template/click-events-have-key-events": ["warn"],
"@angular-eslint/template/elements-content": ["warn"],
"@angular-eslint/template/interactive-supports-focus": ["warn"],
"@angular-eslint/template/label-has-associated-control": ["warn"],
"@angular-eslint/template/mouse-events-have-key-events": ["warn"],
"@angular-eslint/template/no-autofocus": ["off"],
"@angular-eslint/template/no-distracting-elements": ["warn"],
"@angular-eslint/template/role-has-required-aria": ["warn"],
"@angular-eslint/template/table-scope": ["warn"],
"@angular-eslint/template/valid-aria": ["warn"]
}
}
]
Expand Down
107 changes: 107 additions & 0 deletions ui/docs/accessibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# CDS UI accessibility

Target: **WCAG 2.2 Level AA**. Screenreader support is verified with **NVDA** (Chromium/Firefox).

This document describes how accessibility is implemented in the UI and the rules to
follow when adding or changing templates.

## How it works

### Page structure
- `<html lang="en">` is set in `src/index.html`.
- The app shell (`src/app/app.component.html`) exposes landmarks via roles on the
existing layout elements: `role="banner"` (navbar), `role="navigation"` (menu),
`role="main"` + `id="main-content"` (content container) telling screenreader users where they are.
- A "Skip to main content" link is the first focusable element; it is rendered
off-screen and only becomes visible on keyboard focus.
- On navigation, the new page title is announced via CDK `LiveAnnouncer`
(`app.component.ts`). Page titles come from route `data.title`.

### Screenreader-only content
- The `cds-sr-only` class (`src/styles.scss`) renders text visually hidden but
available to assistive technology. Use it for supplementary text that has no
visible equivalent.

### Announcements
- Toasts sent through `ToastService` (`shared/toast/ToastService.ts`) are announced
automatically: `'polite'` for success/info, `'assertive'` for errors.
- Other async outcomes (e.g. run status changes) are announced with `LiveAnnouncer`
(`@angular/cdk/a11y`), using the same politeness convention.
- Loading spinners and banners expose `role="status"`.

### Interactive elements
- The `appClickable` directive (`shared/directives/clickable.directive.ts`) makes a
non-interactive element with a `(click)` handler keyboard-accessible: it adds
`role="button"`, `tabindex="0"` and Enter/Space activation. It exists to retrofit
legacy markup that must not change visually; new code uses real `<button>`/`<a>`.
- Shared tabs (`shared/tabs/`) implement the APG tab pattern: `role="tablist"`/`tab`,
`aria-selected`, arrow-key navigation.
- The shared data table (`shared/table/`) exposes `aria-sort` on sortable headers
and supports keyboard sorting.
- Run log steps are disclosures (`aria-expanded`); streaming log containers use
`role="log"`.
- Status icons carry their state as text (`aria-label="Status: …"`).

### Workflow graph (`libs/workflow-graph`)
- The graph container is focusable and explains its keyboard model via
`aria-roledescription`/`aria-label`.
- Arrow keys move real DOM focus between nodes (roving `tabindex`); each node has an
accessible name including job, status and stage. Selection changes are announced.
- `+`/`-`/`0` zoom in/out and re-center. Key handling is scoped to the graph and
does not intercept keys while typing in inputs.

### Forms
- Every form control has an `id` prefixed with `cds-field-`, associated to its
label via `<nz-form-label nzFor="…">`. Controls without a visible label carry an
`aria-label`.

## Rules for new/changed templates

1. **Form controls**: every control gets an `id` with the `cds-field-` prefix and its
`<nz-form-label nzFor="…">` pointing at it. Controls without a visible label get an
`aria-label`. A placeholder is never the only label.
2. **Icon-only buttons**: always `aria-label="…"` on the button; decorative icons inside
any labelled control get `aria-hidden="true"`. Keep `title`/`nz-tooltip` for sighted users.
3. **Never put `(click)` on a non-interactive element** (`div`, `span`, `li`, `td`, `i`,
`a` without `href`). Use a real `<button>`/`<a [routerLink]>` for new code. Only for
existing markup that must not change visually, use the `appClickable` directive.
4. **Screenreader-only text**: use the `cds-sr-only` class.
5. **Announce async outcomes** with `LiveAnnouncer`: `'polite'` for success/info,
`'assertive'` for errors. Toasts sent through `ToastService` are announced
automatically — do not double-announce.
6. **State must be conveyed in text**, not only by icon or color: status icons carry
`aria-label="Status: …"`; disclosure elements carry `aria-expanded`; sortable table
headers carry `aria-sort`.
7. **Landmarks and headings**: each page exposes exactly one level-1 heading and lives
inside the `role="main"` container provided by the app shell. Where the visual title
is a styled non-heading element, use `role="heading" aria-level="1"` rather than
changing the element.
8. **Dialogs**: every modal needs an accessible name (`nzTitle` or `aria-labelledby`);
focus must return to the triggering control on close.

## Linting

`@angular-eslint/template/*` accessibility rules are enabled in `.eslintrc.js`. Rules
still reporting warnings on legacy templates run at `warn`; a rule is switched to
`error` once the codebase is clean for it. Do not introduce new warnings.

## Known limitations

Accessibility fixes that require visible UI changes are out of scope of the
attribute-only approach above and still open:

- No unified `:focus-visible` styling — newly focusable elements use the browser's
default focus ring.
- Retrofitted elements (fake links, `role="heading"` divs, landmark roles) are not
yet swapped for their native elements (`<button>`, `<h1>`–`<h4>`, `<main>`/`<nav>`).
- The workflow graph has no alternative list/table view.
- Drag-and-drop reordering (pipeline stages, action steps) has no keyboard alternative.
- CodeMirror 5 editors (`shared/codemirror.ts`) are largely inaccessible; Monaco
(`nz-code-editor`) is the accessible editor and is labeled via `ariaLabel`.

## Manual NVDA smoke pass (run before releases)

signin → home → select project → run list → open run → open failing job logs →
settings/profile. Verify: page title announced on navigation, landmarks (`D`) and
headings (`H`) navigate, every control reachable with Tab/arrows and announced with
role + name + state, toasts and run-status changes are spoken.
98 changes: 98 additions & 0 deletions ui/libs/workflow-graph/src/lib/graph.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@ export class GraphComponent implements AfterViewInit, OnDestroy {
hooksOn: any;
selectedNodeNavigationKey: string;
navigationGraph: NavigationGraph;
liveAnnouncement: string = '';
graphSummary: string = '';
direction: GraphDirection = GraphDirection.HORIZONTAL;
ready: boolean;
hasStages = false;
Expand Down Expand Up @@ -186,6 +188,30 @@ export class GraphComponent implements AfterViewInit, OnDestroy {

@HostListener('window:keydown', ['$event'])
handleKeyDown(event: KeyboardEvent) {
const activeElement = document.activeElement as HTMLElement;
const focusInGraph = !!activeElement && this.host.nativeElement.contains(activeElement);
if (activeElement && !focusInGraph) {
const tag = activeElement.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || activeElement.isContentEditable) {
return;
}
}

if (focusInGraph) {
switch (event.key) {
case '+':
case '=':
this.graph?.zoomIn();
return;
case '-':
this.graph?.zoomOut();
return;
case '0':
this.graph?.center();
return;
}
}

// Selection-mode shortcuts (handled regardless of navigationDisabled)
switch (event.key) {
case 'Shift':
Expand Down Expand Up @@ -231,9 +257,66 @@ export class GraphComponent implements AfterViewInit, OnDestroy {
this.selectedNodeNavigationKey = newSelected;
this.graph.selectNode(this.selectedNodeNavigationKey);
this.graph.centerNode(this.selectedNodeNavigationKey, true);
this.graph.focusNode(this.selectedNodeNavigationKey);
this.announceSelection(this.selectedNodeNavigationKey);
}
}

announceSelection(navigationKey: string): void {
this.liveAnnouncement = this.describeNode(navigationKey);
this._cd.markForCheck();
}

describeNode(navigationKey: string): string {
for (const n of this.nodes) {
if (n.type === GraphNodeType.Stage) {
for (const sub of n.sub_graph) {
const description = this.describeGraphNode(sub, navigationKey);
if (description) {
return description;
}
}
} else {
const description = this.describeGraphNode(n, navigationKey);
if (description) {
return description;
}
}
}
return navigationKey;
}

describeGraphNode(n: GraphNode, navigationKey: string): string {
const baseKey = n.job?.stage ? `${n.job.stage}-${n.name}` : n.name;
if (n.type === GraphNodeType.Matrix) {
if (!navigationKey.startsWith(`${baseKey}-`)) {
return null;
}
const matrixKey = navigationKey.substring(baseKey.length + 1);
let description = `Job ${n.name} ${matrixKey}`;
const run = (n.runs ?? []).find(r =>
Object.keys(r.matrix).sort().map(k => `${k}: ${r.matrix[k]}`).join(', ') === matrixKey);
if (run?.status) {
description += `, status ${run.status}`;
}
if (n.job?.stage) {
description += `, stage ${n.job.stage}`;
}
return description;
}
if (navigationKey !== baseKey) {
return null;
}
let description = `Job ${n.name}`;
if (n.run?.status) {
description += `, status ${n.run.status}`;
}
if (n.job?.stage) {
description += `, stage ${n.job.stage}`;
}
return description;
}

@HostListener('window:keyup', ['$event'])
handleKeyUp(event: KeyboardEvent) {
if (event.key === 'Shift' && this.selectionModeActive) {
Expand Down Expand Up @@ -330,6 +413,21 @@ export class GraphComponent implements AfterViewInit, OnDestroy {
};
});
});

this.computeGraphSummary();
}

computeGraphSummary(): void {
if (!this._runJobs || this._runJobs.length === 0) {
this.graphSummary = '';
return;
}
const counts: { [status: string]: number } = {};
this._runJobs.forEach(j => {
counts[j.status] = (counts[j.status] ?? 0) + 1;
});
const details = Object.keys(counts).map(s => `${counts[s]} ${s.toLowerCase()}`).join(', ');
this.graphSummary = `Workflow graph with ${this._runJobs.length} jobs: ${details}.`;
}

initGraph() {
Expand Down
11 changes: 8 additions & 3 deletions ui/libs/workflow-graph/src/lib/graph.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
<div class="svg-wrapper" #svgGraph></div>
<div class="svg-wrapper" #svgGraph tabindex="0" role="application"
aria-label="Workflow graph. Use arrow keys to move between jobs, Enter to open, Shift to select multiple."></div>
<span aria-live="polite"
style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)">{{liveAnnouncement}}</span>
<span style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)">{{graphSummary}}</span>

@if (_workflowRun) {
<div class="hooks">
Expand Down Expand Up @@ -38,7 +42,8 @@
}

<div class="controls">
<button nz-button (click)="changeDirection()" nzSize="default">
<button nz-button (click)="changeDirection()" nzSize="default"
[attr.aria-label]="direction === 'horizontal' ? 'Rotate graph right' : 'Rotate graph left'">
@if (direction === 'horizontal') {
<span nz-icon nzType="rotate-right" nzTheme="outline"
title="Rotate graph right"></span>
Expand All @@ -48,7 +53,7 @@
title="Rotate graph left"></span>
}
</button>
<button nz-button (click)="clickOrigin()" nzSize="default" title="Center graph">
<button nz-button (click)="clickOrigin()" nzSize="default" title="Center graph" aria-label="Center graph">
<span nz-icon nzType="aim" nzTheme="outline"></span>
</button>
</div>
38 changes: 38 additions & 0 deletions ui/libs/workflow-graph/src/lib/graph.lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,20 @@ export class WorkflowV2Graph<T extends InteractiveNode> {
this.previousTransformed = null;
}

zoomIn(): void {
if (!this.zoom || !this.svg) {
return;
}
this.svg.call(this.zoom.scaleBy, 1.2);
}

zoomOut(): void {
if (!this.zoom || !this.svg) {
return;
}
this.svg.call(this.zoom.scaleBy, 1 / 1.2);
}

/**
* Center and zoom the viewport on a specific stage node.
* Computes optimal scale for the stage bounding box, capped at maxOriginScale.
Expand Down Expand Up @@ -645,6 +659,30 @@ export class WorkflowV2Graph<T extends InteractiveNode> {
this.nodesComponent.forEach(n => n.instance.selectNode(navigationKey));
}

focusNode(navigationKey: string): void {
for (let i = 0; i < this.nodes.length; i++) {
if (this.nodes[i].type === GraphNodeType.Stage) {
continue;
}
const component = this.nodesComponent.get(`node-${this.nodes[i].key}`);
if (component && component.instance.match(navigationKey)) {
const instance = component.instance as any;
if (instance.focusNode) {
instance.focusNode(navigationKey);
}
return;
}
}
for (let i = 0; i < this.nodes.length; i++) {
if (this.nodes[i].type === GraphNodeType.Stage) {
const subGraph = (this.nodesComponent.get(`node-${this.nodes[i].key}`)?.instance as any)?.graph as WorkflowV2Graph<T>;
if (subGraph) {
subGraph.focusNode(navigationKey);
}
}
}
}

activateNode(navigationKey: string): void {
this.nodesComponent.forEach(n => n.instance.activateNode(navigationKey));
}
Expand Down
2 changes: 1 addition & 1 deletion ui/libs/workflow-graph/src/lib/node/fork-join-node.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<div class="node pointing" [class.highlight]="highlight" (mouseenter)="onMouseEnter()" (mouseleave)="onMouseOut()"
<div class="node pointing" aria-hidden="true" [class.highlight]="highlight" (mouseenter)="onMouseEnter()" (mouseleave)="onMouseOut()"
[class.success]="status === nodeStatusEnum.SUCCESS"
[class.inactive]="status === nodeStatusEnum.SKIPPED"
[class.fail]="status === nodeStatusEnum.FAIL">
Expand Down
25 changes: 24 additions & 1 deletion ui/libs/workflow-graph/src/lib/node/job-node.component.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnDestroy, OnInit } from '@angular/core';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, inject, Input, OnDestroy, OnInit } from '@angular/core';
import { GraphNode } from '../graph.model'
import { V2WorkflowRunJobStatus } from '../v2.workflow.run.model';
import { Subscription, concatMap, from, interval } from 'rxjs';
Expand Down Expand Up @@ -35,12 +35,35 @@ export class GraphJobNodeComponent implements OnInit, OnDestroy, InteractiveNode
conditionTooltip: string = '';

private _cd = inject(ChangeDetectorRef);
private _host = inject(ElementRef);

constructor() {
this.setHighlight.bind(this);
this.selectNode.bind(this);
}

get ariaLabel(): string {
const name = this.node?.job?.name && this.node.job.name.indexOf('${{') !== 0 ? this.node.job.name : this.node?.name;
let label = `Job ${name}`;
if (this.node?.run?.status) {
label += `, status ${this.node.run.status}`;
}
if (this.node?.job?.stage) {
label += `, stage ${this.node.job.stage}`;
}
return label;
}

focusNode(navigationKey: string): void {
if (!this.match(navigationKey)) {
return;
}
const element = this._host.nativeElement.firstElementChild as HTMLElement;
if (element) {
element.focus({ preventScroll: true });
}
}

ngOnDestroy(): void {
if (this.delaySubs) {
this.delaySubs.unsubscribe();
Expand Down
Loading