Skip to content

Commit bdb268a

Browse files
fix(menu): added focus trapping, improved compatibility with screen readers (#24076)
* fix(menu): add basic accessibility features * fix(menu): add focus trapping * test(menu): add test for focus trapping * style(menu): lint fixes * fix(menu): focus first element inside instead of whole menu * test(menu): fix focus trap test to account for new behavior * refactor(menu): pull focus handler into its own prop * test(menu): add a11y testing * fix(menu): prevent nested aria landmark from header inside menu * fix(menu): revert switch to nav element * fix(menu): remove unnecessary import from test * fix(menu): allow for custom aria-label * fix(menu): move nested ARIA role logic to header for flexibility * fix(item): only add focusable class if it actually is focusable * fix(menu): allow focusing of menu itself, for a11y on menus with no focusable children * fix(item): move isFocusable logic to state for better reactivity * perf(item): only grab one focusable child * fix(menu): hide page content from screen readers when menu is open * fix(menu): fallback to focusing host element * docs(menu): add comments Co-authored-by: Liam DeBeasi <liamdebeasi@icloud.com>
1 parent 2916810 commit bdb268a

File tree

7 files changed

+224
-9
lines changed

7 files changed

+224
-9
lines changed

core/src/components/header/header.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Component, ComponentInterface, Element, Host, Prop, h, writeTask } from
22

33
import { getIonMode } from '../../global/ionic-global';
44
import { inheritAttributes } from '../../utils/helpers';
5+
import { hostContext } from '../../utils/theme';
56

67
import { cloneElement, createHeaderIndex, handleContentScroll, handleToolbarIntersection, setHeaderActive, setToolbarBackgroundOpacity } from './header.utils';
78

@@ -154,9 +155,12 @@ export class Header implements ComponentInterface {
154155
const mode = getIonMode(this);
155156
const collapse = this.collapse || 'none';
156157

158+
// banner role must be at top level, so remove role if inside a menu
159+
const roleType = hostContext('ion-menu', this.el) ? 'none' : 'banner';
160+
157161
return (
158162
<Host
159-
role="banner"
163+
role={roleType}
160164
class={{
161165
[mode]: true,
162166

core/src/components/item/item.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac
3535
@Element() el!: HTMLIonItemElement;
3636

3737
@State() multipleInputs = false;
38+
@State() focusable = true;
3839

3940
/**
4041
* The color to use from your application's color palette.
@@ -173,7 +174,10 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac
173174
}
174175

175176
componentDidLoad() {
176-
raf(() => this.setMultipleInputs());
177+
raf(() => {
178+
this.setMultipleInputs();
179+
this.focusable = this.isFocusable();
180+
});
177181
}
178182

179183
// If the item contains multiple clickable elements and/or inputs, then the item
@@ -217,6 +221,11 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac
217221
return (this.isClickable() || this.hasCover());
218222
}
219223

224+
private isFocusable(): boolean {
225+
const focusableChild = this.el.querySelector('.ion-focusable');
226+
return (this.canActivate() || focusableChild !== null);
227+
}
228+
220229
private getFirstInput(): HTMLIonInputElement | HTMLIonTextareaElement {
221230
const inputs = this.el.querySelectorAll('ion-input, ion-textarea') as NodeListOf<HTMLIonInputElement | HTMLIonTextareaElement>;
222231
return inputs[0];
@@ -289,7 +298,7 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac
289298
'in-list': hostContext('ion-list', this.el),
290299
'item-multiple-inputs': this.multipleInputs,
291300
'ion-activatable': canActivate,
292-
'ion-focusable': true,
301+
'ion-focusable': this.focusable
293302
})
294303
}}
295304
>

core/src/components/menu/menu.tsx

Lines changed: 127 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@ import { getIonMode } from '../../global/ionic-global';
55
import { Animation, Gesture, GestureDetail, MenuChangeEventDetail, MenuI, Side } from '../../interface';
66
import { getTimeGivenProgression } from '../../utils/animation/cubic-bezier';
77
import { GESTURE_CONTROLLER } from '../../utils/gesture';
8-
import { assert, clamp, isEndSide as isEnd } from '../../utils/helpers';
8+
import { assert, clamp, inheritAttributes, isEndSide as isEnd } from '../../utils/helpers';
99
import { menuController } from '../../utils/menu-controller';
1010

1111
const iosEasing = 'cubic-bezier(0.32,0.72,0,1)';
1212
const mdEasing = 'cubic-bezier(0.0,0.0,0.2,1)';
1313
const iosEasingReverse = 'cubic-bezier(1, 0, 0.68, 0.28)';
1414
const mdEasingReverse = 'cubic-bezier(0.4, 0, 0.6, 1)';
15+
const focusableQueryString = '[tabindex]:not([tabindex^="-"]), input:not([type=hidden]):not([tabindex^="-"]), textarea:not([tabindex^="-"]), button:not([tabindex^="-"]), select:not([tabindex^="-"]), .ion-focusable:not([tabindex^="-"])';
1516

1617
/**
1718
* @part container - The container for the menu content.
@@ -39,6 +40,11 @@ export class Menu implements ComponentInterface, MenuI {
3940
backdropEl?: HTMLElement;
4041
menuInnerEl?: HTMLElement;
4142
contentEl?: HTMLElement;
43+
lastFocus?: HTMLElement;
44+
45+
private inheritedAttributes: { [k: string]: any } = {};
46+
47+
private handleFocus = (ev: Event) => this.trapKeyboardFocus(ev, document);
4248

4349
@Element() el!: HTMLIonMenuElement;
4450

@@ -159,6 +165,7 @@ export class Menu implements ComponentInterface, MenuI {
159165

160166
const el = this.el;
161167
const parent = el.parentNode as any;
168+
162169
if (this.contentId === undefined) {
163170
console.warn(`[DEPRECATED][ion-menu] Using the [main] attribute is deprecated, please use the "contentId" property instead:
164171
BEFORE:
@@ -205,6 +212,10 @@ AFTER:
205212
this.updateState();
206213
}
207214

215+
componentWillLoad() {
216+
this.inheritedAttributes = inheritAttributes(this.el, ['aria-label']);
217+
}
218+
208219
async componentDidLoad() {
209220
this.ionMenuChange.emit({ disabled: this.disabled, open: this._isOpen });
210221
this.updateState();
@@ -246,6 +257,13 @@ AFTER:
246257
}
247258
}
248259

260+
@Listen('keydown')
261+
onKeydown(ev: KeyboardEvent) {
262+
if (ev.key === 'Escape') {
263+
this.close();
264+
}
265+
}
266+
249267
/**
250268
* Returns `true` is the menu is open.
251269
*/
@@ -301,6 +319,65 @@ AFTER:
301319
return menuController._setOpen(this, shouldOpen, animated);
302320
}
303321

322+
private focusFirstDescendant() {
323+
const { el } = this;
324+
const firstInput = el.querySelector(focusableQueryString) as HTMLElement | null;
325+
326+
if (firstInput) {
327+
firstInput.focus();
328+
} else {
329+
el.focus();
330+
}
331+
}
332+
333+
private focusLastDescendant() {
334+
const { el } = this;
335+
const inputs = Array.from(el.querySelectorAll<HTMLElement>(focusableQueryString));
336+
const lastInput = inputs.length > 0 ? inputs[inputs.length - 1] : null;
337+
338+
if (lastInput) {
339+
lastInput.focus();
340+
} else {
341+
el.focus();
342+
}
343+
}
344+
345+
private trapKeyboardFocus(ev: Event, doc: Document) {
346+
const target = ev.target as HTMLElement | null;
347+
if (!target) { return; }
348+
349+
/**
350+
* If the target is inside the menu contents, let the browser
351+
* focus as normal and keep a log of the last focused element.
352+
*/
353+
if (this.el.contains(target)) {
354+
this.lastFocus = target;
355+
} else {
356+
/**
357+
* Otherwise, we are about to have focus go out of the menu.
358+
* Wrap the focus to either the first or last element.
359+
*/
360+
361+
/**
362+
* Once we call `focusFirstDescendant`, another focus event
363+
* will fire, which will cause `lastFocus` to be updated
364+
* before we can run the code after that. We cache the value
365+
* here to avoid that.
366+
*/
367+
this.focusFirstDescendant();
368+
369+
/**
370+
* If the cached last focused element is the same as the now-
371+
* active element, that means the user was on the first element
372+
* already and pressed Shift + Tab, so we need to wrap to the
373+
* last descendant.
374+
*/
375+
if (this.lastFocus === doc.activeElement) {
376+
this.focusLastDescendant();
377+
}
378+
}
379+
}
380+
304381
async _setOpen(shouldOpen: boolean, animated = true): Promise<boolean> {
305382
// If the menu is disabled or it is currently being animated, let's do nothing
306383
if (!this._isActive() || this.isAnimating || shouldOpen === this._isOpen) {
@@ -479,6 +556,16 @@ AFTER:
479556
// this places the menu into the correct location before it animates in
480557
// this css class doesn't actually kick off any animations
481558
this.el.classList.add(SHOW_MENU);
559+
560+
/**
561+
* We add a tabindex here so that focus trapping
562+
* still works even if the menu does not have
563+
* any focusable elements slotted inside. The
564+
* focus trapping utility will fallback to focusing
565+
* the menu so focus does not leave when the menu
566+
* is open.
567+
*/
568+
this.el.setAttribute('tabindex', '0');
482569
if (this.backdropEl) {
483570
this.backdropEl.classList.add(SHOW_BACKDROP);
484571
}
@@ -505,19 +592,51 @@ AFTER:
505592
}
506593

507594
if (isOpen) {
508-
// add css class
595+
// add css class and hide content behind menu from screen readers
509596
if (this.contentEl) {
510597
this.contentEl.classList.add(MENU_CONTENT_OPEN);
598+
599+
/**
600+
* When the menu is open and overlaying the main
601+
* content, the main content should not be announced
602+
* by the screenreader as the menu is the main
603+
* focus. This is useful with screenreaders that have
604+
* "read from top" gestures that read the entire
605+
* page from top to bottom when activated.
606+
*/
607+
this.contentEl.setAttribute('aria-hidden', 'true');
511608
}
512609

513610
// emit open event
514611
this.ionDidOpen.emit();
612+
613+
// focus menu content for screen readers
614+
if (this.menuInnerEl) {
615+
this.focusFirstDescendant();
616+
}
617+
618+
// setup focus trapping
619+
document.addEventListener('focus', this.handleFocus, true);
515620
} else {
516-
// remove css classes
621+
// remove css classes and unhide content from screen readers
517622
this.el.classList.remove(SHOW_MENU);
623+
624+
/**
625+
* Remove tabindex from the menu component
626+
* so that is cannot be tabbed to.
627+
*/
628+
this.el.removeAttribute('tabindex');
518629
if (this.contentEl) {
519630
this.contentEl.classList.remove(MENU_CONTENT_OPEN);
631+
632+
/**
633+
* Remove aria-hidden so screen readers
634+
* can announce the main content again
635+
* now that the menu is not the main focus.
636+
*/
637+
this.contentEl.removeAttribute('aria-hidden');
520638
}
639+
521640
if (this.backdropEl) {
522641
this.backdropEl.classList.remove(SHOW_BACKDROP);
523642
}
@@ -528,6 +647,9 @@ AFTER:
528647

529648
// emit close event
530649
this.ionDidClose.emit();
650+
651+
// undo focus trapping so multiple menus don't collide
652+
document.removeEventListener('focus', this.handleFocus, true);
531653
}
532654
}
533655

@@ -561,12 +683,13 @@ AFTER:
561683
}
562684

563685
render() {
564-
const { isEndSide, type, disabled, isPaneVisible } = this;
686+
const { isEndSide, type, disabled, isPaneVisible, inheritedAttributes } = this;
565687
const mode = getIonMode(this);
566688

567689
return (
568690
<Host
569691
role="navigation"
692+
aria-label={inheritedAttributes['aria-label'] || 'menu'}
570693
class={{
571694
[mode]: true,
572695
[`menu-type-${type}`]: true,
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { newE2EPage } from '@stencil/core/testing';
2+
import { AxePuppeteer } from '@axe-core/puppeteer';
3+
4+
test('menu: axe', async () => {
5+
const page = await newE2EPage({
6+
url: '/src/components/menu/test/a11y?ionic:_testing=true'
7+
});
8+
9+
const menu = await page.find('ion-menu');
10+
await menu.callMethod('open');
11+
await menu.waitForVisible();
12+
13+
const results = await new AxePuppeteer(page).analyze();
14+
expect(results.violations.length).toEqual(0);
15+
});
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<!DOCTYPE html>
2+
<html lang="en" dir="ltr">
3+
4+
<head>
5+
<meta charset="UTF-8">
6+
<title>Segment - a11y</title>
7+
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0">
8+
<link href="../../../../../css/core.css" rel="stylesheet">
9+
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet">
10+
<script src="../../../../../scripts/testing/scripts.js"></script>
11+
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
12+
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
13+
</head>
14+
15+
<body>
16+
<main>
17+
<h1>Menu</h1>
18+
<ion-menu menu-id="menu" content-id="main-content">
19+
<ion-header>
20+
<ion-toolbar>
21+
<ion-title>Menu</ion-title>
22+
</ion-toolbar>
23+
</ion-header>
24+
<ion-content>
25+
<ion-list>
26+
<ion-item>
27+
<ion-button>Button</ion-button>
28+
</ion-item>
29+
<ion-item>
30+
<ion-button>Button 2</ion-button>
31+
</ion-item>
32+
<ion-item>Menu Item</ion-item>
33+
<ion-item>Menu Item</ion-item>
34+
<ion-item>Menu Item</ion-item>
35+
</ion-list>
36+
</ion-content>
37+
</ion-menu>
38+
</main>
39+
</body>
40+
41+
</html>

core/src/components/menu/test/basic/e2e.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { testMenu } from '../test.utils';
2+
import { newE2EPage } from '@stencil/core/testing';
23

34
const DIRECTORY = 'basic';
5+
const getActiveElementID = async (page) => {
6+
const activeElement = await page.evaluateHandle(() => document.activeElement);
7+
return await page.evaluate(el => el && el.id, activeElement);
8+
}
49

510
test('menu: start menu', async () => {
611
await testMenu(DIRECTORY, '#start-menu', 'first');
@@ -14,6 +19,21 @@ test('menu: end menu', async () => {
1419
await testMenu(DIRECTORY, '#end-menu');
1520
});
1621

22+
test('menu: focus trap', async () => {
23+
const page = await newE2EPage({ url: '/src/components/menu/test/basic?ionic:_testing=true' });
24+
25+
await page.click('#open-first');
26+
const menu = await page.find('#start-menu');
27+
await menu.waitForVisible();
28+
29+
let activeElID = await getActiveElementID(page);
30+
expect(activeElID).toEqual('start-menu-button');
31+
32+
await page.keyboard.press('Tab');
33+
activeElID = await getActiveElementID(page);
34+
expect(activeElID).toEqual('start-menu-button');
35+
});
36+
1737
/**
1838
* RTL Tests
1939
*/

0 commit comments

Comments
 (0)