mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-11-01 09:38:28 +08:00
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:
@ -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
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user