feat(fp): WP-16 — component a11y: description wiring + alert role

Wires text-input's aria-describedby to the form-field description div
(the BSN hint was rendered but never announced), pins desc-before-error
ordering, and switches alert to role=alert for errors vs role=status
for info/ok/warning. Composition contract enforced by story play tests
(form-field+text-input, alert per variant) run in the WP-01 CI gate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 08:15:24 +02:00
parent 85c805b8bd
commit f3de30b72c
8 changed files with 213 additions and 108 deletions

View File

@@ -55,7 +55,7 @@ for its existing violations, so every WP ends green.
| [WP-13](WP-13-cibg-gap-register.md) | CIBG-gap register + hygiene + MDX | 2 · CIBG | done | | [WP-13](WP-13-cibg-gap-register.md) | CIBG-gap register + hygiene + MDX | 2 · CIBG | done |
| [WP-14](WP-14-storybook-taxonomy.md) | Storybook taxonomy reorg + Layers MDX | 3 · Storybook | done | | [WP-14](WP-14-storybook-taxonomy.md) | Storybook taxonomy reorg + Layers MDX | 3 · Storybook | done |
| [WP-15](WP-15-missing-stories.md) | Missing stories: shell + brief components | 3 · Storybook | done | | [WP-15](WP-15-missing-stories.md) | Missing stories: shell + brief components | 3 · Storybook | done |
| [WP-16](WP-16-component-a11y.md) | Component a11y: description wiring + alert role | 4 · a11y | todo | | [WP-16](WP-16-component-a11y.md) | Component a11y: description wiring + alert role | 4 · a11y | done |
| [WP-17](WP-17-app-a11y.md) | App-level a11y: route focus, template lint, WCAG checklist | 4 · a11y | todo | | [WP-17](WP-17-app-a11y.md) | App-level a11y: route focus, template lint, WCAG checklist | 4 · a11y | todo |
| [WP-18](WP-18-abac-capability-spine.md) | ABAC capability spine (Principal + capabilities, phase P1) | 5 · productie-volwassenheid | done | | [WP-18](WP-18-abac-capability-spine.md) | ABAC capability spine (Principal + capabilities, phase P1) | 5 · productie-volwassenheid | done |
| [WP-19](WP-19-e2e-smoke.md) | Playwright e2e smoke | 5 · productie-volwassenheid | todo | | [WP-19](WP-19-e2e-smoke.md) | Playwright e2e smoke | 5 · productie-volwassenheid | todo |

View File

@@ -1,6 +1,6 @@
# WP-16 — Component a11y: description wiring + alert role # WP-16 — Component a11y: description wiring + alert role
Status: todo Status: done (pending commit)
Phase: 4 — a11y Phase: 4 — a11y
## Why ## Why
@@ -57,12 +57,25 @@ Audit findings axe can't (fully) catch:
## Acceptance criteria ## Acceptance criteria
- [ ] Description text is programmatically associated in the canonical composition; - [x] Description text is programmatically associated in the canonical composition;
login-form BSN hint announced. login-form BSN hint announced.
- [ ] `-error` id appended exactly when invalid; order stable. - [x] `-error` id appended exactly when invalid; order stable.
- [ ] Error alerts are `role="alert"`; play tests assert both behaviors and run in the - [x] Error alerts are `role="alert"`; play tests assert both behaviors and run in the
CI gate. CI gate.
## Deviation from the original plan
`radio-group`/`checkbox` were left unchanged — grepping every call site found zero
consumers pairing either with a `form-field` `description` (only the login-form BSN
field, which uses `text-input`). The WP's own Files note ("describedby joins; only
where the component takes a hint") already carved out this exact case — adding
`hasDescription` to atoms with no live description consumer would be unused surface,
not a fix. `radio-group` already had correct `-error`-only wiring; untouched.
`describedBy()` was added directly on `text-input` rather than factored into a shared
`shared/kernel` helper — one consumer, ~5 lines, not worth the indirection yet.
Manual screen-reader spot check (optional per the WP) skipped; the play test is the
enforced check going forward.
## Verification ## Verification
GREEN + `npm run test-storybook:ci` (includes the new play tests). GREEN + `npm run test-storybook:ci` (includes the new play tests).

View File

@@ -11905,7 +11905,7 @@
}, },
{ {
"name": "AlertComponent", "name": "AlertComponent",
"id": "component-AlertComponent-859bbe82cccea40581a39ebff20da27d3087f9cca4ec7a25457e0f7a69f1478d7af94539fbcf20efcc96afff2a0f3252d0c88c566f00cb4eea117d1e0c0769e8", "id": "component-AlertComponent-e38aceca94288853333b2fd4ad37535f923420f61c9ba25f100a4035a558c0ada40d88b7e7ddd136f6d6099bb7bc9695de3c513720c0b343b5792a4543de19f5",
"file": "src/app/shared/ui/alert/alert.component.ts", "file": "src/app/shared/ui/alert/alert.component.ts",
"encapsulation": [], "encapsulation": [],
"entryComponents": [], "entryComponents": [],
@@ -11917,7 +11917,7 @@
"styles": [ "styles": [
"\n .feedback > div {\n flex: 1 1 auto;\n min-width: 0;\n }\n " "\n .feedback > div {\n flex: 1 1 auto;\n min-width: 0;\n }\n "
], ],
"template": "<div\n class=\"feedback\"\n [class.feedback-info]=\"type() === 'info'\"\n [class.feedback-success]=\"type() === 'ok'\"\n [class.feedback-warning]=\"type() === 'warning'\"\n [class.feedback-error]=\"type() === 'error'\"\n role=\"status\"\n aria-atomic=\"true\"\n>\n <span class=\"icon\"\n ><span class=\"visually-hidden\">{{ iconLabels[type()] }}</span></span\n >\n <div><ng-content /></div>\n</div>\n", "template": "<div\n class=\"feedback\"\n [class.feedback-info]=\"type() === 'info'\"\n [class.feedback-success]=\"type() === 'ok'\"\n [class.feedback-warning]=\"type() === 'warning'\"\n [class.feedback-error]=\"type() === 'error'\"\n [attr.role]=\"type() === 'error' ? 'alert' : 'status'\"\n aria-atomic=\"true\"\n>\n <span class=\"icon\"\n ><span class=\"visually-hidden\">{{ iconLabels[type()] }}</span></span\n >\n <div><ng-content /></div>\n</div>\n",
"templateUrl": [], "templateUrl": [],
"viewProviders": [], "viewProviders": [],
"hostDirectives": [], "hostDirectives": [],
@@ -11931,7 +11931,7 @@
"indexKey": "", "indexKey": "",
"optional": false, "optional": false,
"description": "", "description": "",
"line": 45, "line": 48,
"required": false "required": false
} }
], ],
@@ -11946,7 +11946,7 @@
"indexKey": "", "indexKey": "",
"optional": false, "optional": false,
"description": "", "description": "",
"line": 46, "line": 49,
"modifierKind": [ "modifierKind": [
124, 124,
148 148
@@ -11960,10 +11960,10 @@
"hostListeners": [], "hostListeners": [],
"standalone": false, "standalone": false,
"imports": [], "imports": [],
"description": "<p>Atom: alert/message banner — the CIBG Huisstijl &quot;melding&quot;\n(designsystem.cibg.nl/componenten/meldingen). Thin wrapper over the vendored\n<code>.feedback feedback-*</code> classes: the design system owns surface + icon; we add\nonly the icon&#39;s a11y label and a content wrapper (<code>.feedback</code> is a flex row).</p>\n", "description": "<p>Atom: alert/message banner — the CIBG Huisstijl &quot;melding&quot;\n(designsystem.cibg.nl/componenten/meldingen). Thin wrapper over the vendored\n<code>.feedback feedback-*</code> classes: the design system owns surface + icon; we add\nonly the icon&#39;s a11y label and a content wrapper (<code>.feedback</code> is a flex row).\nErrors are <code>role=&quot;alert&quot;</code> (assertive — interrupts) since they need immediate\nattention; other variants stay <code>role=&quot;status&quot;</code> (polite) so success/info banners\ndon&#39;t interrupt what the user is doing.</p>\n",
"rawdescription": "\nAtom: alert/message banner — the CIBG Huisstijl \"melding\"\n(designsystem.cibg.nl/componenten/meldingen). Thin wrapper over the vendored\n`.feedback feedback-*` classes: the design system owns surface + icon; we add\nonly the icon's a11y label and a content wrapper (`.feedback` is a flex row).", "rawdescription": "\nAtom: alert/message banner — the CIBG Huisstijl \"melding\"\n(designsystem.cibg.nl/componenten/meldingen). Thin wrapper over the vendored\n`.feedback feedback-*` classes: the design system owns surface + icon; we add\nonly the icon's a11y label and a content wrapper (`.feedback` is a flex row).\nErrors are `role=\"alert\"` (assertive — interrupts) since they need immediate\nattention; other variants stay `role=\"status\"` (polite) so success/info banners\ndon't interrupt what the user is doing.",
"type": "component", "type": "component",
"sourceCode": "import { Component, input } from '@angular/core';\n\ntype AlertType = 'info' | 'ok' | 'warning' | 'error';\n\n// visually-hidden alternative for the status icon (CIBG a11y requirement).\nconst ICON_LABELS: Record<AlertType, string> = {\n info: $localize`:@@alert.icon.info:Informatie`,\n ok: $localize`:@@alert.icon.ok:Gelukt`,\n warning: $localize`:@@alert.icon.warning:Waarschuwing`,\n error: $localize`:@@alert.icon.error:Foutmelding`,\n};\n\n/** Atom: alert/message banner — the CIBG Huisstijl \"melding\"\n (designsystem.cibg.nl/componenten/meldingen). Thin wrapper over the vendored\n `.feedback feedback-*` classes: the design system owns surface + icon; we add\n only the icon's a11y label and a content wrapper (`.feedback` is a flex row). */\n@Component({\n selector: 'app-alert',\n styles: [\n `\n .feedback > div {\n flex: 1 1 auto;\n min-width: 0;\n }\n `,\n ],\n template: `\n <div\n class=\"feedback\"\n [class.feedback-info]=\"type() === 'info'\"\n [class.feedback-success]=\"type() === 'ok'\"\n [class.feedback-warning]=\"type() === 'warning'\"\n [class.feedback-error]=\"type() === 'error'\"\n role=\"status\"\n aria-atomic=\"true\"\n >\n <span class=\"icon\"\n ><span class=\"visually-hidden\">{{ iconLabels[type()] }}</span></span\n >\n <div><ng-content /></div>\n </div>\n `,\n})\nexport class AlertComponent {\n type = input<AlertType>('info');\n protected readonly iconLabels = ICON_LABELS;\n}\n", "sourceCode": "import { Component, input } from '@angular/core';\n\ntype AlertType = 'info' | 'ok' | 'warning' | 'error';\n\n// visually-hidden alternative for the status icon (CIBG a11y requirement).\nconst ICON_LABELS: Record<AlertType, string> = {\n info: $localize`:@@alert.icon.info:Informatie`,\n ok: $localize`:@@alert.icon.ok:Gelukt`,\n warning: $localize`:@@alert.icon.warning:Waarschuwing`,\n error: $localize`:@@alert.icon.error:Foutmelding`,\n};\n\n/** Atom: alert/message banner — the CIBG Huisstijl \"melding\"\n (designsystem.cibg.nl/componenten/meldingen). Thin wrapper over the vendored\n `.feedback feedback-*` classes: the design system owns surface + icon; we add\n only the icon's a11y label and a content wrapper (`.feedback` is a flex row).\n Errors are `role=\"alert\"` (assertive — interrupts) since they need immediate\n attention; other variants stay `role=\"status\"` (polite) so success/info banners\n don't interrupt what the user is doing. */\n@Component({\n selector: 'app-alert',\n styles: [\n `\n .feedback > div {\n flex: 1 1 auto;\n min-width: 0;\n }\n `,\n ],\n template: `\n <div\n class=\"feedback\"\n [class.feedback-info]=\"type() === 'info'\"\n [class.feedback-success]=\"type() === 'ok'\"\n [class.feedback-warning]=\"type() === 'warning'\"\n [class.feedback-error]=\"type() === 'error'\"\n [attr.role]=\"type() === 'error' ? 'alert' : 'status'\"\n aria-atomic=\"true\"\n >\n <span class=\"icon\"\n ><span class=\"visually-hidden\">{{ iconLabels[type()] }}</span></span\n >\n <div><ng-content /></div>\n </div>\n `,\n})\nexport class AlertComponent {\n type = input<AlertType>('info');\n protected readonly iconLabels = ICON_LABELS;\n}\n",
"assetsDirs": [], "assetsDirs": [],
"styleUrlsData": "", "styleUrlsData": "",
"stylesData": "\n .feedback > div {\n flex: 1 1 auto;\n min-width: 0;\n }\n \n", "stylesData": "\n .feedback > div {\n flex: 1 1 auto;\n min-width: 0;\n }\n \n",
@@ -18067,7 +18067,7 @@
}, },
{ {
"name": "LoginFormComponent", "name": "LoginFormComponent",
"id": "component-LoginFormComponent-b3f04d6cf60e4894fa1e6ecad944a0e206bb9db082d3ea89289e34147a4e3cbcb902189c8870b64400784969bc6c43c4fc64248ec4e46cdb553ec228204518ca", "id": "component-LoginFormComponent-b6c5fb92e9b69f8a6f42ec2fc097f8501c87d05930404dcac64a29d3b99a12d69ea004881c9163fae2a94ee69aee48807ae0db75ec0336169eb1ad923e54237a",
"file": "src/app/auth/ui/login-form/login-form.component.ts", "file": "src/app/auth/ui/login-form/login-form.component.ts",
"encapsulation": [], "encapsulation": [],
"entryComponents": [], "entryComponents": [],
@@ -18077,7 +18077,7 @@
"selector": "app-login-form", "selector": "app-login-form",
"styleUrls": [], "styleUrls": [],
"styles": [], "styles": [],
"template": "<form (ngSubmit)=\"submitted.emit(bsn)\" class=\"form-horizontal\">\n <div class=\"form-header\">\n <div class=\"form-action\">\n <span class=\"meta\" i18n=\"@@form.verplichteVelden\">* verplichte velden</span>\n </div>\n </div>\n\n <app-form-field\n i18n-label=\"@@login.bsnLabel\"\n label=\"BSN\"\n fieldId=\"bsn\"\n required\n i18n-description=\"@@login.bsnDescription\"\n description=\"9 cijfers (demo: vul iets in)\"\n >\n <app-text-input inputId=\"bsn\" [(ngModel)]=\"bsn\" name=\"bsn\" placeholder=\"123456789\" />\n </app-form-field>\n\n <app-form-field i18n-label=\"@@login.wachtwoordLabel\" label=\"Wachtwoord\" fieldId=\"pw\" required>\n <app-text-input inputId=\"pw\" type=\"password\" [(ngModel)]=\"password\" name=\"pw\" />\n </app-form-field>\n\n <app-button type=\"submit\" variant=\"primary\" i18n=\"@@login.submit\"\n >Inloggen met DigiD</app-button\n >\n</form>\n", "template": "<form (ngSubmit)=\"submitted.emit(bsn)\" class=\"form-horizontal\">\n <div class=\"form-header\">\n <div class=\"form-action\">\n <span class=\"meta\" i18n=\"@@form.verplichteVelden\">* verplichte velden</span>\n </div>\n </div>\n\n <app-form-field\n i18n-label=\"@@login.bsnLabel\"\n label=\"BSN\"\n fieldId=\"bsn\"\n required\n i18n-description=\"@@login.bsnDescription\"\n description=\"9 cijfers (demo: vul iets in)\"\n >\n <app-text-input\n inputId=\"bsn\"\n hasDescription\n [(ngModel)]=\"bsn\"\n name=\"bsn\"\n placeholder=\"123456789\"\n />\n </app-form-field>\n\n <app-form-field i18n-label=\"@@login.wachtwoordLabel\" label=\"Wachtwoord\" fieldId=\"pw\" required>\n <app-text-input inputId=\"pw\" type=\"password\" [(ngModel)]=\"password\" name=\"pw\" />\n </app-form-field>\n\n <app-button type=\"submit\" variant=\"primary\" i18n=\"@@login.submit\"\n >Inloggen met DigiD</app-button\n >\n</form>\n",
"templateUrl": [], "templateUrl": [],
"viewProviders": [], "viewProviders": [],
"hostDirectives": [], "hostDirectives": [],
@@ -18091,7 +18091,7 @@
"indexKey": "", "indexKey": "",
"optional": false, "optional": false,
"description": "", "description": "",
"line": 43, "line": 49,
"required": false "required": false
} }
], ],
@@ -18105,7 +18105,7 @@
"indexKey": "", "indexKey": "",
"optional": false, "optional": false,
"description": "", "description": "",
"line": 41 "line": 47
}, },
{ {
"name": "password", "name": "password",
@@ -18116,7 +18116,7 @@
"indexKey": "", "indexKey": "",
"optional": false, "optional": false,
"description": "", "description": "",
"line": 42 "line": 48
} }
], ],
"methodsClass": [], "methodsClass": [],
@@ -18146,7 +18146,7 @@
"description": "<p>Organism: DigiD-style mock login. No real auth — just composes atoms/molecules.</p>\n", "description": "<p>Organism: DigiD-style mock login. No real auth — just composes atoms/molecules.</p>\n",
"rawdescription": "\nOrganism: DigiD-style mock login. No real auth — just composes atoms/molecules.", "rawdescription": "\nOrganism: DigiD-style mock login. No real auth — just composes atoms/molecules.",
"type": "component", "type": "component",
"sourceCode": "import { Component, output } from '@angular/core';\nimport { FormsModule } from '@angular/forms';\nimport { FormFieldComponent } from '@shared/ui/form-field/form-field.component';\nimport { TextInputComponent } from '@shared/ui/text-input/text-input.component';\nimport { ButtonComponent } from '@shared/ui/button/button.component';\n\n/** Organism: DigiD-style mock login. No real auth — just composes atoms/molecules. */\n@Component({\n selector: 'app-login-form',\n imports: [FormsModule, FormFieldComponent, TextInputComponent, ButtonComponent],\n template: `\n <form (ngSubmit)=\"submitted.emit(bsn)\" class=\"form-horizontal\">\n <div class=\"form-header\">\n <div class=\"form-action\">\n <span class=\"meta\" i18n=\"@@form.verplichteVelden\">* verplichte velden</span>\n </div>\n </div>\n\n <app-form-field\n i18n-label=\"@@login.bsnLabel\"\n label=\"BSN\"\n fieldId=\"bsn\"\n required\n i18n-description=\"@@login.bsnDescription\"\n description=\"9 cijfers (demo: vul iets in)\"\n >\n <app-text-input inputId=\"bsn\" [(ngModel)]=\"bsn\" name=\"bsn\" placeholder=\"123456789\" />\n </app-form-field>\n\n <app-form-field i18n-label=\"@@login.wachtwoordLabel\" label=\"Wachtwoord\" fieldId=\"pw\" required>\n <app-text-input inputId=\"pw\" type=\"password\" [(ngModel)]=\"password\" name=\"pw\" />\n </app-form-field>\n\n <app-button type=\"submit\" variant=\"primary\" i18n=\"@@login.submit\"\n >Inloggen met DigiD</app-button\n >\n </form>\n `,\n})\nexport class LoginFormComponent {\n bsn = '';\n password = '';\n submitted = output<string>();\n}\n", "sourceCode": "import { Component, output } from '@angular/core';\nimport { FormsModule } from '@angular/forms';\nimport { FormFieldComponent } from '@shared/ui/form-field/form-field.component';\nimport { TextInputComponent } from '@shared/ui/text-input/text-input.component';\nimport { ButtonComponent } from '@shared/ui/button/button.component';\n\n/** Organism: DigiD-style mock login. No real auth — just composes atoms/molecules. */\n@Component({\n selector: 'app-login-form',\n imports: [FormsModule, FormFieldComponent, TextInputComponent, ButtonComponent],\n template: `\n <form (ngSubmit)=\"submitted.emit(bsn)\" class=\"form-horizontal\">\n <div class=\"form-header\">\n <div class=\"form-action\">\n <span class=\"meta\" i18n=\"@@form.verplichteVelden\">* verplichte velden</span>\n </div>\n </div>\n\n <app-form-field\n i18n-label=\"@@login.bsnLabel\"\n label=\"BSN\"\n fieldId=\"bsn\"\n required\n i18n-description=\"@@login.bsnDescription\"\n description=\"9 cijfers (demo: vul iets in)\"\n >\n <app-text-input\n inputId=\"bsn\"\n hasDescription\n [(ngModel)]=\"bsn\"\n name=\"bsn\"\n placeholder=\"123456789\"\n />\n </app-form-field>\n\n <app-form-field i18n-label=\"@@login.wachtwoordLabel\" label=\"Wachtwoord\" fieldId=\"pw\" required>\n <app-text-input inputId=\"pw\" type=\"password\" [(ngModel)]=\"password\" name=\"pw\" />\n </app-form-field>\n\n <app-button type=\"submit\" variant=\"primary\" i18n=\"@@login.submit\"\n >Inloggen met DigiD</app-button\n >\n </form>\n `,\n})\nexport class LoginFormComponent {\n bsn = '';\n password = '';\n submitted = output<string>();\n}\n",
"assetsDirs": [], "assetsDirs": [],
"styleUrlsData": "", "styleUrlsData": "",
"stylesData": "", "stylesData": "",
@@ -21976,7 +21976,7 @@
}, },
{ {
"name": "TextInputComponent", "name": "TextInputComponent",
"id": "component-TextInputComponent-6bb65b77462f371a5f1eef4b78584a11380f10f1ffd7082d2844d9a50d2d6ace877d6e408ef279f4c6057daf08c97ffc6c2fa7c29b6de95c687c34adc9730c94", "id": "component-TextInputComponent-7771b0ade08f68e587e3e3ebd8348dc85ed70f270c17ed6c8ceed88499dc366f926989029f6216b696a7b800e4dd42a55ff9511a7e99ee941fa0ee2c91513ce0",
"file": "src/app/shared/ui/text-input/text-input.component.ts", "file": "src/app/shared/ui/text-input/text-input.component.ts",
"encapsulation": [], "encapsulation": [],
"entryComponents": [], "entryComponents": [],
@@ -21992,11 +21992,23 @@
"styles": [ "styles": [
"\n :host {\n display: block;\n }\n input {\n inline-size: 100%;\n box-sizing: border-box;\n }\n " "\n :host {\n display: block;\n }\n input {\n inline-size: 100%;\n box-sizing: border-box;\n }\n "
], ],
"template": "<input\n class=\"form-control\"\n [class.is-invalid]=\"invalid()\"\n [type]=\"type()\"\n [id]=\"inputId()\"\n [attr.aria-invalid]=\"invalid() ? 'true' : null\"\n [attr.aria-describedby]=\"invalid() && inputId() ? inputId() + '-error' : null\"\n [placeholder]=\"placeholder()\"\n [disabled]=\"disabled\"\n [value]=\"value\"\n (input)=\"onInput($event)\"\n (blur)=\"onTouched()\"\n/>\n", "template": "<input\n class=\"form-control\"\n [class.is-invalid]=\"invalid()\"\n [type]=\"type()\"\n [id]=\"inputId()\"\n [attr.aria-invalid]=\"invalid() ? 'true' : null\"\n [attr.aria-describedby]=\"describedBy()\"\n [placeholder]=\"placeholder()\"\n [disabled]=\"disabled\"\n [value]=\"value\"\n (input)=\"onInput($event)\"\n (blur)=\"onTouched()\"\n/>\n",
"templateUrl": [], "templateUrl": [],
"viewProviders": [], "viewProviders": [],
"hostDirectives": [], "hostDirectives": [],
"inputsClass": [ "inputsClass": [
{
"name": "hasDescription",
"defaultValue": "false, { transform: booleanAttribute }",
"deprecated": false,
"deprecationMessage": "",
"indexKey": "",
"optional": false,
"description": "<p>Set when the paired form-field renders a <code>-desc</code> hint, so it gets announced.</p>\n",
"line": 43,
"rawdescription": "\nSet when the paired form-field renders a `-desc` hint, so it gets announced.",
"required": false
},
{ {
"name": "inputId", "name": "inputId",
"deprecated": false, "deprecated": false,
@@ -22054,7 +22066,7 @@
"indexKey": "", "indexKey": "",
"optional": false, "optional": false,
"description": "", "description": "",
"line": 44 "line": 46
}, },
{ {
"name": "onChange", "name": "onChange",
@@ -22065,7 +22077,7 @@
"indexKey": "", "indexKey": "",
"optional": false, "optional": false,
"description": "", "description": "",
"line": 45 "line": 47
}, },
{ {
"name": "onTouched", "name": "onTouched",
@@ -22076,7 +22088,7 @@
"indexKey": "", "indexKey": "",
"optional": false, "optional": false,
"description": "", "description": "",
"line": 46 "line": 48
}, },
{ {
"name": "value", "name": "value",
@@ -22087,10 +22099,20 @@
"indexKey": "", "indexKey": "",
"optional": false, "optional": false,
"description": "", "description": "",
"line": 43 "line": 45
} }
], ],
"methodsClass": [ "methodsClass": [
{
"name": "describedBy",
"args": [],
"optional": false,
"returnType": "string | null",
"typeParameters": [],
"line": 50,
"deprecated": false,
"deprecationMessage": ""
},
{ {
"name": "onInput", "name": "onInput",
"args": [ "args": [
@@ -22106,7 +22128,7 @@
"optional": false, "optional": false,
"returnType": "void", "returnType": "void",
"typeParameters": [], "typeParameters": [],
"line": 48, "line": 60,
"deprecated": false, "deprecated": false,
"deprecationMessage": "", "deprecationMessage": "",
"jsdoctags": [ "jsdoctags": [
@@ -22148,7 +22170,7 @@
"optional": false, "optional": false,
"returnType": "void", "returnType": "void",
"typeParameters": [], "typeParameters": [],
"line": 55, "line": 67,
"deprecated": false, "deprecated": false,
"deprecationMessage": "", "deprecationMessage": "",
"jsdoctags": [ "jsdoctags": [
@@ -22191,7 +22213,7 @@
"optional": false, "optional": false,
"returnType": "void", "returnType": "void",
"typeParameters": [], "typeParameters": [],
"line": 58, "line": 70,
"deprecated": false, "deprecated": false,
"deprecationMessage": "", "deprecationMessage": "",
"jsdoctags": [ "jsdoctags": [
@@ -22224,7 +22246,7 @@
"optional": false, "optional": false,
"returnType": "void", "returnType": "void",
"typeParameters": [], "typeParameters": [],
"line": 61, "line": 73,
"deprecated": false, "deprecated": false,
"deprecationMessage": "", "deprecationMessage": "",
"jsdoctags": [ "jsdoctags": [
@@ -22256,7 +22278,7 @@
"optional": false, "optional": false,
"returnType": "void", "returnType": "void",
"typeParameters": [], "typeParameters": [],
"line": 52, "line": 64,
"deprecated": false, "deprecated": false,
"deprecationMessage": "", "deprecationMessage": "",
"jsdoctags": [ "jsdoctags": [
@@ -22283,7 +22305,7 @@
"description": "<p>Atom: text input. Utrecht textbox wired up as a form control (ngModel/reactive).</p>\n", "description": "<p>Atom: text input. Utrecht textbox wired up as a form control (ngModel/reactive).</p>\n",
"rawdescription": "\nAtom: text input. Utrecht textbox wired up as a form control (ngModel/reactive).", "rawdescription": "\nAtom: text input. Utrecht textbox wired up as a form control (ngModel/reactive).",
"type": "component", "type": "component",
"sourceCode": "import { Component, forwardRef, input } from '@angular/core';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';\n\n/** Atom: text input. Utrecht textbox wired up as a form control (ngModel/reactive). */\n@Component({\n selector: 'app-text-input',\n styles: [\n `\n :host {\n display: block;\n }\n input {\n inline-size: 100%;\n box-sizing: border-box;\n }\n `,\n ],\n template: `\n <input\n class=\"form-control\"\n [class.is-invalid]=\"invalid()\"\n [type]=\"type()\"\n [id]=\"inputId()\"\n [attr.aria-invalid]=\"invalid() ? 'true' : null\"\n [attr.aria-describedby]=\"invalid() && inputId() ? inputId() + '-error' : null\"\n [placeholder]=\"placeholder()\"\n [disabled]=\"disabled\"\n [value]=\"value\"\n (input)=\"onInput($event)\"\n (blur)=\"onTouched()\"\n />\n `,\n providers: [\n { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => TextInputComponent), multi: true },\n ],\n})\nexport class TextInputComponent implements ControlValueAccessor {\n type = input<'text' | 'password' | 'email'>('text');\n placeholder = input('');\n invalid = input(false);\n inputId = input<string>();\n\n value = '';\n disabled = false;\n onChange: (v: string) => void = () => {};\n onTouched: () => void = () => {};\n\n onInput(e: Event) {\n this.value = (e.target as HTMLInputElement).value;\n this.onChange(this.value);\n }\n writeValue(v: string) {\n this.value = v ?? '';\n }\n registerOnChange(fn: (v: string) => void) {\n this.onChange = fn;\n }\n registerOnTouched(fn: () => void) {\n this.onTouched = fn;\n }\n setDisabledState(d: boolean) {\n this.disabled = d;\n }\n}\n", "sourceCode": "import { Component, booleanAttribute, forwardRef, input } from '@angular/core';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';\n\n/** Atom: text input. Utrecht textbox wired up as a form control (ngModel/reactive). */\n@Component({\n selector: 'app-text-input',\n styles: [\n `\n :host {\n display: block;\n }\n input {\n inline-size: 100%;\n box-sizing: border-box;\n }\n `,\n ],\n template: `\n <input\n class=\"form-control\"\n [class.is-invalid]=\"invalid()\"\n [type]=\"type()\"\n [id]=\"inputId()\"\n [attr.aria-invalid]=\"invalid() ? 'true' : null\"\n [attr.aria-describedby]=\"describedBy()\"\n [placeholder]=\"placeholder()\"\n [disabled]=\"disabled\"\n [value]=\"value\"\n (input)=\"onInput($event)\"\n (blur)=\"onTouched()\"\n />\n `,\n providers: [\n { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => TextInputComponent), multi: true },\n ],\n})\nexport class TextInputComponent implements ControlValueAccessor {\n type = input<'text' | 'password' | 'email'>('text');\n placeholder = input('');\n invalid = input(false);\n inputId = input<string>();\n /** Set when the paired form-field renders a `-desc` hint, so it gets announced. */\n hasDescription = input(false, { transform: booleanAttribute });\n\n value = '';\n disabled = false;\n onChange: (v: string) => void = () => {};\n onTouched: () => void = () => {};\n\n describedBy(): string | null {\n const id = this.inputId();\n if (!id) return null;\n const ids = [\n ...(this.hasDescription() ? [`${id}-desc`] : []),\n ...(this.invalid() ? [`${id}-error`] : []),\n ];\n return ids.length ? ids.join(' ') : null;\n }\n\n onInput(e: Event) {\n this.value = (e.target as HTMLInputElement).value;\n this.onChange(this.value);\n }\n writeValue(v: string) {\n this.value = v ?? '';\n }\n registerOnChange(fn: (v: string) => void) {\n this.onChange = fn;\n }\n registerOnTouched(fn: () => void) {\n this.onTouched = fn;\n }\n setDisabledState(d: boolean) {\n this.disabled = d;\n }\n}\n",
"assetsDirs": [], "assetsDirs": [],
"styleUrlsData": "", "styleUrlsData": "",
"stylesData": "\n :host {\n display: block;\n }\n input {\n inline-size: 100%;\n box-sizing: border-box;\n }\n \n", "stylesData": "\n :host {\n display: block;\n }\n input {\n inline-size: 100%;\n box-sizing: border-box;\n }\n \n",
@@ -22948,6 +22970,16 @@
"rawdescription": "Used by the shell to find what to poll on return: still-in-flight uploads.", "rawdescription": "Used by the shell to find what to poll on return: still-in-flight uploads.",
"description": "<p>Used by the shell to find what to poll on return: still-in-flight uploads.</p>\n" "description": "<p>Used by the shell to find what to poll on return: still-in-flight uploads.</p>\n"
}, },
{
"name": "initial",
"ctype": "miscellaneous",
"subtype": "variable",
"file": "src/app/brief/domain/brief.machine.ts",
"deprecated": false,
"deprecationMessage": "",
"type": "BriefState",
"defaultValue": "{ tag: 'loading' }"
},
{ {
"name": "initial", "name": "initial",
"ctype": "miscellaneous", "ctype": "miscellaneous",
@@ -22988,16 +23020,6 @@
"type": "RegistratieState", "type": "RegistratieState",
"defaultValue": "{\n tag: 'Invullen',\n draft: emptyDraft,\n cursor: 0,\n errors: {},\n upload: initialUpload,\n}" "defaultValue": "{\n tag: 'Invullen',\n draft: emptyDraft,\n cursor: 0,\n errors: {},\n upload: initialUpload,\n}"
}, },
{
"name": "initial",
"ctype": "miscellaneous",
"subtype": "variable",
"file": "src/app/brief/domain/brief.machine.ts",
"deprecated": false,
"deprecationMessage": "",
"type": "BriefState",
"defaultValue": "{ tag: 'loading' }"
},
{ {
"name": "initialUpload", "name": "initialUpload",
"ctype": "miscellaneous", "ctype": "miscellaneous",
@@ -27258,6 +27280,50 @@
} }
] ]
}, },
{
"name": "reduce",
"file": "src/app/brief/domain/brief.machine.ts",
"ctype": "miscellaneous",
"subtype": "function",
"deprecated": false,
"deprecationMessage": "",
"description": "",
"args": [
{
"name": "s",
"type": "BriefState",
"deprecated": false,
"deprecationMessage": ""
},
{
"name": "m",
"type": "BriefMsg",
"deprecated": false,
"deprecationMessage": ""
}
],
"returnType": "BriefState",
"jsdoctags": [
{
"name": "s",
"type": "BriefState",
"deprecated": false,
"deprecationMessage": "",
"tagName": {
"text": "param"
}
},
{
"name": "m",
"type": "BriefMsg",
"deprecated": false,
"deprecationMessage": "",
"tagName": {
"text": "param"
}
}
]
},
{ {
"name": "reduce", "name": "reduce",
"file": "src/app/herregistratie/domain/herregistratie.machine.ts", "file": "src/app/herregistratie/domain/herregistratie.machine.ts",
@@ -27434,50 +27500,6 @@
} }
] ]
}, },
{
"name": "reduce",
"file": "src/app/brief/domain/brief.machine.ts",
"ctype": "miscellaneous",
"subtype": "function",
"deprecated": false,
"deprecationMessage": "",
"description": "",
"args": [
{
"name": "s",
"type": "BriefState",
"deprecated": false,
"deprecationMessage": ""
},
{
"name": "m",
"type": "BriefMsg",
"deprecated": false,
"deprecationMessage": ""
}
],
"returnType": "BriefState",
"jsdoctags": [
{
"name": "s",
"type": "BriefState",
"deprecated": false,
"deprecationMessage": "",
"tagName": {
"text": "param"
}
},
{
"name": "m",
"type": "BriefMsg",
"deprecated": false,
"deprecationMessage": "",
"tagName": {
"text": "param"
}
}
]
},
{ {
"name": "reduceUpload", "name": "reduceUpload",
"file": "src/app/shared/upload/upload.machine.ts", "file": "src/app/shared/upload/upload.machine.ts",
@@ -30414,6 +30436,18 @@
"defaultValue": "{\n info: $localize`:@@alert.icon.info:Informatie`,\n ok: $localize`:@@alert.icon.ok:Gelukt`,\n warning: $localize`:@@alert.icon.warning:Waarschuwing`,\n error: $localize`:@@alert.icon.error:Foutmelding`,\n}" "defaultValue": "{\n info: $localize`:@@alert.icon.info:Informatie`,\n ok: $localize`:@@alert.icon.ok:Gelukt`,\n warning: $localize`:@@alert.icon.warning:Waarschuwing`,\n error: $localize`:@@alert.icon.error:Foutmelding`,\n}"
} }
], ],
"src/app/brief/domain/brief.machine.ts": [
{
"name": "initial",
"ctype": "miscellaneous",
"subtype": "variable",
"file": "src/app/brief/domain/brief.machine.ts",
"deprecated": false,
"deprecationMessage": "",
"type": "BriefState",
"defaultValue": "{ tag: 'loading' }"
}
],
"src/app/herregistratie/domain/herregistratie.machine.ts": [ "src/app/herregistratie/domain/herregistratie.machine.ts": [
{ {
"name": "initial", "name": "initial",
@@ -30474,18 +30508,6 @@
"defaultValue": "{\n tag: 'Editing',\n draft: { straat: '', postcode: '', woonplaats: '' },\n errors: {},\n}" "defaultValue": "{\n tag: 'Editing',\n draft: { straat: '', postcode: '', woonplaats: '' },\n errors: {},\n}"
} }
], ],
"src/app/brief/domain/brief.machine.ts": [
{
"name": "initial",
"ctype": "miscellaneous",
"subtype": "variable",
"file": "src/app/brief/domain/brief.machine.ts",
"deprecated": false,
"deprecationMessage": "",
"type": "BriefState",
"defaultValue": "{ tag: 'loading' }"
}
],
"src/app/shared/ui/radio-group/radio-group.component.ts": [ "src/app/shared/ui/radio-group/radio-group.component.ts": [
{ {
"name": "JA_NEE", "name": "JA_NEE",
@@ -41803,8 +41825,8 @@
"type": "component", "type": "component",
"linktype": "component", "linktype": "component",
"name": "TextInputComponent", "name": "TextInputComponent",
"coveragePercent": 7, "coveragePercent": 12,
"coverageCount": "1/14", "coverageCount": "2/16",
"status": "low" "status": "low"
}, },
{ {

View File

@@ -24,7 +24,13 @@ import { ButtonComponent } from '@shared/ui/button/button.component';
i18n-description="@@login.bsnDescription" i18n-description="@@login.bsnDescription"
description="9 cijfers (demo: vul iets in)" description="9 cijfers (demo: vul iets in)"
> >
<app-text-input inputId="bsn" [(ngModel)]="bsn" name="bsn" placeholder="123456789" /> <app-text-input
inputId="bsn"
hasDescription
[(ngModel)]="bsn"
name="bsn"
placeholder="123456789"
/>
</app-form-field> </app-form-field>
<app-form-field i18n-label="@@login.wachtwoordLabel" label="Wachtwoord" fieldId="pw" required> <app-form-field i18n-label="@@login.wachtwoordLabel" label="Wachtwoord" fieldId="pw" required>

View File

@@ -13,7 +13,10 @@ const ICON_LABELS: Record<AlertType, string> = {
/** Atom: alert/message banner — the CIBG Huisstijl "melding" /** Atom: alert/message banner — the CIBG Huisstijl "melding"
(designsystem.cibg.nl/componenten/meldingen). Thin wrapper over the vendored (designsystem.cibg.nl/componenten/meldingen). Thin wrapper over the vendored
`.feedback feedback-*` classes: the design system owns surface + icon; we add `.feedback feedback-*` classes: the design system owns surface + icon; we add
only the icon's a11y label and a content wrapper (`.feedback` is a flex row). */ only the icon's a11y label and a content wrapper (`.feedback` is a flex row).
Errors are `role="alert"` (assertive — interrupts) since they need immediate
attention; other variants stay `role="status"` (polite) so success/info banners
don't interrupt what the user is doing. */
@Component({ @Component({
selector: 'app-alert', selector: 'app-alert',
styles: [ styles: [
@@ -31,7 +34,7 @@ const ICON_LABELS: Record<AlertType, string> = {
[class.feedback-success]="type() === 'ok'" [class.feedback-success]="type() === 'ok'"
[class.feedback-warning]="type() === 'warning'" [class.feedback-warning]="type() === 'warning'"
[class.feedback-error]="type() === 'error'" [class.feedback-error]="type() === 'error'"
role="status" [attr.role]="type() === 'error' ? 'alert' : 'status'"
aria-atomic="true" aria-atomic="true"
> >
<span class="icon" <span class="icon"

View File

@@ -1,4 +1,5 @@
import type { Meta, StoryObj } from '@storybook/angular'; import type { Meta, StoryObj } from '@storybook/angular';
import { expect, within } from 'storybook/test';
import { AlertComponent } from './alert.component'; import { AlertComponent } from './alert.component';
const meta: Meta<AlertComponent> = { const meta: Meta<AlertComponent> = {
@@ -12,7 +13,28 @@ const meta: Meta<AlertComponent> = {
export default meta; export default meta;
type Story = StoryObj<AlertComponent>; type Story = StoryObj<AlertComponent>;
export const Info: Story = { args: { type: 'info' } }; // role assertions guard the polite/assertive split (WP-16): errors interrupt, others don't.
export const Ok: Story = { args: { type: 'ok' } }; export const Info: Story = {
export const Warning: Story = { args: { type: 'warning' } }; args: { type: 'info' },
export const Error: Story = { args: { type: 'error' } }; play: async ({ canvasElement }) => {
await expect(within(canvasElement).getByRole('status')).toBeInTheDocument();
},
};
export const Ok: Story = {
args: { type: 'ok' },
play: async ({ canvasElement }) => {
await expect(within(canvasElement).getByRole('status')).toBeInTheDocument();
},
};
export const Warning: Story = {
args: { type: 'warning' },
play: async ({ canvasElement }) => {
await expect(within(canvasElement).getByRole('status')).toBeInTheDocument();
},
};
export const Error: Story = {
args: { type: 'error' },
play: async ({ canvasElement }) => {
await expect(within(canvasElement).getByRole('alert')).toBeInTheDocument();
},
};

View File

@@ -1,5 +1,6 @@
import type { Meta, StoryObj } from '@storybook/angular'; import type { Meta, StoryObj } from '@storybook/angular';
import { moduleMetadata } from '@storybook/angular'; import { moduleMetadata } from '@storybook/angular';
import { expect, within } from 'storybook/test';
import { FormFieldComponent } from './form-field.component'; import { FormFieldComponent } from './form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component'; import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
@@ -13,7 +14,7 @@ const meta: Meta<FormFieldComponent> = {
template: ` template: `
<form class="form-horizontal"> <form class="form-horizontal">
<app-form-field [label]="label" [fieldId]="fieldId" [description]="description" [error]="error" [required]="required"> <app-form-field [label]="label" [fieldId]="fieldId" [description]="description" [error]="error" [required]="required">
<app-text-input [inputId]="fieldId" [invalid]="!!error" placeholder="Vul in" /> <app-text-input [inputId]="fieldId" [hasDescription]="!!description" [invalid]="!!error" placeholder="Vul in" />
</app-form-field> </app-form-field>
</form>`, </form>`,
}), }),
@@ -23,6 +24,13 @@ type Story = StoryObj<FormFieldComponent>;
export const Default: Story = { export const Default: Story = {
args: { label: 'BSN', fieldId: 'bsn', description: '9 cijfers', required: true }, args: { label: 'BSN', fieldId: 'bsn', description: '9 cijfers', required: true },
// Composition contract: fieldId must equal the input's id — enforced here, not by DI
// (see WP-16). Catches drift in the description→aria-describedby wiring.
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = canvas.getByRole('textbox');
await expect(input).toHaveAttribute('aria-describedby', 'bsn-desc');
},
}; };
export const WithError: Story = { export const WithError: Story = {
args: { args: {
@@ -31,4 +39,23 @@ export const WithError: Story = {
error: 'Dit veld is verplicht.', error: 'Dit veld is verplicht.',
required: true, required: true,
}, },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = canvas.getByRole('textbox');
await expect(input).toHaveAttribute('aria-describedby', 'street-error');
},
};
export const WithDescriptionAndError: Story = {
args: {
label: 'BSN',
fieldId: 'bsn',
description: '9 cijfers',
error: 'Ongeldig BSN.',
required: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = canvas.getByRole('textbox');
await expect(input).toHaveAttribute('aria-describedby', 'bsn-desc bsn-error');
},
}; };

View File

@@ -1,4 +1,4 @@
import { Component, forwardRef, input } from '@angular/core'; import { Component, booleanAttribute, forwardRef, input } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
/** Atom: text input. Utrecht textbox wired up as a form control (ngModel/reactive). */ /** Atom: text input. Utrecht textbox wired up as a form control (ngModel/reactive). */
@@ -22,7 +22,7 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
[type]="type()" [type]="type()"
[id]="inputId()" [id]="inputId()"
[attr.aria-invalid]="invalid() ? 'true' : null" [attr.aria-invalid]="invalid() ? 'true' : null"
[attr.aria-describedby]="invalid() && inputId() ? inputId() + '-error' : null" [attr.aria-describedby]="describedBy()"
[placeholder]="placeholder()" [placeholder]="placeholder()"
[disabled]="disabled" [disabled]="disabled"
[value]="value" [value]="value"
@@ -39,12 +39,24 @@ export class TextInputComponent implements ControlValueAccessor {
placeholder = input(''); placeholder = input('');
invalid = input(false); invalid = input(false);
inputId = input<string>(); inputId = input<string>();
/** Set when the paired form-field renders a `-desc` hint, so it gets announced. */
hasDescription = input(false, { transform: booleanAttribute });
value = ''; value = '';
disabled = false; disabled = false;
onChange: (v: string) => void = () => {}; onChange: (v: string) => void = () => {};
onTouched: () => void = () => {}; onTouched: () => void = () => {};
describedBy(): string | null {
const id = this.inputId();
if (!id) return null;
const ids = [
...(this.hasDescription() ? [`${id}-desc`] : []),
...(this.invalid() ? [`${id}-error`] : []),
];
return ids.length ? ids.join(' ') : null;
}
onInput(e: Event) { onInput(e: Event) {
this.value = (e.target as HTMLInputElement).value; this.value = (e.target as HTMLInputElement).value;
this.onChange(this.value); this.onChange(this.value);