fix: keuzelijst rows become the <li> (RD-37)

app-choice-link rendered a component host between the keuzelijst <ul>
and its <li>. This broke the axe list/listitem rule for assistive
technology. Five story suppressions named WP-11 as the fix, but WP-11
closed with no open ticket left to own the defect.

choice-link now uses selector: 'li[app-choice-link]', the same
attribute-host pattern as application-link. The host carries the
keuzelijst__list-item class; the template drops its own <li>.
Position: relative stays on .keuzelijst__link so the stretched-link
overlay still resolves against the card, not the host.

aanvraag-block needed no component change: it renders a CIBG melding,
never an <li>. Only its story wrapped it in a <ul>, which is what axe
rejected. The wrapper is removed, and the four non-Concept stories are
deleted — the component's template only renders for status Concept, so
they rendered nothing.

All five a11y: { disable: true } suppressions are gone, with no
replacement. atomic-design.mdx now records that both molecules are the
<li>, kept separate for the vendored CSS they bind, not for list
semantics.

npm run ci --full passes, axe included.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-09-08 22:51:04 +02:00
co-authored by Claude Opus 5
parent 2aa343f255
commit 097e8468e0
10 changed files with 171 additions and 106 deletions
+7 -7
View File
@@ -108,13 +108,13 @@ the next person doesn't spend an afternoon re-deciding. (Deliberate CIBG-specifi
live in [CIBG gaps](?path=/docs/foundations-cibg-gap-register--docs); the FE⇄DS "same shape, different
context" cases in [Domain-driven design](?path=/docs/foundations-domain-driven-design--docs).)
| Pair | Why kept separate |
| ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `choice-link` vs `application-link` | Share the same `to`/`clickable`/`activate` navigation triad, but bind **different vendored patterns** — CIBG _Keuzelijst_ (`.keuzelijst__link`, `.stretched-link`) vs _Aanvragen_ (`.dashboard-block.applications li a`) — with different list/host semantics (`app-choice-link` renders an inner `<li>`; `application-link` **is** the `<li>`). Merging would fight the vendored CSS. Extract the shared triad into a mixin only if it grows. |
| `text-input` / `radio-group` / `checkbox` | Share only the standard Angular **ControlValueAccessor** boilerplate (the `writeValue`/`registerOn*`/`setDisabledState` block). They render genuinely different controls, so they stay three atoms. A base CVA class is the only DRY move — a refactor, not a component merge, and not worth it at three. |
| `button variant="subtle"` (`.btn-link`) vs `app-link` | A subtle button _looks_ like a link but is an **action** (`<button>`, emits click); `app-link` is **navigation** (`<a routerLink>`). Different semantics and a11y roles → keep both. |
| `shell` / `page-shell` / `wizard-shell` | Three distinct jobs that **compose**, not overlap: persistent app chrome (mounted once) → routed page body → the wizard form/step frame. |
| Raw `<h3>` in `application-link` vs the `heading` atom | The vendored `.applications li a h3` chain styles the **bare `<h3>`**; wrapping it in the `app-heading` host element would sit between the anchor and the h3 and can break that selector. This is the one sanctioned raw-heading; everywhere else uses `<app-heading [level]>`. |
| Pair | Why kept separate |
| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `choice-link` vs `application-link` | Share the same `to`/`clickable`/`activate` navigation triad, and both **are** the `<li>` (`selector: 'li[app-…]'`), but bind **different vendored patterns** — CIBG _Keuzelijst_ (`.keuzelijst__link`, `.stretched-link`) vs _Aanvragen_ (`.dashboard-block.applications li a`). Merging would fight the vendored CSS. Extract the shared triad into a mixin only if it grows. |
| `text-input` / `radio-group` / `checkbox` | Share only the standard Angular **ControlValueAccessor** boilerplate (the `writeValue`/`registerOn*`/`setDisabledState` block). They render genuinely different controls, so they stay three atoms. A base CVA class is the only DRY move — a refactor, not a component merge, and not worth it at three. |
| `button variant="subtle"` (`.btn-link`) vs `app-link` | A subtle button _looks_ like a link but is an **action** (`<button>`, emits click); `app-link` is **navigation** (`<a routerLink>`). Different semantics and a11y roles → keep both. |
| `shell` / `page-shell` / `wizard-shell` | Three distinct jobs that **compose**, not overlap: persistent app chrome (mounted once) → routed page body → the wizard form/step frame. |
| Raw `<h3>` in `application-link` vs the `heading` atom | The vendored `.applications li a h3` chain styles the **bare `<h3>`**; wrapping it in the `app-heading` host element would sit between the anchor and the h3 and can break that selector. This is the one sanctioned raw-heading; everywhere else uses `<app-heading [level]>`. |
Single-consumer shared blocks (e.g. `placeholder-chip`, `rich-text-editor`, `checkbox`, the
`task-list`/`choice-list`/`choice-link` family) currently have one consumer each. They stay in
@@ -17,15 +17,17 @@ import { RouterLink } from '@angular/router';
class — keuzelijst assumes every item IS a link — so a non-interactive row would
otherwise inherit the chevron and hover accent too. The `--static` modifier below
suppresses both for that case. `:focus-within` restores the focus accent that
`:focus` would have given the (no longer directly focused) card. */
`:focus` would have given the (no longer directly focused) card.
The host **is** the `<li>` (`selector: 'li[app-choice-link]'`), the same pattern as
`application-link` — a bare component element between the keuzelijst `<ul>` and its
`<li>` breaks axe's list/listitem rule for assistive technology. */
@Component({
selector: 'app-choice-link',
selector: 'li[app-choice-link]',
imports: [RouterLink],
host: { class: 'keuzelijst__list-item' },
styles: [
`
:host {
display: contents;
}
.keuzelijst__link {
position: relative;
}
@@ -43,23 +45,21 @@ import { RouterLink } from '@angular/router';
`,
],
template: `
<li class="keuzelijst__list-item">
<div class="keuzelijst__link" [class.keuzelijst__link--static]="!to() && !clickable()">
<h3 class="keuzelijst__header">
@if (to()) {
<a class="stretched-link" [routerLink]="to()">{{ heading() }}</a>
} @else if (clickable()) {
<a class="stretched-link" href="#" (click)="onActivate($event)">{{ heading() }}</a>
} @else {
{{ heading() }}
}
</h3>
@if (instructions()) {
<p class="keuzelijst__instructions">{{ instructions() }}</p>
<div class="keuzelijst__link" [class.keuzelijst__link--static]="!to() && !clickable()">
<h3 class="keuzelijst__header">
@if (to()) {
<a class="stretched-link" [routerLink]="to()">{{ heading() }}</a>
} @else if (clickable()) {
<a class="stretched-link" href="#" (click)="onActivate($event)">{{ heading() }}</a>
} @else {
{{ heading() }}
}
<ng-content select="[choiceActions]" />
</div>
</li>
</h3>
@if (instructions()) {
<p class="keuzelijst__instructions">{{ instructions() }}</p>
}
<ng-content select="[choiceActions]" />
</div>
`,
})
export class ChoiceLinkComponent {
@@ -9,15 +9,9 @@ const meta: Meta<ChoiceLinkComponent> = {
decorators: [applicationConfig({ providers: [provideRouter([])] })],
render: (args) => ({
props: args,
// Rows are <li>s — a real list gives them their normal layout in the story.
template: `<ul class="keuzelijst__list"><app-choice-link [heading]="heading" [instructions]="instructions" [to]="to" [clickable]="clickable" /></ul>`,
// The host is the <li> itself — a real <ul> gives it its normal layout in the story.
template: `<ul class="keuzelijst__list"><li app-choice-link [heading]="heading" [instructions]="instructions" [to]="to" [clickable]="clickable"></li></ul>`,
}),
parameters: {
// Structural: app-choice-link's host sits between the <ul> and its <li> — axe's
// list/listitem rule requires them adjacent regardless of `display:contents`.
// WP-11 (CIBG markup fidelity) reworks this markup; see docs/project/backlog/WP-11-markup-fidelity.md.
a11y: { disable: true },
},
};
export default meta;
type Story = StoryObj<ChoiceLinkComponent>;
@@ -15,15 +15,10 @@ const meta: Meta<ChoiceListComponent> = {
props: args,
template: `
<app-choice-list [heading]="heading">
<app-choice-link heading="Ik heb een Nederlands diploma" instructions="U kunt direct uw registratie aanvragen." to="/registreren" />
<app-choice-link heading="Ik heb een buitenlands diploma" instructions="Uw diploma moet eerst officieel erkend worden." clickable="true" />
<li app-choice-link heading="Ik heb een Nederlands diploma" instructions="U kunt direct uw registratie aanvragen." to="/registreren"></li>
<li app-choice-link heading="Ik heb een buitenlands diploma" instructions="Uw diploma moet eerst officieel erkend worden." clickable="true"></li>
</app-choice-list>`,
}),
parameters: {
// Structural: app-choice-link's host sits between the <ul> and its <li> —
// fixed by the WP-11 markup rework. See docs/project/backlog/WP-11-markup-fidelity.md.
a11y: { disable: true },
},
};
export default meta;
type Story = StoryObj<ChoiceListComponent>;
@@ -22,7 +22,7 @@ export interface TaskItem {
template: `
<app-choice-list [heading]="listHeading()">
@for (t of tasks(); track t.title) {
<app-choice-link [heading]="t.title" [instructions]="t.description" [to]="t.to" />
<li app-choice-link [heading]="t.title" [instructions]="t.description" [to]="t.to"></li>
}
</app-choice-list>
`,
@@ -11,12 +11,6 @@ const meta: Meta<TaskListComponent> = {
props: args,
template: `<app-task-list [listHeading]="listHeading" [tasks]="tasks" />`,
}),
parameters: {
// Structural: app-choice-link's host sits between the keuzelijst <ul> and its <li>
// — axe's list/listitem rule needs them adjacent regardless of `display:contents`.
// WP-11 (CIBG markup fidelity) reworks this markup; see docs/project/backlog/WP-11-markup-fidelity.md.
a11y: { disable: true },
},
};
export default meta;
type Story = StoryObj<TaskListComponent>;