From 5bb8335ec3de02ad763b18416c246ad25c518244 Mon Sep 17 00:00:00 2001 From: Maria Hutt Date: Wed, 8 Oct 2025 09:40:11 -0700 Subject: [PATCH 1/3] fix(header): ensure single banner role in condensed header --- core/src/components/header/header.tsx | 3 ++- core/src/components/header/header.utils.ts | 23 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/core/src/components/header/header.tsx b/core/src/components/header/header.tsx index 6b2102a7db3..a7d2677f481 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'; /** @@ -210,7 +211,7 @@ export class Header implements ComponentInterface { const collapse = this.collapse || 'none'; // 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)); 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,13 @@ 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. + * @returns 'none' if inside ion-menu, otherwise 'banner'. + */ +export const getRoleType = (isInsideMenu: boolean) => { + return isInsideMenu ? ROLE_NONE : ROLE_BANNER; +}; From f6dba2317b435bebf6f3fa53e25a76f3e0cc671e Mon Sep 17 00:00:00 2001 From: Maria Hutt Date: Wed, 8 Oct 2025 10:12:02 -0700 Subject: [PATCH 2/3] test(header): verify the roles --- .../header/test/condense/header.e2e.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/core/src/components/header/test/condense/header.e2e.ts b/core/src/components/header/test/condense/header.e2e.ts index b57d1ee58f7..2a87aa0a68a 100644 --- a/core/src/components/header/test/condense/header.e2e.ts +++ b/core/src/components/header/test/condense/header.e2e.ts @@ -40,5 +40,23 @@ 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'); + }); }); }); From 98fcbed8f57919a22c49ede7efaaac8453be0429 Mon Sep 17 00:00:00 2001 From: Maria Hutt Date: Fri, 10 Oct 2025 15:34:58 -0700 Subject: [PATCH 3/3] test(header): check role on md --- core/src/components/header/header.tsx | 3 ++- core/src/components/header/header.utils.ts | 21 +++++++++++++++--- .../header/test/condense/header.e2e.ts | 22 +++++++++++++++++++ 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/core/src/components/header/header.tsx b/core/src/components/header/header.tsx index a7d2677f481..ab93fada783 100644 --- a/core/src/components/header/header.tsx +++ b/core/src/components/header/header.tsx @@ -209,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 = getRoleType(hostContext('ion-menu', this.el)); + const roleType = getRoleType(hostContext('ion-menu', this.el), isCondensed, mode); return ( { - return isInsideMenu ? ROLE_NONE : ROLE_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 2a87aa0a68a..c416532973e 100644 --- a/core/src/components/header/test/condense/header.e2e.ts +++ b/core/src/components/header/test/condense/header.e2e.ts @@ -60,3 +60,25 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, screenshot, c }); }); }); + +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'); + }); + }); +});