Skip to content

Commit 23f28fa

Browse files
chore(components, accessibility): improve post-header semantics (#6841)
## 📋 Summary This PR improves the semantic structure and accessibility of the `post-header` and `post-mainnavigation` components. --- ## Key Question **Original Question from [[#6687](https://github.com/swisspost/design-system/issues/6687)]** > "Is it correct for the `<nav>` element from the `post-mainnavigation` to be placed inside a `<header>` element?" ### Decision: **Yes**, placing `<nav>` inside `<header>` is correct and follows W3C best practices. ### Why this is valid: Both W3C and MDN provide examples showing navigation elements nested within the header. According to **MDN**: > "Even if it's not mandatory, it's common practice to put the main navigation menu within the main header." **Source:** [[MDN - WAI-ARIA Basics](https://developer.mozilla.org/de/docs/Learn_web_development/Core/Accessibility/WAI-ARIA_basics?utm_source=chatgpt.com#wegweiserlandmarken)](https://developer.mozilla.org/de/docs/Learn_web_development/Core/Accessibility/WAI-ARIA_basics?utm_source=chatgpt.com#wegweiserlandmarken) ### Trusted Source Examples: 1. [[MDN - WAI-ARIA Basics: Landmark Navigation](https://developer.mozilla.org/de/docs/Learn_web_development/Core/Accessibility/WAI-ARIA_basics?utm_source=chatgpt.com#wegweiserlandmarken)](https://developer.mozilla.org/de/docs/Learn_web_development/Core/Accessibility/WAI-ARIA_basics?utm_source=chatgpt.com#wegweiserlandmarken) 2. [[W3C Design System - Header with Navigation](https://design-system.w3.org/code/header-nav.html)](https://design-system.w3.org/code/header-nav.html) --- ## Changes Made ### 1. Added Semantic `<header>` Element with `role="banner"` #### Accessibility Tree Comparison: **BEFORE:** <img width="452" alt="Accessibility tree before changes" src="https://github.com/user-attachments/assets/6e7e236e-1d61-45a4-b9d9-3e8a3b044fc2" /> **AFTER:** <img width="571" alt="Accessibility tree after changes" src="https://github.com/user-attachments/assets/265d5a70-c3b0-477a-be6b-0dacc5d52fc5" /> #### Why `role="banner"` is needed: <img width="1029" alt="W3C guidance on role banner" src="https://github.com/user-attachments/assets/d2bb7621-23bf-4b46-b07a-38fbe5e7cb76" /> --- ### 2. Added Required `caption` Prop to `post-mainnavigation` --- ### 3. Moved Event Listener to Shadow Root **Problem with host listener:** When the event listener is attached to the host element, screen readers detect the entire component as interactive and announce it as **"clickable banner landmark"**, which is incorrect and confusing for users with assistive technology. **Solution:** Changed from `this.host.addEventListener()` to `this.host.shadowRoot.addEventListener()`.
1 parent 2c5da86 commit 23f28fa

File tree

16 files changed

+97
-56
lines changed

16 files changed

+97
-56
lines changed

.changeset/free-lamps-create.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@swisspost/design-system-components': major
3+
'@swisspost/design-system-documentation': patch
4+
---
5+
6+
Added a required `caption` property to the `post-mainnavigation` component for the accessible name of the navigation landmark.

packages/components-angular/projects/consumer-app/src/app/app.component.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@
6767
</post-togglebutton>
6868

6969
<!-- Main navigation -->
70-
<post-mainnavigation caption="Main navigation">
70+
<post-mainnavigation caption="Main">
7171
<ul>
7272
<!-- Link only level 1 -->
7373
<li>

packages/components/cypress/fixtures/post-mainnavigation-overflow.test.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@
6262
</ul>
6363

6464
<!-- Main navigation -->
65-
<post-mainnavigation slot="main-nav" caption="Hauptnavigation">
65+
<post-mainnavigation slot="main-nav" caption="Haupt">
6666
<ul>
6767
<li>
6868
<post-megadropdown-trigger for="item-1">Item 1</post-megadropdown-trigger>

packages/components/cypress/fixtures/post-mainnavigation.test.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@
6262
</ul>
6363

6464
<!-- Main navigation -->
65-
<post-mainnavigation slot="main-nav" caption="Hauptnavigation">
65+
<post-mainnavigation slot="main-nav" caption="Haupt">
6666
<ul>
6767
<!-- Link only level 1 -->
6868
<li><a href="/briefe">Briefe</a></li>

packages/components/src/components.d.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,10 @@ export namespace Components {
308308
"url"?: string | URL;
309309
}
310310
interface PostMainnavigation {
311+
/**
312+
* Defines the accessible label for the navigation element. This text is used as the `aria-label` attribute to provide screen reader users with a description of the navigation's purpose.
313+
*/
314+
"caption": string;
311315
}
312316
interface PostMegadropdown {
313317
/**
@@ -1326,6 +1330,10 @@ declare namespace LocalJSX {
13261330
"url"?: string | URL;
13271331
}
13281332
interface PostMainnavigation {
1333+
/**
1334+
* Defines the accessible label for the navigation element. This text is used as the `aria-label` attribute to provide screen reader users with a description of the navigation's purpose.
1335+
*/
1336+
"caption": string;
13291337
}
13301338
interface PostMegadropdown {
13311339
/**

packages/components/src/components/post-header/post-header.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
}
1111

1212
:host,
13+
header,
1314
.global-header,
1415
.local-header {
1516
transition: inset-block-start animation.$transition-time-simple

packages/components/src/components/post-header/post-header.tsx

Lines changed: 46 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,6 @@ export class PostHeader {
121121
passive: true,
122122
});
123123
document.addEventListener('postToggleMegadropdown', this.megadropdownStateHandler);
124-
this.host.addEventListener('click', this.handleLinkClick);
125124
window.addEventListener('postBreakpoint:device', this.breakpointChange);
126125

127126
this.handleScrollParentResize();
@@ -141,6 +140,7 @@ export class PostHeader {
141140

142141
componentDidLoad() {
143142
this.updateLocalHeaderHeight();
143+
this.host.shadowRoot.addEventListener('click', this.handleLinkClick);
144144
}
145145

146146
// Clean up possible side effects when post-header is disconnected
@@ -153,7 +153,9 @@ export class PostHeader {
153153
if (scrollParent) scrollParent.removeEventListener('scroll', this.handleScrollEvent);
154154
document.removeEventListener('postToggleMegadropdown', this.megadropdownStateHandler);
155155
this.host.removeEventListener('keydown', this.keyboardHandler);
156-
this.host.removeEventListener('click', this.handleLinkClick);
156+
if (this.host.shadowRoot) {
157+
this.host.shadowRoot.removeEventListener('click', this.handleLinkClick);
158+
}
157159

158160
if (this.scrollParentResizeObserver) {
159161
this.scrollParentResizeObserver.disconnect();
@@ -417,48 +419,50 @@ export class PostHeader {
417419
render() {
418420
return (
419421
<Host data-version={version} data-color-scheme="light" data-burger-menu={this.hasBurgerMenu}>
420-
<div
421-
class={{
422-
'global-header': true,
423-
'no-target-group': !this.hasTargetGroup,
424-
}}
425-
>
426-
<div class="logo">
427-
<slot name="post-logo"></slot>
422+
<header>
423+
<div
424+
class={{
425+
'global-header': true,
426+
'no-target-group': !this.hasTargetGroup,
427+
}}
428+
>
429+
<div class="logo">
430+
<slot name="post-logo"></slot>
431+
</div>
432+
<div class="sliding-controls">
433+
{this.device === 'desktop' && (
434+
<div class="target-group">
435+
<slot name="audience"></slot>
436+
</div>
437+
)}
438+
<slot name="global-nav-primary"></slot>
439+
{!this.hasBurgerMenu && [
440+
<slot name="global-nav-secondary"></slot>,
441+
<slot name="language-menu"></slot>,
442+
]}
443+
<slot name="post-login"></slot>
444+
{this.hasNavigation && this.device !== 'desktop' && (
445+
<div onClick={() => this.toggleBurgerMenu()} class="burger-menu-toggle">
446+
<slot name="post-togglebutton"></slot>
447+
</div>
448+
)}
449+
</div>
428450
</div>
429-
<div class="sliding-controls">
430-
{this.device === 'desktop' && (
431-
<div class="target-group">
432-
<slot name="audience"></slot>
433-
</div>
434-
)}
435-
<slot name="global-nav-primary"></slot>
436-
{!this.hasBurgerMenu && [
437-
<slot name="global-nav-secondary"></slot>,
438-
<slot name="language-menu"></slot>,
439-
]}
440-
<slot name="post-login"></slot>
441-
{this.hasNavigation && this.device !== 'desktop' && (
442-
<div onClick={() => this.toggleBurgerMenu()} class="burger-menu-toggle">
443-
<slot name="post-togglebutton"></slot>
444-
</div>
445-
)}
451+
<div
452+
class={{
453+
'local-header': true,
454+
'no-title': !this.hasTitle,
455+
'no-target-group': !this.hasTargetGroup,
456+
'no-navigation': this.device !== 'desktop' || !this.hasNavigation,
457+
'no-local-nav': !this.hasLocalNav,
458+
}}
459+
>
460+
<slot name="title"></slot>
461+
{this.hasTitle && <slot name="local-nav"></slot>}
462+
{this.device === 'desktop' && this.renderNavigation()}
446463
</div>
447-
</div>
448-
<div
449-
class={{
450-
'local-header': true,
451-
'no-title': !this.hasTitle,
452-
'no-target-group': !this.hasTargetGroup,
453-
'no-navigation': this.device !== 'desktop' || !this.hasNavigation,
454-
'no-local-nav': !this.hasLocalNav,
455-
}}
456-
>
457-
<slot name="title"></slot>
458-
{this.hasTitle && <slot name="local-nav"></slot>}
459-
{this.device === 'desktop' && this.renderNavigation()}
460-
</div>
461-
{this.device !== 'desktop' && this.renderNavigation()}
464+
{this.device !== 'desktop' && this.renderNavigation()}
465+
</header>
462466
</Host>
463467
);
464468
}

packages/components/src/components/post-mainnavigation/post-mainnavigation.tsx

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { Component, Element, Host, h, State, Listen } from '@stencil/core';
1+
import { Component, Element, Host, h, State, Listen, Prop, Watch } from '@stencil/core';
2+
import { checkRequiredAndType } from '@/utils';
23
import { version } from '@root/package.json';
34

45
const SCROLL_REPEAT_INTERVAL = 100; // Interval for repeated scrolling when holding down scroll button
@@ -10,7 +11,7 @@ const NAVBAR_DISABLE_DURATION = 400; // Duration to temporarily disable navbar i
1011
shadow: true,
1112
})
1213
export class PostMainnavigation {
13-
@Element() private host: HTMLPostMainnavigationElement;
14+
@Element() host: HTMLPostMainnavigationElement;
1415

1516
private navbar: HTMLElement;
1617

@@ -23,6 +24,16 @@ export class PostMainnavigation {
2324
@State() canScrollLeft = false;
2425
@State() canScrollRight = false;
2526

27+
/**
28+
* Defines the accessible label for the navigation element. This text is used as the `aria-label` attribute to provide screen reader users with a description of the navigation's purpose.
29+
*/
30+
@Prop({ reflect: true }) caption!: string;
31+
32+
@Watch('caption')
33+
validateCaption() {
34+
checkRequiredAndType(this, 'caption', 'string');
35+
}
36+
2637
constructor() {
2738
this.scrollRight = this.scrollRight.bind(this);
2839
this.scrollLeft = this.scrollLeft.bind(this);
@@ -34,6 +45,8 @@ export class PostMainnavigation {
3445
}
3546

3647
componentDidLoad() {
48+
this.validateCaption();
49+
3750
setTimeout(() => {
3851
this.fixLayoutShift();
3952
this.checkScrollability();
@@ -200,7 +213,7 @@ export class PostMainnavigation {
200213
<post-icon aria-hidden="true" name="chevronleft"></post-icon>
201214
</div>
202215

203-
<nav ref={el => (this.navbar = el)}>
216+
<nav ref={el => (this.navbar = el)} aria-label={this.caption}>
204217
<slot></slot>
205218
</nav>
206219

packages/components/src/components/post-mainnavigation/readme.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@
55
<!-- Auto Generated Below -->
66

77

8+
## Properties
9+
10+
| Property | Attribute | Description | Type | Default |
11+
| ---------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ----------- |
12+
| `caption` _(required)_ | `caption` | Defines the accessible label for the navigation element. This text is used as the `aria-label` attribute to provide screen reader users with a description of the navigation's purpose. | `string` | `undefined` |
13+
14+
815
## Dependencies
916

1017
### Depends on

packages/components/src/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@
7878
</ul>
7979

8080
<!-- Main navigation -->
81-
<post-mainnavigation slot="main-nav" caption="Hauptnavigation">
81+
<post-mainnavigation slot="main-nav" caption="Haupt">
8282
<ul>
8383
<!-- Link only level 1 -->
8484
<li><a href="/briefe">Briefe</a></li>

0 commit comments

Comments
 (0)