Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion core/src/components/header/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
handleToolbarIntersection,
setHeaderActive,
setToolbarBackgroundOpacity,
getRoleType,
} from './header.utils';

/**
Expand Down Expand Up @@ -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 (
<Host
Expand Down
23 changes: 23 additions & 0 deletions core/src/components/header/header.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { readTask, writeTask } from '@stencil/core';
import { clamp } from '@utils/helpers';

const TRANSITION = 'all 0.2s ease-in-out';
const ROLE_NONE = 'none';
const ROLE_BANNER = 'banner';

interface HeaderIndex {
el: HTMLIonHeaderElement;
Expand Down Expand Up @@ -171,6 +173,7 @@ export const setHeaderActive = (headerIndex: HeaderIndex, active = true) => {
const ionTitles = toolbars.map((toolbar) => toolbar.ionTitleEl);

if (active) {
headerEl.setAttribute('role', ROLE_BANNER);
headerEl.classList.remove('header-collapse-condense-inactive');

ionTitles.forEach((ionTitle) => {
Expand All @@ -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');

/**
Expand Down Expand Up @@ -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;
};
18 changes: 18 additions & 0 deletions core/src/components/header/test/condense/header.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Copy link
Member

@brandyscarney brandyscarney Oct 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This works great for ios but md still has two banner roles:

Image

It shouldn't be announced since the 2nd header is display none but it might still fail an audit.

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');
});
});
});
Loading