chore(): sync with main for rc1

This commit is contained in:
Liam DeBeasi
2021-10-27 09:21:44 -04:00
8 changed files with 235 additions and 9 deletions

View File

@ -1,3 +1,14 @@
## [5.8.5](https://github.com/ionic-team/ionic/compare/v5.8.4...v5.8.5) (2021-10-27)
### Bug Fixes
* **menu:** added focus trapping, improved compatibility with screen readers ([#24076](https://github.com/ionic-team/ionic/issues/24076)) ([bdb268a](https://github.com/ionic-team/ionic/commit/bdb268aa12c5bf411c96529672486d35e018cefa))
* **vue:** back button now selects correct route when navigating from view multiple times ([#24060](https://github.com/ionic-team/ionic/issues/24060)) ([a09d7d4](https://github.com/ionic-team/ionic/commit/a09d7d4ab6dd0d90204015eaaf232ed190753c56)), closes [#23987](https://github.com/ionic-team/ionic/issues/23987)
* **vue:** mount correct views when navigating ([#24056](https://github.com/ionic-team/ionic/issues/24056)) ([24659a5](https://github.com/ionic-team/ionic/commit/24659a527abe0c70df7e8ae6da3dcb4017bf500c)), closes [#23914](https://github.com/ionic-team/ionic/issues/23914)
## [5.8.4](https://github.com/ionic-team/ionic/compare/v5.8.3...v5.8.4) (2021-10-11) ## [5.8.4](https://github.com/ionic-team/ionic/compare/v5.8.3...v5.8.4) (2021-10-11)

View File

@ -2,6 +2,7 @@ import { Component, ComponentInterface, Element, Host, Prop, h, writeTask } from
import { getIonMode } from '../../global/ionic-global'; import { getIonMode } from '../../global/ionic-global';
import { componentOnReady, inheritAttributes } from '../../utils/helpers'; import { componentOnReady, inheritAttributes } from '../../utils/helpers';
import { hostContext } from '../../utils/theme';
import { cloneElement, createHeaderIndex, handleContentScroll, handleHeaderFade, handleToolbarIntersection, setHeaderActive, setToolbarBackgroundOpacity } from './header.utils'; import { cloneElement, createHeaderIndex, handleContentScroll, handleHeaderFade, handleToolbarIntersection, setHeaderActive, setToolbarBackgroundOpacity } from './header.utils';
@ -173,9 +174,12 @@ export class Header implements ComponentInterface {
const mode = getIonMode(this); const mode = getIonMode(this);
const collapse = this.collapse || 'none'; 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';
return ( return (
<Host <Host
role="banner" role={roleType}
class={{ class={{
[mode]: true, [mode]: true,

View File

@ -38,6 +38,7 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac
@Element() el!: HTMLIonItemElement; @Element() el!: HTMLIonItemElement;
@State() multipleInputs = false; @State() multipleInputs = false;
@State() focusable = true;
/** /**
* The color to use from your application's color palette. * The color to use from your application's color palette.
@ -209,7 +210,10 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac
} }
componentDidLoad() { componentDidLoad() {
raf(() => this.setMultipleInputs()); raf(() => {
this.setMultipleInputs();
this.focusable = this.isFocusable();
});
} }
// If the item contains multiple clickable elements and/or inputs, then the item // If the item contains multiple clickable elements and/or inputs, then the item
@ -253,6 +257,11 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac
return (this.isClickable() || this.hasCover()); return (this.isClickable() || this.hasCover());
} }
private isFocusable(): boolean {
const focusableChild = this.el.querySelector('.ion-focusable');
return (this.canActivate() || focusableChild !== null);
}
private getFirstInput(): HTMLIonInputElement | HTMLIonTextareaElement { private getFirstInput(): HTMLIonInputElement | HTMLIonTextareaElement {
const inputs = this.el.querySelectorAll('ion-input, ion-textarea') as NodeListOf<HTMLIonInputElement | HTMLIonTextareaElement>; const inputs = this.el.querySelectorAll('ion-input, ion-textarea') as NodeListOf<HTMLIonInputElement | HTMLIonTextareaElement>;
return inputs[0]; return inputs[0];
@ -341,7 +350,7 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac
'in-list': hostContext('ion-list', this.el), 'in-list': hostContext('ion-list', this.el),
'item-multiple-inputs': this.multipleInputs, 'item-multiple-inputs': this.multipleInputs,
'ion-activatable': canActivate, 'ion-activatable': canActivate,
'ion-focusable': true, 'ion-focusable': this.focusable
'item-rtl': document.dir === 'rtl' 'item-rtl': document.dir === 'rtl'
}) })
}} }}

View File

@ -5,13 +5,14 @@ import { getIonMode } from '../../global/ionic-global';
import { Animation, Gesture, GestureDetail, MenuChangeEventDetail, MenuI, Side } from '../../interface'; import { Animation, Gesture, GestureDetail, MenuChangeEventDetail, MenuI, Side } from '../../interface';
import { getTimeGivenProgression } from '../../utils/animation/cubic-bezier'; import { getTimeGivenProgression } from '../../utils/animation/cubic-bezier';
import { GESTURE_CONTROLLER } from '../../utils/gesture'; import { GESTURE_CONTROLLER } from '../../utils/gesture';
import { assert, clamp, isEndSide as isEnd } from '../../utils/helpers'; import { assert, clamp, inheritAttributes, isEndSide as isEnd } from '../../utils/helpers';
import { menuController } from '../../utils/menu-controller'; import { menuController } from '../../utils/menu-controller';
const iosEasing = 'cubic-bezier(0.32,0.72,0,1)'; const iosEasing = 'cubic-bezier(0.32,0.72,0,1)';
const mdEasing = 'cubic-bezier(0.0,0.0,0.2,1)'; const mdEasing = 'cubic-bezier(0.0,0.0,0.2,1)';
const iosEasingReverse = 'cubic-bezier(1, 0, 0.68, 0.28)'; const iosEasingReverse = 'cubic-bezier(1, 0, 0.68, 0.28)';
const mdEasingReverse = 'cubic-bezier(0.4, 0, 0.6, 1)'; const mdEasingReverse = 'cubic-bezier(0.4, 0, 0.6, 1)';
const focusableQueryString = '[tabindex]:not([tabindex^="-"]), input:not([type=hidden]):not([tabindex^="-"]), textarea:not([tabindex^="-"]), button:not([tabindex^="-"]), select:not([tabindex^="-"]), .ion-focusable:not([tabindex^="-"])';
/** /**
* @part container - The container for the menu content. * @part container - The container for the menu content.
@ -39,6 +40,11 @@ export class Menu implements ComponentInterface, MenuI {
backdropEl?: HTMLElement; backdropEl?: HTMLElement;
menuInnerEl?: HTMLElement; menuInnerEl?: HTMLElement;
contentEl?: HTMLElement; contentEl?: HTMLElement;
lastFocus?: HTMLElement;
private inheritedAttributes: { [k: string]: any } = {};
private handleFocus = (ev: Event) => this.trapKeyboardFocus(ev, document);
@Element() el!: HTMLIonMenuElement; @Element() el!: HTMLIonMenuElement;
@ -165,6 +171,7 @@ export class Menu implements ComponentInterface, MenuI {
const el = this.el; const el = this.el;
const parent = el.parentNode as any; const parent = el.parentNode as any;
if (this.contentId === undefined) { if (this.contentId === undefined) {
console.warn(`[DEPRECATED][ion-menu] Using the [main] attribute is deprecated, please use the "contentId" property instead: console.warn(`[DEPRECATED][ion-menu] Using the [main] attribute is deprecated, please use the "contentId" property instead:
BEFORE: BEFORE:
@ -216,6 +223,10 @@ AFTER:
this.updateState(); this.updateState();
} }
componentWillLoad() {
this.inheritedAttributes = inheritAttributes(this.el, ['aria-label']);
}
async componentDidLoad() { async componentDidLoad() {
this.ionMenuChange.emit({ disabled: this.disabled, open: this._isOpen }); this.ionMenuChange.emit({ disabled: this.disabled, open: this._isOpen });
this.updateState(); this.updateState();
@ -257,6 +268,13 @@ AFTER:
} }
} }
@Listen('keydown')
onKeydown(ev: KeyboardEvent) {
if (ev.key === 'Escape') {
this.close();
}
}
/** /**
* Returns `true` is the menu is open. * Returns `true` is the menu is open.
*/ */
@ -312,6 +330,65 @@ AFTER:
return menuController._setOpen(this, shouldOpen, animated); return menuController._setOpen(this, shouldOpen, animated);
} }
private focusFirstDescendant() {
const { el } = this;
const firstInput = el.querySelector(focusableQueryString) as HTMLElement | null;
if (firstInput) {
firstInput.focus();
} else {
el.focus();
}
}
private focusLastDescendant() {
const { el } = this;
const inputs = Array.from(el.querySelectorAll<HTMLElement>(focusableQueryString));
const lastInput = inputs.length > 0 ? inputs[inputs.length - 1] : null;
if (lastInput) {
lastInput.focus();
} else {
el.focus();
}
}
private trapKeyboardFocus(ev: Event, doc: Document) {
const target = ev.target as HTMLElement | null;
if (!target) { return; }
/**
* If the target is inside the menu contents, let the browser
* focus as normal and keep a log of the last focused element.
*/
if (this.el.contains(target)) {
this.lastFocus = target;
} else {
/**
* Otherwise, we are about to have focus go out of the menu.
* Wrap the focus to either the first or last element.
*/
/**
* Once we call `focusFirstDescendant`, another focus event
* will fire, which will cause `lastFocus` to be updated
* before we can run the code after that. We cache the value
* here to avoid that.
*/
this.focusFirstDescendant();
/**
* If the cached last focused element is the same as the now-
* active element, that means the user was on the first element
* already and pressed Shift + Tab, so we need to wrap to the
* last descendant.
*/
if (this.lastFocus === doc.activeElement) {
this.focusLastDescendant();
}
}
}
async _setOpen(shouldOpen: boolean, animated = true): Promise<boolean> { async _setOpen(shouldOpen: boolean, animated = true): Promise<boolean> {
// If the menu is disabled or it is currently being animated, let's do nothing // If the menu is disabled or it is currently being animated, let's do nothing
if (!this._isActive() || this.isAnimating || shouldOpen === this._isOpen) { if (!this._isActive() || this.isAnimating || shouldOpen === this._isOpen) {
@ -490,6 +567,16 @@ AFTER:
// this places the menu into the correct location before it animates in // this places the menu into the correct location before it animates in
// this css class doesn't actually kick off any animations // this css class doesn't actually kick off any animations
this.el.classList.add(SHOW_MENU); this.el.classList.add(SHOW_MENU);
/**
* We add a tabindex here so that focus trapping
* still works even if the menu does not have
* any focusable elements slotted inside. The
* focus trapping utility will fallback to focusing
* the menu so focus does not leave when the menu
* is open.
*/
this.el.setAttribute('tabindex', '0');
if (this.backdropEl) { if (this.backdropEl) {
this.backdropEl.classList.add(SHOW_BACKDROP); this.backdropEl.classList.add(SHOW_BACKDROP);
} }
@ -516,19 +603,51 @@ AFTER:
} }
if (isOpen) { if (isOpen) {
// add css class // add css class and hide content behind menu from screen readers
if (this.contentEl) { if (this.contentEl) {
this.contentEl.classList.add(MENU_CONTENT_OPEN); this.contentEl.classList.add(MENU_CONTENT_OPEN);
/**
* When the menu is open and overlaying the main
* content, the main content should not be announced
* by the screenreader as the menu is the main
* focus. This is useful with screenreaders that have
* "read from top" gestures that read the entire
* page from top to bottom when activated.
*/
this.contentEl.setAttribute('aria-hidden', 'true');
} }
// emit open event // emit open event
this.ionDidOpen.emit(); this.ionDidOpen.emit();
// focus menu content for screen readers
if (this.menuInnerEl) {
this.focusFirstDescendant();
}
// setup focus trapping
document.addEventListener('focus', this.handleFocus, true);
} else { } else {
// remove css classes // remove css classes and unhide content from screen readers
this.el.classList.remove(SHOW_MENU); this.el.classList.remove(SHOW_MENU);
/**
* Remove tabindex from the menu component
* so that is cannot be tabbed to.
*/
this.el.removeAttribute('tabindex');
if (this.contentEl) { if (this.contentEl) {
this.contentEl.classList.remove(MENU_CONTENT_OPEN); this.contentEl.classList.remove(MENU_CONTENT_OPEN);
/**
* Remove aria-hidden so screen readers
* can announce the main content again
* now that the menu is not the main focus.
*/
this.contentEl.removeAttribute('aria-hidden');
} }
if (this.backdropEl) { if (this.backdropEl) {
this.backdropEl.classList.remove(SHOW_BACKDROP); this.backdropEl.classList.remove(SHOW_BACKDROP);
} }
@ -539,6 +658,9 @@ AFTER:
// emit close event // emit close event
this.ionDidClose.emit(); this.ionDidClose.emit();
// undo focus trapping so multiple menus don't collide
document.removeEventListener('focus', this.handleFocus, true);
} }
} }
@ -572,12 +694,13 @@ AFTER:
} }
render() { render() {
const { isEndSide, type, disabled, isPaneVisible } = this; const { isEndSide, type, disabled, isPaneVisible, inheritedAttributes } = this;
const mode = getIonMode(this); const mode = getIonMode(this);
return ( return (
<Host <Host
role="navigation" role="navigation"
aria-label={inheritedAttributes['aria-label'] || 'menu'}
class={{ class={{
[mode]: true, [mode]: true,
[`menu-type-${type}`]: true, [`menu-type-${type}`]: true,

View File

@ -0,0 +1,15 @@
import { newE2EPage } from '@stencil/core/testing';
import { AxePuppeteer } from '@axe-core/puppeteer';
test('menu: axe', async () => {
const page = await newE2EPage({
url: '/src/components/menu/test/a11y?ionic:_testing=true'
});
const menu = await page.find('ion-menu');
await menu.callMethod('open');
await menu.waitForVisible();
const results = await new AxePuppeteer(page).analyze();
expect(results.violations.length).toEqual(0);
});

View File

@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8">
<title>Segment - a11y</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0">
<link href="../../../../../css/core.css" rel="stylesheet">
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet">
<script src="../../../../../scripts/testing/scripts.js"></script>
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
</head>
<body>
<main>
<h1>Menu</h1>
<ion-menu menu-id="menu" content-id="main-content">
<ion-header>
<ion-toolbar>
<ion-title>Menu</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-list>
<ion-item>
<ion-button>Button</ion-button>
</ion-item>
<ion-item>
<ion-button>Button 2</ion-button>
</ion-item>
<ion-item>Menu Item</ion-item>
<ion-item>Menu Item</ion-item>
<ion-item>Menu Item</ion-item>
</ion-list>
</ion-content>
</ion-menu>
</main>
</body>
</html>

View File

@ -1,6 +1,11 @@
import { testMenu } from '../test.utils'; import { testMenu } from '../test.utils';
import { newE2EPage } from '@stencil/core/testing';
const DIRECTORY = 'basic'; const DIRECTORY = 'basic';
const getActiveElementID = async (page) => {
const activeElement = await page.evaluateHandle(() => document.activeElement);
return await page.evaluate(el => el && el.id, activeElement);
}
test('menu: start menu', async () => { test('menu: start menu', async () => {
await testMenu(DIRECTORY, '#start-menu', 'first'); await testMenu(DIRECTORY, '#start-menu', 'first');
@ -14,6 +19,21 @@ test('menu: end menu', async () => {
await testMenu(DIRECTORY, '#end-menu'); await testMenu(DIRECTORY, '#end-menu');
}); });
test('menu: focus trap', async () => {
const page = await newE2EPage({ url: '/src/components/menu/test/basic?ionic:_testing=true' });
await page.click('#open-first');
const menu = await page.find('#start-menu');
await menu.waitForVisible();
let activeElID = await getActiveElementID(page);
expect(activeElID).toEqual('start-menu-button');
await page.keyboard.press('Tab');
activeElID = await getActiveElementID(page);
expect(activeElID).toEqual('start-menu-button');
});
/** /**
* RTL Tests * RTL Tests
*/ */

View File

@ -24,7 +24,7 @@
<body> <body>
<ion-app> <ion-app>
<ion-menu side="start" menu-id="first" id="start-menu" content-id="main" class="menu-part"> <ion-menu side="start" menu-id="first" id="start-menu" content-id="main" class="menu-part" aria-label="start menu">
<ion-header> <ion-header>
<ion-toolbar color="primary"> <ion-toolbar color="primary">
<ion-title>Start Menu</ion-title> <ion-title>Start Menu</ion-title>
@ -32,6 +32,9 @@
</ion-header> </ion-header>
<ion-content> <ion-content>
<ion-list> <ion-list>
<ion-item>
<ion-button id="start-menu-button">Button</ion-button>
</ion-item>
<ion-item>Menu Item</ion-item> <ion-item>Menu Item</ion-item>
<ion-item>Menu Item</ion-item> <ion-item>Menu Item</ion-item>
<ion-item>Menu Item</ion-item> <ion-item>Menu Item</ion-item>
@ -82,7 +85,7 @@
</ion-toolbar> </ion-toolbar>
</ion-header> </ion-header>
<ion-content class="ion-padding"> <ion-content class="ion-padding">
<ion-button expand="block" onclick="openFirst()">Open Start Menu</ion-button> <ion-button expand="block" id="open-first" onclick="openFirst()">Open Start Menu</ion-button>
<ion-button expand="block" onclick="openEnd()">Open End Menu</ion-button> <ion-button expand="block" onclick="openEnd()">Open End Menu</ion-button>
<ion-button expand="block" onclick="openCustom()">Open Custom Menu</ion-button> <ion-button expand="block" onclick="openCustom()">Open Custom Menu</ion-button>
</ion-content> </ion-content>