fix(header): ensure one banner role in condensed header (#30718)

Issue number: internal

---------

<!-- Please do not submit updates to dependencies unless it fixes an
issue. -->

<!-- Please try to limit your pull request to one type (bugfix, feature,
etc). Submit multiple pull requests if needed. -->

## What is the current behavior?
<!-- Please describe the current behavior that you are modifying. -->

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?
<!-- Please describe the behavior or changes that are being added by
this PR. -->

- 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

<!--
  If this introduces a breaking change:
1. Describe the impact and migration path for existing applications
below.
  2. Update the BREAKING.md file with the breaking change.
3. Add "BREAKING CHANGE: [...]" to the commit description when merging.
See
https://github.com/ionic-team/ionic-framework/blob/main/docs/CONTRIBUTING.md#footer
for more information.
-->


## Other information

<!-- Any other information that is important to this PR such as
screenshots of how the component looks before and after the change. -->


[Preview](https://ionic-framework-git-fw-6767-ionic1.vercel.app/src/components/header/test/condense/?ionic%3Amode=ios)
This commit is contained in:
Maria Hutt
2025-10-15 10:50:44 -07:00
committed by GitHub
parent add33c5995
commit 12084af163
3 changed files with 81 additions and 1 deletions

View File

@ -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 (
<Host

View File

@ -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;
@ -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) => {
@ -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;
};

View File

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