Skip to content
Merged
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
4 changes: 3 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 @@ -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
Expand Down
38 changes: 38 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,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') => {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

We'll might have to update the mode to theme in next.

// 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 changes: 40 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,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');
});
});
});
Loading