diff --git a/docs/backlog/README.md b/docs/backlog/README.md index bb285f2..71224da 100644 --- a/docs/backlog/README.md +++ b/docs/backlog/README.md @@ -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-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-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-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 | diff --git a/docs/backlog/WP-16-component-a11y.md b/docs/backlog/WP-16-component-a11y.md index 7cdc07b..78be96b 100644 --- a/docs/backlog/WP-16-component-a11y.md +++ b/docs/backlog/WP-16-component-a11y.md @@ -1,6 +1,6 @@ # WP-16 — Component a11y: description wiring + alert role -Status: todo +Status: done (pending commit) Phase: 4 — a11y ## Why @@ -57,12 +57,25 @@ Audit findings axe can't (fully) catch: ## 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. -- [ ] `-error` id appended exactly when invalid; order stable. -- [ ] Error alerts are `role="alert"`; play tests assert both behaviors and run in the +- [x] `-error` id appended exactly when invalid; order stable. +- [x] Error alerts are `role="alert"`; play tests assert both behaviors and run in the 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 GREEN + `npm run test-storybook:ci` (includes the new play tests). diff --git a/documentation.json b/documentation.json index c4c8e19..37d16f7 100644 --- a/documentation.json +++ b/documentation.json @@ -11905,7 +11905,7 @@ }, { "name": "AlertComponent", - "id": "component-AlertComponent-859bbe82cccea40581a39ebff20da27d3087f9cca4ec7a25457e0f7a69f1478d7af94539fbcf20efcc96afff2a0f3252d0c88c566f00cb4eea117d1e0c0769e8", + "id": "component-AlertComponent-e38aceca94288853333b2fd4ad37535f923420f61c9ba25f100a4035a558c0ada40d88b7e7ddd136f6d6099bb7bc9695de3c513720c0b343b5792a4543de19f5", "file": "src/app/shared/ui/alert/alert.component.ts", "encapsulation": [], "entryComponents": [], @@ -11917,7 +11917,7 @@ "styles": [ "\n .feedback > div {\n flex: 1 1 auto;\n min-width: 0;\n }\n " ], - "template": "\n {{ iconLabels[type()] }}\n
\n\n", + "template": "\n {{ iconLabels[type()] }}\n
\n\n", "templateUrl": [], "viewProviders": [], "hostDirectives": [], @@ -11931,7 +11931,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 45, + "line": 48, "required": false } ], @@ -11946,7 +11946,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 46, + "line": 49, "modifierKind": [ 124, 148 @@ -11960,10 +11960,10 @@ "hostListeners": [], "standalone": false, "imports": [], - "description": "

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\nonly the icon's a11y label and a content wrapper (.feedback is a flex row).

\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).", + "description": "

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\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.

\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).\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", - "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 = {\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 \n {{ iconLabels[type()] }}\n
\n \n `,\n})\nexport class AlertComponent {\n type = input('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 = {\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 \n {{ iconLabels[type()] }}\n
\n \n `,\n})\nexport class AlertComponent {\n type = input('info');\n protected readonly iconLabels = ICON_LABELS;\n}\n", "assetsDirs": [], "styleUrlsData": "", "stylesData": "\n .feedback > div {\n flex: 1 1 auto;\n min-width: 0;\n }\n \n", @@ -18067,7 +18067,7 @@ }, { "name": "LoginFormComponent", - "id": "component-LoginFormComponent-b3f04d6cf60e4894fa1e6ecad944a0e206bb9db082d3ea89289e34147a4e3cbcb902189c8870b64400784969bc6c43c4fc64248ec4e46cdb553ec228204518ca", + "id": "component-LoginFormComponent-b6c5fb92e9b69f8a6f42ec2fc097f8501c87d05930404dcac64a29d3b99a12d69ea004881c9163fae2a94ee69aee48807ae0db75ec0336169eb1ad923e54237a", "file": "src/app/auth/ui/login-form/login-form.component.ts", "encapsulation": [], "entryComponents": [], @@ -18077,7 +18077,7 @@ "selector": "app-login-form", "styleUrls": [], "styles": [], - "template": "
\n
\n
\n * verplichte velden\n
\n
\n\n \n \n \n\n \n \n \n\n Inloggen met DigiD\n\n", + "template": "
\n
\n
\n * verplichte velden\n
\n
\n\n \n \n \n\n \n \n \n\n Inloggen met DigiD\n\n", "templateUrl": [], "viewProviders": [], "hostDirectives": [], @@ -18091,7 +18091,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 43, + "line": 49, "required": false } ], @@ -18105,7 +18105,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 41 + "line": 47 }, { "name": "password", @@ -18116,7 +18116,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 42 + "line": 48 } ], "methodsClass": [], @@ -18146,7 +18146,7 @@ "description": "

Organism: DigiD-style mock login. No real auth — just composes atoms/molecules.

\n", "rawdescription": "\nOrganism: DigiD-style mock login. No real auth — just composes atoms/molecules.", "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
\n
\n
\n * verplichte velden\n
\n
\n\n \n \n \n\n \n \n \n\n Inloggen met DigiD\n \n `,\n})\nexport class LoginFormComponent {\n bsn = '';\n password = '';\n submitted = output();\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
\n
\n
\n * verplichte velden\n
\n
\n\n \n \n \n\n \n \n \n\n Inloggen met DigiD\n \n `,\n})\nexport class LoginFormComponent {\n bsn = '';\n password = '';\n submitted = output();\n}\n", "assetsDirs": [], "styleUrlsData": "", "stylesData": "", @@ -21976,7 +21976,7 @@ }, { "name": "TextInputComponent", - "id": "component-TextInputComponent-6bb65b77462f371a5f1eef4b78584a11380f10f1ffd7082d2844d9a50d2d6ace877d6e408ef279f4c6057daf08c97ffc6c2fa7c29b6de95c687c34adc9730c94", + "id": "component-TextInputComponent-7771b0ade08f68e587e3e3ebd8348dc85ed70f270c17ed6c8ceed88499dc366f926989029f6216b696a7b800e4dd42a55ff9511a7e99ee941fa0ee2c91513ce0", "file": "src/app/shared/ui/text-input/text-input.component.ts", "encapsulation": [], "entryComponents": [], @@ -21992,11 +21992,23 @@ "styles": [ "\n :host {\n display: block;\n }\n input {\n inline-size: 100%;\n box-sizing: border-box;\n }\n " ], - "template": "\n", + "template": "\n", "templateUrl": [], "viewProviders": [], "hostDirectives": [], "inputsClass": [ + { + "name": "hasDescription", + "defaultValue": "false, { transform: booleanAttribute }", + "deprecated": false, + "deprecationMessage": "", + "indexKey": "", + "optional": false, + "description": "

Set when the paired form-field renders a -desc hint, so it gets announced.

\n", + "line": 43, + "rawdescription": "\nSet when the paired form-field renders a `-desc` hint, so it gets announced.", + "required": false + }, { "name": "inputId", "deprecated": false, @@ -22054,7 +22066,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 44 + "line": 46 }, { "name": "onChange", @@ -22065,7 +22077,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 45 + "line": 47 }, { "name": "onTouched", @@ -22076,7 +22088,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 46 + "line": 48 }, { "name": "value", @@ -22087,10 +22099,20 @@ "indexKey": "", "optional": false, "description": "", - "line": 43 + "line": 45 } ], "methodsClass": [ + { + "name": "describedBy", + "args": [], + "optional": false, + "returnType": "string | null", + "typeParameters": [], + "line": 50, + "deprecated": false, + "deprecationMessage": "" + }, { "name": "onInput", "args": [ @@ -22106,7 +22128,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 48, + "line": 60, "deprecated": false, "deprecationMessage": "", "jsdoctags": [ @@ -22148,7 +22170,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 55, + "line": 67, "deprecated": false, "deprecationMessage": "", "jsdoctags": [ @@ -22191,7 +22213,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 58, + "line": 70, "deprecated": false, "deprecationMessage": "", "jsdoctags": [ @@ -22224,7 +22246,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 61, + "line": 73, "deprecated": false, "deprecationMessage": "", "jsdoctags": [ @@ -22256,7 +22278,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 52, + "line": 64, "deprecated": false, "deprecationMessage": "", "jsdoctags": [ @@ -22283,7 +22305,7 @@ "description": "

Atom: text input. Utrecht textbox wired up as a form control (ngModel/reactive).

\n", "rawdescription": "\nAtom: text input. Utrecht textbox wired up as a form control (ngModel/reactive).", "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 \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();\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 \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();\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": [], "styleUrlsData": "", "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.", "description": "

Used by the shell to find what to poll on return: still-in-flight uploads.

\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", "ctype": "miscellaneous", @@ -22988,16 +23020,6 @@ "type": "RegistratieState", "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", "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", "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", "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}" } ], + "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": [ { "name": "initial", @@ -30474,18 +30508,6 @@ "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": [ { "name": "JA_NEE", @@ -41803,8 +41825,8 @@ "type": "component", "linktype": "component", "name": "TextInputComponent", - "coveragePercent": 7, - "coverageCount": "1/14", + "coveragePercent": 12, + "coverageCount": "2/16", "status": "low" }, { diff --git a/src/app/auth/ui/login-form/login-form.component.ts b/src/app/auth/ui/login-form/login-form.component.ts index 8927007..5fa2dc9 100644 --- a/src/app/auth/ui/login-form/login-form.component.ts +++ b/src/app/auth/ui/login-form/login-form.component.ts @@ -24,7 +24,13 @@ import { ButtonComponent } from '@shared/ui/button/button.component'; i18n-description="@@login.bsnDescription" description="9 cijfers (demo: vul iets in)" > - + diff --git a/src/app/shared/ui/alert/alert.component.ts b/src/app/shared/ui/alert/alert.component.ts index 6fa9537..71c2f72 100644 --- a/src/app/shared/ui/alert/alert.component.ts +++ b/src/app/shared/ui/alert/alert.component.ts @@ -13,7 +13,10 @@ const ICON_LABELS: Record = { /** Atom: alert/message banner — the CIBG Huisstijl "melding" (designsystem.cibg.nl/componenten/meldingen). Thin wrapper over the vendored `.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({ selector: 'app-alert', styles: [ @@ -31,7 +34,7 @@ const ICON_LABELS: Record = { [class.feedback-success]="type() === 'ok'" [class.feedback-warning]="type() === 'warning'" [class.feedback-error]="type() === 'error'" - role="status" + [attr.role]="type() === 'error' ? 'alert' : 'status'" aria-atomic="true" > = { @@ -12,7 +13,28 @@ const meta: Meta = { export default meta; type Story = StoryObj; -export const Info: Story = { args: { type: 'info' } }; -export const Ok: Story = { args: { type: 'ok' } }; -export const Warning: Story = { args: { type: 'warning' } }; -export const Error: Story = { args: { type: 'error' } }; +// role assertions guard the polite/assertive split (WP-16): errors interrupt, others don't. +export const Info: Story = { + args: { type: 'info' }, + 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(); + }, +}; diff --git a/src/app/shared/ui/form-field/form-field.stories.ts b/src/app/shared/ui/form-field/form-field.stories.ts index ec5ad58..8808e39 100644 --- a/src/app/shared/ui/form-field/form-field.stories.ts +++ b/src/app/shared/ui/form-field/form-field.stories.ts @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from '@storybook/angular'; import { moduleMetadata } from '@storybook/angular'; +import { expect, within } from 'storybook/test'; import { FormFieldComponent } from './form-field.component'; import { TextInputComponent } from '@shared/ui/text-input/text-input.component'; @@ -13,7 +14,7 @@ const meta: Meta = { template: `
- +
`, }), @@ -23,6 +24,13 @@ type Story = StoryObj; export const Default: Story = { 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 = { args: { @@ -31,4 +39,23 @@ export const WithError: Story = { error: 'Dit veld is verplicht.', 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'); + }, }; diff --git a/src/app/shared/ui/text-input/text-input.component.ts b/src/app/shared/ui/text-input/text-input.component.ts index 288d3e0..c24650c 100644 --- a/src/app/shared/ui/text-input/text-input.component.ts +++ b/src/app/shared/ui/text-input/text-input.component.ts @@ -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'; /** 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()" [id]="inputId()" [attr.aria-invalid]="invalid() ? 'true' : null" - [attr.aria-describedby]="invalid() && inputId() ? inputId() + '-error' : null" + [attr.aria-describedby]="describedBy()" [placeholder]="placeholder()" [disabled]="disabled" [value]="value" @@ -39,12 +39,24 @@ export class TextInputComponent implements ControlValueAccessor { placeholder = input(''); invalid = input(false); inputId = input(); + /** Set when the paired form-field renders a `-desc` hint, so it gets announced. */ + hasDescription = input(false, { transform: booleanAttribute }); value = ''; disabled = false; onChange: (v: string) => 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) { this.value = (e.target as HTMLInputElement).value; this.onChange(this.value);