mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-08-22 13:32:54 +08:00
chore(): sync with main for rc1
This commit is contained in:
11
CHANGELOG.md
11
CHANGELOG.md
@ -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)
|
||||
|
||||
|
||||
|
@ -2,6 +2,7 @@ import { Component, ComponentInterface, Element, Host, Prop, h, writeTask } from
|
||||
|
||||
import { getIonMode } from '../../global/ionic-global';
|
||||
import { componentOnReady, inheritAttributes } from '../../utils/helpers';
|
||||
import { hostContext } from '../../utils/theme';
|
||||
|
||||
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 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 (
|
||||
<Host
|
||||
role="banner"
|
||||
role={roleType}
|
||||
class={{
|
||||
[mode]: true,
|
||||
|
||||
|
@ -38,6 +38,7 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac
|
||||
@Element() el!: HTMLIonItemElement;
|
||||
|
||||
@State() multipleInputs = false;
|
||||
@State() focusable = true;
|
||||
|
||||
/**
|
||||
* The color to use from your application's color palette.
|
||||
@ -209,7 +210,10 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac
|
||||
}
|
||||
|
||||
componentDidLoad() {
|
||||
raf(() => this.setMultipleInputs());
|
||||
raf(() => {
|
||||
this.setMultipleInputs();
|
||||
this.focusable = this.isFocusable();
|
||||
});
|
||||
}
|
||||
|
||||
// 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());
|
||||
}
|
||||
|
||||
private isFocusable(): boolean {
|
||||
const focusableChild = this.el.querySelector('.ion-focusable');
|
||||
return (this.canActivate() || focusableChild !== null);
|
||||
}
|
||||
|
||||
private getFirstInput(): HTMLIonInputElement | HTMLIonTextareaElement {
|
||||
const inputs = this.el.querySelectorAll('ion-input, ion-textarea') as NodeListOf<HTMLIonInputElement | HTMLIonTextareaElement>;
|
||||
return inputs[0];
|
||||
@ -341,7 +350,7 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac
|
||||
'in-list': hostContext('ion-list', this.el),
|
||||
'item-multiple-inputs': this.multipleInputs,
|
||||
'ion-activatable': canActivate,
|
||||
'ion-focusable': true,
|
||||
'ion-focusable': this.focusable
|
||||
'item-rtl': document.dir === 'rtl'
|
||||
})
|
||||
}}
|
||||
|
@ -5,13 +5,14 @@ import { getIonMode } from '../../global/ionic-global';
|
||||
import { Animation, Gesture, GestureDetail, MenuChangeEventDetail, MenuI, Side } from '../../interface';
|
||||
import { getTimeGivenProgression } from '../../utils/animation/cubic-bezier';
|
||||
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';
|
||||
|
||||
const iosEasing = 'cubic-bezier(0.32,0.72,0,1)';
|
||||
const mdEasing = 'cubic-bezier(0.0,0.0,0.2,1)';
|
||||
const iosEasingReverse = 'cubic-bezier(1, 0, 0.68, 0.28)';
|
||||
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.
|
||||
@ -39,6 +40,11 @@ export class Menu implements ComponentInterface, MenuI {
|
||||
backdropEl?: HTMLElement;
|
||||
menuInnerEl?: HTMLElement;
|
||||
contentEl?: HTMLElement;
|
||||
lastFocus?: HTMLElement;
|
||||
|
||||
private inheritedAttributes: { [k: string]: any } = {};
|
||||
|
||||
private handleFocus = (ev: Event) => this.trapKeyboardFocus(ev, document);
|
||||
|
||||
@Element() el!: HTMLIonMenuElement;
|
||||
|
||||
@ -165,6 +171,7 @@ export class Menu implements ComponentInterface, MenuI {
|
||||
|
||||
const el = this.el;
|
||||
const parent = el.parentNode as any;
|
||||
|
||||
if (this.contentId === undefined) {
|
||||
console.warn(`[DEPRECATED][ion-menu] Using the [main] attribute is deprecated, please use the "contentId" property instead:
|
||||
BEFORE:
|
||||
@ -216,6 +223,10 @@ AFTER:
|
||||
this.updateState();
|
||||
}
|
||||
|
||||
componentWillLoad() {
|
||||
this.inheritedAttributes = inheritAttributes(this.el, ['aria-label']);
|
||||
}
|
||||
|
||||
async componentDidLoad() {
|
||||
this.ionMenuChange.emit({ disabled: this.disabled, open: this._isOpen });
|
||||
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.
|
||||
*/
|
||||
@ -312,6 +330,65 @@ AFTER:
|
||||
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> {
|
||||
// If the menu is disabled or it is currently being animated, let's do nothing
|
||||
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 css class doesn't actually kick off any animations
|
||||
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) {
|
||||
this.backdropEl.classList.add(SHOW_BACKDROP);
|
||||
}
|
||||
@ -516,19 +603,51 @@ AFTER:
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
// add css class
|
||||
// add css class and hide content behind menu from screen readers
|
||||
if (this.contentEl) {
|
||||
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
|
||||
this.ionDidOpen.emit();
|
||||
|
||||
// focus menu content for screen readers
|
||||
if (this.menuInnerEl) {
|
||||
this.focusFirstDescendant();
|
||||
}
|
||||
|
||||
// setup focus trapping
|
||||
document.addEventListener('focus', this.handleFocus, true);
|
||||
} else {
|
||||
// remove css classes
|
||||
// remove css classes and unhide content from screen readers
|
||||
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) {
|
||||
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) {
|
||||
this.backdropEl.classList.remove(SHOW_BACKDROP);
|
||||
}
|
||||
@ -539,6 +658,9 @@ AFTER:
|
||||
|
||||
// emit close event
|
||||
this.ionDidClose.emit();
|
||||
|
||||
// undo focus trapping so multiple menus don't collide
|
||||
document.removeEventListener('focus', this.handleFocus, true);
|
||||
}
|
||||
}
|
||||
|
||||
@ -572,12 +694,13 @@ AFTER:
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isEndSide, type, disabled, isPaneVisible } = this;
|
||||
const { isEndSide, type, disabled, isPaneVisible, inheritedAttributes } = this;
|
||||
const mode = getIonMode(this);
|
||||
|
||||
return (
|
||||
<Host
|
||||
role="navigation"
|
||||
aria-label={inheritedAttributes['aria-label'] || 'menu'}
|
||||
class={{
|
||||
[mode]: true,
|
||||
[`menu-type-${type}`]: true,
|
||||
|
15
core/src/components/menu/test/a11y/e2e.ts
Normal file
15
core/src/components/menu/test/a11y/e2e.ts
Normal 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);
|
||||
});
|
41
core/src/components/menu/test/a11y/index.html
Normal file
41
core/src/components/menu/test/a11y/index.html
Normal 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>
|
@ -1,6 +1,11 @@
|
||||
import { testMenu } from '../test.utils';
|
||||
import { newE2EPage } from '@stencil/core/testing';
|
||||
|
||||
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 () => {
|
||||
await testMenu(DIRECTORY, '#start-menu', 'first');
|
||||
@ -14,6 +19,21 @@ test('menu: end menu', async () => {
|
||||
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
|
||||
*/
|
||||
|
@ -24,7 +24,7 @@
|
||||
|
||||
<body>
|
||||
<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-toolbar color="primary">
|
||||
<ion-title>Start Menu</ion-title>
|
||||
@ -32,6 +32,9 @@
|
||||
</ion-header>
|
||||
<ion-content>
|
||||
<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>
|
||||
@ -82,7 +85,7 @@
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<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="openCustom()">Open Custom Menu</ion-button>
|
||||
</ion-content>
|
||||
|
Reference in New Issue
Block a user