From 12084af163ed811b9c6bda3c7850fc0c53c60c7b Mon Sep 17 00:00:00 2001 From: Maria Hutt Date: Wed, 15 Oct 2025 10:50:44 -0700 Subject: [PATCH] fix(header): ensure one banner role in condensed header (#30718) Issue number: internal --------- ## What is the current behavior? As per accessibility guidelines, there should only be one banner landmark per page. A condensed header creates two banner landmarks since 2 `ion-header` components are required on the page. `ion-header` renders with a `role="banner"` by default (when not in `ion-menu`). The visual effect of the condensed header is achieved by rendering two distinct header components. Because both components exist in the code at the same time and both have `role="banner"`, they create a duplicate landmark announcement for screen readers. This leads to a violation with the accessibility guidelines. ## What is the new behavior? - The role is updated to either `none` or `banner` based off the header's active state. - Added test. ## Does this introduce a breaking change? - [ ] Yes - [x] No ## Other information [Preview](https://ionic-framework-git-fw-6767-ionic1.vercel.app/src/components/header/test/condense/?ionic%3Amode=ios) --- core/src/components/header/header.tsx | 4 +- core/src/components/header/header.utils.ts | 38 ++++++++++++++++++ .../header/test/condense/header.e2e.ts | 40 +++++++++++++++++++ 3 files changed, 81 insertions(+), 1 deletion(-) diff --git a/core/src/components/header/header.tsx b/core/src/components/header/header.tsx index 6b2102a7db..ab93fada78 100644 --- a/core/src/components/header/header.tsx +++ b/core/src/components/header/header.tsx @@ -15,6 +15,7 @@ import { handleToolbarIntersection, setHeaderActive, setToolbarBackgroundOpacity, + getRoleType, } from './header.utils'; /** @@ -208,9 +209,10 @@ export class Header implements ComponentInterface { const { translucent, inheritedAttributes } = this; const mode = getIonMode(this); const collapse = this.collapse || 'none'; + const isCondensed = collapse === 'condense'; // banner role must be at top level, so remove role if inside a menu - const roleType = hostContext('ion-menu', this.el) ? 'none' : 'banner'; + const roleType = getRoleType(hostContext('ion-menu', this.el), isCondensed, mode); return ( { const ionTitles = toolbars.map((toolbar) => toolbar.ionTitleEl); if (active) { + headerEl.setAttribute('role', ROLE_BANNER); headerEl.classList.remove('header-collapse-condense-inactive'); ionTitles.forEach((ionTitle) => { @@ -179,6 +182,16 @@ export const setHeaderActive = (headerIndex: HeaderIndex, active = true) => { } }); } else { + /** + * There can only be one banner landmark per page. + * By default, all ion-headers have the banner role. + * This causes an accessibility issue when using a + * condensed header since there are two ion-headers + * on the page at once (active and inactive). + * To solve this, the role needs to be toggled + * based on which header is active. + */ + headerEl.setAttribute('role', ROLE_NONE); headerEl.classList.add('header-collapse-condense-inactive'); /** @@ -244,3 +257,28 @@ export const handleHeaderFade = (scrollEl: HTMLElement, baseEl: HTMLElement, con }); }); }; + +/** + * Get the role type for the ion-header. + * + * @param isInsideMenu If ion-header is inside ion-menu. + * @param isCondensed If ion-header has collapse="condense". + * @param mode The current mode. + * @returns 'none' if inside ion-menu or if condensed in md + * mode, otherwise 'banner'. + */ +export const getRoleType = (isInsideMenu: boolean, isCondensed: boolean, mode: 'ios' | 'md') => { + // If the header is inside a menu, it should not have the banner role. + if (isInsideMenu) { + return ROLE_NONE; + } + /** + * Only apply role="none" to `md` mode condensed headers + * since the large header is never shown. + */ + if (isCondensed && mode === 'md') { + return ROLE_NONE; + } + // Default to banner role. + return ROLE_BANNER; +}; diff --git a/core/src/components/header/test/condense/header.e2e.ts b/core/src/components/header/test/condense/header.e2e.ts index b57d1ee58f..c416532973 100644 --- a/core/src/components/header/test/condense/header.e2e.ts +++ b/core/src/components/header/test/condense/header.e2e.ts @@ -40,5 +40,45 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, screenshot, c await expect(smallTitle).toHaveAttribute('aria-hidden', 'true'); }); + + test('should only have the banner role on the active header', async ({ page }) => { + await page.goto('/src/components/header/test/condense', config); + const largeTitleHeader = page.locator('#largeTitleHeader'); + const smallTitleHeader = page.locator('#smallTitleHeader'); + const content = page.locator('ion-content'); + + await expect(largeTitleHeader).toHaveAttribute('role', 'banner'); + await expect(smallTitleHeader).toHaveAttribute('role', 'none'); + + await content.evaluate(async (el: HTMLIonContentElement) => { + await el.scrollToBottom(); + }); + await page.locator('#largeTitleHeader.header-collapse-condense-inactive').waitFor(); + + await expect(largeTitleHeader).toHaveAttribute('role', 'none'); + await expect(smallTitleHeader).toHaveAttribute('role', 'banner'); + }); + }); +}); + +configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('header: condense'), () => { + test('should only have the banner role on the small header', async ({ page }) => { + await page.goto('/src/components/header/test/condense', config); + const largeTitleHeader = page.locator('#largeTitleHeader'); + const smallTitleHeader = page.locator('#smallTitleHeader'); + const content = page.locator('ion-content'); + + await expect(smallTitleHeader).toHaveAttribute('role', 'banner'); + await expect(largeTitleHeader).toHaveAttribute('role', 'none'); + + await content.evaluate(async (el: HTMLIonContentElement) => { + await el.scrollToBottom(); + }); + await page.waitForChanges(); + + await expect(smallTitleHeader).toHaveAttribute('role', 'banner'); + await expect(largeTitleHeader).toHaveAttribute('role', 'none'); + }); }); });