Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
155c141
Add a11y config and audit module to @surfnet/storybook-config
sjoerdbeentjes Jun 22, 2026
3efa711
Wire WCAG 2.1 AA audit into React and Angular Storybooks
sjoerdbeentjes Jul 6, 2026
05d383b
Add test:a11y workspace script, Turbo task, and CI step
sjoerdbeentjes Jun 22, 2026
846c602
Reorder storybook:serve before test:a11y in package scripts
sjoerdbeentjes Jul 6, 2026
5ae42c8
Write a11y reports to git-ignored .a11y-report dir
sjoerdbeentjes Jun 22, 2026
46b9672
Group a11y comment by component → theme → mode
sjoerdbeentjes Jun 22, 2026
89e8005
Split component and variation into h4/h5 in a11y comment
sjoerdbeentjes Jun 23, 2026
de5e010
Collapse theme-independent a11y findings to one per variation
sjoerdbeentjes Jun 23, 2026
e0f82c9
Add link-in-text-block WCAG ref (1.4.1) to a11y comment
sjoerdbeentjes Jul 6, 2026
93eec90
Move playwright dep from react to storybook-config, scope CI install …
sjoerdbeentjes Jun 23, 2026
c170730
Stabilise a11y audit and trim comments
sjoerdbeentjes Jul 6, 2026
643bae6
Adopt @surfnet/curve-* package names; note headless run in a11y CI
sjoerdbeentjes Jul 6, 2026
8599669
Inline a11y-comment script call in CI, drop a11y:comment workspace sc…
sjoerdbeentjes Jul 6, 2026
bba3d48
Capture and embed a11y violation screenshots
sjoerdbeentjes Jul 6, 2026
a2532d0
Drop fixed width on a11y comment screenshots
sjoerdbeentjes Jul 6, 2026
98fe989
Update playwright to 1.61.1
sjoerdbeentjes Jul 6, 2026
046e5c2
chore: Update tokens from Figma
anneke Sep 1, 2026
cf86998
fix: always give progress component a accessible name
anneke Sep 1, 2026
834b43f
fix: use primary-strong for badges to increase contrast
anneke Sep 1, 2026
f736774
fix: add aria-attributes to combobox trigger button
anneke Sep 1, 2026
051e307
fix: add aria-attributes to resizable handle
anneke Sep 1, 2026
48e06f3
fix: combobox change breaking linter
anneke Sep 1, 2026
8d45420
fix: combobox clear button missing label
anneke Sep 1, 2026
12d9567
fix: curve data table needs label on button
anneke Sep 1, 2026
6e56220
fix: Add aria-labels to buttons in input-group
anneke Sep 1, 2026
78f29d0
fix: give nativeSelect accessible name
anneke Sep 1, 2026
591849d
fix: input-OTP accessibility
anneke Sep 1, 2026
6dad9fd
fix: breadcrumb accessibility, removed obsolete attributes
anneke Sep 1, 2026
31bfa29
fix: add roles to command element
anneke Sep 1, 2026
0d7fb1b
fix: add tabindex 0 to scroll element
anneke Sep 1, 2026
ce284e2
fixup fix: add roles to command element
anneke Sep 1, 2026
1ab5d36
fix: set label on button
anneke Sep 1, 2026
5e6b7d3
fix: improve layout of accessibility feedback
anneke Sep 1, 2026
0076bbb
fix: remove Command (temporarily?)
anneke Sep 1, 2026
950bc0e
fix: add missing aria-labels
anneke Sep 1, 2026
01a8638
fix: improve layout of accessibility feedback
anneke Sep 1, 2026
f1f54e9
fix: improve layout of accessibility feedback
anneke Sep 1, 2026
8b70bbb
fix: improve layout of accessibility feedback
anneke Sep 1, 2026
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
6 changes: 6 additions & 0 deletions .changeset/badge-link-primary-strong.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@surfnet/curve-react': patch
'@surfnet/curve-angular': patch
---

Use `--primary-strong` for link-style text (Badge `link`, and inline links in Field, Empty, and Item) so it meets contrast on the page background. Filled Primary is unchanged.
6 changes: 6 additions & 0 deletions .changeset/combobox-trigger-aria.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@surfnet/curve-angular': patch
'@surfnet/curve-react': patch
---

Wire combobox input trigger button `aria-expanded`, `aria-haspopup="listbox"`, and `aria-controls` to the listbox id. Add default `aria-label="Clear"` on the React clear button.
5 changes: 5 additions & 0 deletions .changeset/command-accessible-label.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@surfnet/curve-react': patch
---

Give Command a default accessible label ("Type a command or search...") so cmdk no longer renders an empty visually hidden label.
6 changes: 6 additions & 0 deletions .changeset/command-empty-live-region.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@surfnet/curve-angular': patch
'@surfnet/curve-react': patch
---

Keep Command empty state mounted in the DOM and announce it with `aria-live="polite"` and `aria-atomic`. Use `role="option"` (`aria-disabled`, `aria-selected="false"`) so the empty message is a valid listbox child, and mark separators as presentational so listboxes only expose `option` / `group` children.
5 changes: 5 additions & 0 deletions .changeset/command-input-aria-label.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@surfnet/curve-angular': patch
---

Forward `aria-label` and `aria-labelledby` from Command Input onto the underlying combobox `<input>` so the accessible name is on the field assistive technology uses, not the wrapper.
5 changes: 5 additions & 0 deletions .changeset/data-table-action-cell-aria.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@surfnet/curve-angular': patch
---

Give the data table action-cell icon button and each menu item accessible names so the icon-only trigger and actions are announced by screen readers.
5 changes: 5 additions & 0 deletions .changeset/input-otp-aria-label.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@surfnet/curve-angular': patch
---

Forward `aria-label` from Input OTP onto the underlying input so unlabeled examples (and consumers) can name the field for assistive technology.
6 changes: 6 additions & 0 deletions .changeset/input-otp-complete-live-region.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@surfnet/curve-angular': patch
'@surfnet/curve-react': patch
---

Announce Input OTP completion to screen readers with a polite live region so pasting a full code is perceivable.
5 changes: 5 additions & 0 deletions .changeset/resizable-handle-aria.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@surfnet/curve-angular': patch
---

Set `aria-grabbed` and `aria-valuenow` on Resizable handles, and expose an `isDragging` signal on the group and handle.
6 changes: 6 additions & 0 deletions .changeset/scroll-area-tabindex.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@surfnet/curve-angular': patch
'@surfnet/curve-react': patch
---

Always set `tabindex="0"` on ScrollArea so the scrollable region is keyboard-accessible, including before overflow is measured.
50 changes: 50 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ concurrency:
jobs:
checks:
runs-on: ubuntu-latest
# The a11y steps write a PR comment and push violation screenshots to the
# a11y-screenshots branch.
permissions:
contents: write
pull-requests: write
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
steps:
- uses: actions/checkout@v4

Expand Down Expand Up @@ -42,3 +49,46 @@ jobs:
- run: pnpm lint

- run: pnpm build

# The audit runs @storybook/test-runner, which drives Playwright Chromium
# headless (no display needed on CI). Install it once, scoped to
# @surfnet/curve-storybook-config which pins playwright, so both framework
# runs share one browser.
- name: Install Playwright Chromium

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hoe is de performance? Ik zou verwachten dat we in een CI-stap een headless browser gebruiken.

run: pnpm --filter @surfnet/curve-storybook-config exec playwright install --with-deps chromium

# Headless axe sweep of every story across all themes/modes. Fast: the
# test phase is a few seconds per framework once Storybook is built (the
# build-storybook step dominates). Report-only for now; flip to blocking by
# removing `continue-on-error`.
- name: Accessibility audit (WCAG 2.1 AA)
continue-on-error: true
run: pnpm test:a11y

- name: Upload a11y reports
if: always()
uses: actions/upload-artifact@v4
with:
name: a11y-reports
path: packages/*/.a11y-report/**
if-no-files-found: ignore

# Pushes violating-element screenshots to the `a11y-screenshots` branch
# so the PR comment below can embed them by raw.githubusercontent.com
# URL. PR-only: there's no PR to attach screenshots to on a push build.
- name: Publish a11y screenshots
if: always() && github.event_name == 'pull_request'
run: ./scripts/publish-a11y-screenshots.sh

# Summarise the JSON reports into .a11y-report/comment.md.
- name: Summarise a11y findings
if: always()
run: pnpm exec jiti scripts/a11y-comment.ts

# One sticky comment, updated in place.
- name: Comment a11y findings on PR
if: always() && github.event_name == 'pull_request'
uses: marocchino/sticky-pull-request-comment@v2
with:
header: a11y-audit
path: .a11y-report/comment.md
65 changes: 34 additions & 31 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,31 +1,34 @@
# dependencies
node_modules
.pnpm-store

# secrets
.env
.env.local

# build output
dist
storybook-static

# turbo
.turbo

# angular
.angular

# logs
*.log
npm-debug.log*
pnpm-debug.log*

# editor / os
.DS_Store
.vs
!.vscode
.vscode/*
!.vscode/extensions.json
!.vscode/mcp.json
.idea
# dependencies
node_modules
.pnpm-store

# secrets
.env
.env.local

# build output
dist
storybook-static

# accessibility audit reports
.a11y-report

# turbo
.turbo

# angular
.angular

# logs
*.log
npm-debug.log*
pnpm-debug.log*

# editor / os
.DS_Store
.vs
\!.vscode
.vscode/*
\!.vscode/extensions.json
\!.vscode/mcp.json
.idea
30 changes: 30 additions & 0 deletions docs/decision-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ the replacement.
| 17 | [Prove it in a real app](#adr-017--prove-it-in-a-real-app) | Proposed | 2026-06-30 |
| 18 | [Relative imports for vendored helm cross-references](#adr-018--relative-imports-for-vendored-helm-cross-references) | Accepted | 2026-07-01 |
| 19 | [Only add components available in both frameworks](#adr-019--only-add-components-available-in-both-frameworks) | Accepted | 2026-07-13 |
| 20 | [Keep Command off Storybook until we use it](#adr-020--keep-command-off-storybook-until-we-use-it) | Accepted | 2026-09-01 |

### Open questions (not yet decided)

Expand Down Expand Up @@ -452,3 +453,32 @@ here than let per-framework drift creep in one component at a time.
- **Typography** — decide whether this becomes a real component (e.g. `Heading`/`Text`
wrapper components with contract-enforced size/weight scales) or stays documentation-only
(a Storybook foundations page, like the existing Design Tokens stories) before building it.

---

## ADR-020 — Keep Command off Storybook until we use it

**Status:** Accepted · **Date:** 2026-09-01

**Context.** Command is vendored in both frameworks (`@surfnet/curve-react` and
`@surfnet/curve-angular`) because shadcn and Spartan ship it as a primitive. We don't
intend to offer it as a public, documented component yet — there is no product use for
a command palette — but ADR-016/013 would otherwise require a Storybook story for every
shipped component. Leaving those stories in place surfaces Command in the catalog and
the a11y audit as if it were ready for consumers.

**Decision.** Keep the Command **implementation** in both packages (and still export it),
but **do not document it in Storybook**. The React and Angular story files were removed
so Command stays out of the sidebar, Docs, and the a11y sweep.

**Rationale.** Storybook is the living catalog (ADR-013). Showing a component we are not
ready to stand behind is more confusing than keeping the code "under water" until there
is a real use. Deleting stories rather than deleting the component avoids re-vendoring
later.

**Consequences.** Command is an explicit exception to "every component gets a story"
(ADR-016). Bring it back when we decide to use it: restore
`packages/react/src/components/ui/command/command.stories.tsx` and
`packages/angular/src/lib/ui/command/src/lib/hlm-command.stories.ts` (git history has the
last versions), covering the full surface the way the other components do. Until then,
treat Command as internal plumbing, not a documented Curve component.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"storybook": "turbo run storybook",
"build-storybook": "turbo run build-storybook",
"lint": "turbo run lint",
"test:a11y": "turbo run test:a11y:ci",
"format": "prettier --write \"**/*.{ts,tsx,js,mjs,cjs,json,md,css,html}\"",
"format:check": "prettier --check \"**/*.{ts,tsx,js,mjs,cjs,json,md,css,html}\"",
"changeset": "changeset",
Expand Down
2 changes: 2 additions & 0 deletions packages/angular/.storybook/preview.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
a11yParameters,
frameworkGlobalTypes,
frameworkSwitcher,
sharedParameters,
Expand All @@ -16,6 +17,7 @@ export default {
decorators: [frameworkSwitcher('angular'), themeSwitcher()],
parameters: {
...sharedParameters,
...a11yParameters,
// Must be a literal (Storybook reads it via static analysis, not
// execution). Keep in sync with packages/react/.storybook/preview.ts.
options: {
Expand Down
12 changes: 12 additions & 0 deletions packages/angular/.storybook/test-runner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { TestRunnerConfig } from '@storybook/test-runner';

import { runStoryA11yAudit } from '@surfnet/curve-storybook-config/test-runner';

// Delegate to the shared audit so React and Angular stay in lockstep.
const config: TestRunnerConfig = {
async postVisit(page, context) {
await runStoryA11yAudit(page, context);
},
};

export default config;
9 changes: 8 additions & 1 deletion packages/angular/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@
"lint": "ngc --noEmit -p tsconfig.json",
"storybook": "ng run angular:storybook",
"build-storybook": "ng run angular:build-storybook",
"fix-helm-imports": "jiti scripts/rewrite-helm-imports.ts"
"fix-helm-imports": "jiti scripts/rewrite-helm-imports.ts",
"storybook:serve": "http-server storybook-static -p 6007 --silent",
"test:a11y": "test-storybook --url http://127.0.0.1:6007 --testTimeout 180000",
"test:a11y:ci": "start-server-and-test storybook:serve http://127.0.0.1:6007 test:a11y"
},
"peerDependencies": {
"@angular/common": "^22.0.0",
Expand Down Expand Up @@ -63,15 +66,19 @@
"@storybook/addon-a11y": "10.4.5",
"@storybook/addon-docs": "10.4.5",
"@storybook/angular": "10.4.5",
"@storybook/test-runner": "0.24.4",
"@surfnet/curve-contracts": "workspace:*",
"@surfnet/curve-storybook-config": "workspace:*",
"@surfnet/curve-tokens": "workspace:*",
"@surfnet/curve-typescript-config": "workspace:*",
"@tailwindcss/cli": "4.3.1",
"@tailwindcss/postcss": "4.3.1",
"axe-playwright": "2.2.2",
"http-server": "14.1.1",
"jiti": "2.7.0",
"ng-packagr": "22.0.0",
"rxjs": "7.8.2",
"start-server-and-test": "3.0.11",
"storybook": "10.4.5",
"tailwindcss": "4.3.1",
"tw-animate-css": "1.4.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/angular/src/lib/ui/badge/src/lib/hlm-badge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const badgeVariantClasses = {
'bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20',
outline: 'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground',
ghost: 'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50',
link: 'text-primary underline-offset-4 hover:underline',
link: 'text-primary-strong underline-offset-4 hover:underline',
} satisfies Record<BadgeVariantName, string>;

const badgeVariants = cva(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,8 @@ import type { ClassValue } from 'clsx';
providers: [provideIcons({ phosphorDotsThree })],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
[class]="_computedClass()"
>
<span data-slot="breadcrumb-ellipsis" aria-hidden="true" [class]="_computedClass()">
<ng-icon hlm size="sm" name="phosphorDotsThree" />
<span class="sr-only">{{ srOnlyText() }}</span>
</span>
`,
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@ import { Directive, input } from '@angular/core';
},
})
export class HlmBreadcrumb {
public readonly ariaLabel = input<string>('breadcrumb', { alias: 'aria-label' });
public readonly ariaLabel = input<string>('You are here: ', { alias: 'aria-label' });
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export const Default: Story = {
render: (args) => ({
props: args,
template: `
<hlm-checkbox inputId="testCheckbox" checked [disabled]="disabled" />
<hlm-checkbox inputId="testCheckbox" checked [disabled]="disabled" aria-label="Checkbox" />
`,
}),
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { HlmComboboxChipRemove } from './hlm-combobox-chip-remove';
<ng-content />

@if (showRemove()) {
<button hlmComboboxChipRemove>
<button hlmComboboxChipRemove aria-label="Remove">
<ng-icon name="phosphorX" />
</button>
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@ import { BooleanInput } from '@angular/cdk/coercion';
import { booleanAttribute, ChangeDetectionStrategy, Component, input } from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { phosphorCaretDown, phosphorX } from '@ng-icons/phosphor-icons/regular';
import { BrnComboboxImports, BrnComboboxPopoverTrigger } from '@spartan-ng/brain/combobox';
import {
BrnComboboxImports,
BrnComboboxPopoverTrigger,
injectBrnComboboxBase,
} from '@spartan-ng/brain/combobox';
import { HlmInputGroupImports } from '../../../input-group/src';
import { injectHlmComboboxListboxId } from './hlm-combobox-listbox-id';

@Component({
selector: 'hlm-combobox-input',
Expand All @@ -26,9 +31,13 @@ import { HlmInputGroupImports } from '../../../input-group/src';
<hlm-input-group-addon align="inline-end">
@if (showTrigger()) {
<button
aria-label="Open combobox"
aria-haspopup="listbox"
brnComboboxPopoverTrigger
hlmInputGroupButton
data-slot="input-group-button"
[attr.aria-expanded]="_isExpanded() ? 'true' : 'false'"
[attr.aria-controls]="_listboxId"
[disabled]="comboboxInput.disabled()"
size="icon-xs"
variant="ghost"
Expand All @@ -46,6 +55,7 @@ import { HlmInputGroupImports } from '../../../input-group/src';
[disabled]="comboboxInput.disabled()"
size="icon-xs"
variant="ghost"
aria-label="Clear"
>
<ng-icon name="phosphorX" />
</button>
Expand All @@ -59,6 +69,10 @@ import { HlmInputGroupImports } from '../../../input-group/src';
export class HlmComboboxInput {
private static _id = 0;

private readonly _combobox = injectBrnComboboxBase();
protected readonly _listboxId = injectHlmComboboxListboxId();
protected readonly _isExpanded = this._combobox.isExpanded;

public readonly inputId = input<string>(`hlm-combobox-input-${HlmComboboxInput._id++}`);
public readonly placeholder = input<string>('');

Expand Down
Loading
Loading