fix(overlays): trap focus inside overlay components except toast (#21716)

fixes #21647
This commit is contained in:
Liam DeBeasi
2020-07-22 12:09:31 -04:00
committed by GitHub
parent eb592b8917
commit fff4aec6cf
15 changed files with 377 additions and 30 deletions

View File

@ -26,6 +26,7 @@ import { mdLeaveAnimation } from './animations/md.leave';
export class ActionSheet implements ComponentInterface, OverlayInterface {
presented = false;
lastFocus?: HTMLElement;
animation?: any;
private wrapperEl?: HTMLElement;
private groupEl?: HTMLElement;
@ -250,7 +251,10 @@ export class ActionSheet implements ComponentInterface, OverlayInterface {
onIonBackdropTap={this.onBackdropTap}
>
<ion-backdrop tappable={this.backdropDismiss}/>
<div class="action-sheet-wrapper" role="dialog" ref={el => this.wrapperEl = el}>
<div tabindex="0"></div>
<div class="action-sheet-wrapper ion-overlay-wrapper" role="dialog" ref={el => this.wrapperEl = el}>
<div class="action-sheet-container">
<div class="action-sheet-group" ref={el => this.groupEl = el}>
{this.header !== undefined &&
@ -292,6 +296,8 @@ export class ActionSheet implements ComponentInterface, OverlayInterface {
}
</div>
</div>
<div tabindex="0"></div>
</Host>
);
}

View File

@ -1,6 +1,40 @@
import { testActionSheet, testActionSheetAlert, testActionSheetBackdrop } from '../test.utils';
import { newE2EPage } from '@stencil/core/testing';
const DIRECTORY = 'basic';
const getActiveElementText = async (page) => {
const activeElement = await page.evaluateHandle(() => document.activeElement);
return await page.evaluate(el => el && el.textContent, activeElement);
}
test('action-sheet: focus trap', async () => {
const page = await newE2EPage({ url: '/src/components/action-sheet/test/basic?ionic:_testing=true' });
await page.click('#basic');
await page.waitForSelector('#basic');
let actionSheet = await page.find('ion-action-sheet');
expect(actionSheet).not.toBe(null);
await actionSheet.waitForVisible();
await page.keyboard.press('Tab');
const activeElementText = await getActiveElementText(page);
expect(activeElementText).toEqual('Delete');
await page.keyboard.down('Shift');
await page.keyboard.press('Tab');
await page.keyboard.up('Shift');
const activeElementTextTwo = await getActiveElementText(page);
expect(activeElementTextTwo).toEqual('Cancel');
await page.keyboard.press('Tab');
const activeElementTextThree = await getActiveElementText(page);
expect(activeElementTextThree).toEqual('Delete');
});
test('action-sheet: basic', async () => {
await testActionSheet(DIRECTORY, '#basic');

View File

@ -34,6 +34,7 @@ export class Alert implements ComponentInterface, OverlayInterface {
private gesture?: Gesture;
presented = false;
lastFocus?: HTMLElement;
@Element() el!: HTMLIonAlertElement;
@ -514,7 +515,9 @@ export class Alert implements ComponentInterface, OverlayInterface {
<ion-backdrop tappable={this.backdropDismiss}/>
<div class="alert-wrapper" ref={el => this.wrapperEl = el}>
<div tabindex="0"></div>
<div class="alert-wrapper ion-overlay-wrapper" ref={el => this.wrapperEl = el}>
<div class="alert-head">
{header && <h2 id={hdrId} class="alert-title">{header}</h2>}
@ -527,6 +530,8 @@ export class Alert implements ComponentInterface, OverlayInterface {
{this.renderAlertButtons()}
</div>
<div tabindex="0"></div>
</Host>
);
}

View File

@ -1,6 +1,40 @@
import { testAlert } from '../test.utils';
import { newE2EPage } from '@stencil/core/testing';
const DIRECTORY = 'basic';
const getActiveElementText = async (page) => {
const activeElement = await page.evaluateHandle(() => document.activeElement);
return await page.evaluate(el => el && el.textContent, activeElement);
}
test('alert: focus trap', async () => {
const page = await newE2EPage({ url: '/src/components/alert/test/basic?ionic:_testing=true' });
await page.click('#multipleButtons');
await page.waitForSelector('#multipleButtons');
let alert = await page.find('ion-alert');
expect(alert).not.toBe(null);
await alert.waitForVisible();
await page.keyboard.press('Tab');
const activeElementText = await getActiveElementText(page);
expect(activeElementText).toEqual('Open Modal');
await page.keyboard.down('Shift');
await page.keyboard.press('Tab');
await page.keyboard.up('Shift');
const activeElementTextTwo = await getActiveElementText(page);
expect(activeElementTextTwo).toEqual('Cancel');
await page.keyboard.press('Tab');
const activeElementTextThree = await getActiveElementText(page);
expect(activeElementTextThree).toEqual('Open Modal');
});
test(`alert: basic`, async () => {
await testAlert(DIRECTORY, '#basic');

View File

@ -1,5 +1,42 @@
import { newE2EPage } from '@stencil/core/testing';
const getActiveElementText = async (page) => {
const activeElement = await page.evaluateHandle(() => document.activeElement);
return await page.evaluate(el => el && el.textContent, activeElement);
}
test('datetime/picker: focus trap', async () => {
const page = await newE2EPage({ url: '/src/components/datetime/test/basic?ionic:_testing=true' });
await page.click('#datetime-part');
await page.waitForSelector('#datetime-part');
let datetime = await page.find('ion-datetime');
expect(datetime).not.toBe(null);
await datetime.waitForVisible();
// TODO fix
await page.waitFor(100);
await page.keyboard.press('Tab');
const activeElementText = await getActiveElementText(page);
expect(activeElementText).toEqual('Cancel');
await page.keyboard.down('Shift');
await page.keyboard.press('Tab');
await page.keyboard.up('Shift');
const activeElementTextTwo = await getActiveElementText(page);
expect(activeElementTextTwo).toEqual('1920');
await page.keyboard.press('Tab');
const activeElementTextThree = await getActiveElementText(page);
expect(activeElementTextThree).toEqual('Cancel');
});
test('datetime: basic', async () => {
const page = await newE2EPage({
url: '/src/components/datetime/test/basic?ionic:_testing=true'

View File

@ -27,6 +27,7 @@ export class Loading implements ComponentInterface, OverlayInterface {
private durationTimeout: any;
presented = false;
lastFocus?: HTMLElement;
@Element() el!: HTMLIonLoadingElement;
@ -194,7 +195,10 @@ export class Loading implements ComponentInterface, OverlayInterface {
}}
>
<ion-backdrop visible={this.showBackdrop} tappable={this.backdropDismiss} />
<div class="loading-wrapper" role="dialog">
<div tabindex="0"></div>
<div class="loading-wrapper ion-overlay-wrapper" role="dialog">
{spinner && (
<div class="loading-spinner">
<ion-spinner name={spinner} aria-hidden="true" />
@ -203,6 +207,8 @@ export class Loading implements ComponentInterface, OverlayInterface {
{message && <div class="loading-content" innerHTML={sanitizeDOMString(message)}></div>}
</div>
<div tabindex="0"></div>
</Host>
);
}

View File

@ -1,6 +1,40 @@
import { testLoading } from '../test.utils';
import { newE2EPage } from '@stencil/core/testing';
const DIRECTORY = 'basic';
const getActiveElementText = async (page) => {
const activeElement = await page.evaluateHandle(() => document.activeElement);
return await page.evaluate(el => el && el.textContent, activeElement);
}
test('loading: focus trap', async () => {
const page = await newE2EPage({ url: '/src/components/loading/test/basic?ionic:_testing=true' });
await page.click('#html-content-loading');
await page.waitForSelector('#html-content-loading');
let loading = await page.find('ion-loading');
expect(loading).not.toBe(null);
await loading.waitForVisible();
await page.keyboard.press('Tab');
const activeElementText = await getActiveElementText(page);
expect(activeElementText).toEqual('Click impatiently to load faster');
await page.keyboard.down('Shift');
await page.keyboard.press('Tab');
await page.keyboard.up('Shift');
const activeElementTextTwo = await getActiveElementText(page);
expect(activeElementTextTwo).toEqual('Click impatiently to load faster');
await page.keyboard.press('Tab');
const activeElementTextThree = await getActiveElementText(page);
expect(activeElementTextThree).toEqual('Click impatiently to load faster');
});
test('loading: basic', async () => {
await testLoading(DIRECTORY, '#basic-loading');

View File

@ -34,6 +34,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
// Whether or not modal is being dismissed via gesture
private gestureAnimationDismissing = false;
presented = false;
lastFocus?: HTMLElement;
animation?: Animation;
@Element() el!: HTMLIonModalElement;
@ -289,11 +290,16 @@ export class Modal implements ComponentInterface, OverlayInterface {
<ion-backdrop visible={this.showBackdrop} tappable={this.backdropDismiss}/>
{mode === 'ios' && <div class="modal-shadow"></div>}
<div tabindex="0"></div>
<div
role="dialog"
class="modal-wrapper"
class="modal-wrapper ion-overlay-wrapper"
>
</div>
<div tabindex="0"></div>
</Host>
);
}

View File

@ -1,6 +1,43 @@
import { testModal } from '../test.utils';
import { newE2EPage } from '@stencil/core/testing';
const DIRECTORY = 'basic';
const getActiveElementText = async (page) => {
const activeElement = await page.evaluateHandle(() => document.activeElement);
return await page.evaluate(el => el && el.textContent, activeElement);
}
test('modal: focus trap', async () => {
const page = await newE2EPage({ url: '/src/components/modal/test/basic?ionic:_testing=true' });
await page.click('#basic-modal');
await page.waitForSelector('#basic-modal');
let modal = await page.find('ion-modal');
expect(modal).not.toBe(null);
await modal.waitForVisible();
// TODO fix
await page.waitFor(50);
await page.keyboard.press('Tab');
const activeElementText = await getActiveElementText(page);
expect(activeElementText).toEqual('Dismiss Modal');
await page.keyboard.down('Shift');
await page.keyboard.press('Tab');
await page.keyboard.up('Shift');
const activeElementTextTwo = await getActiveElementText(page);
expect(activeElementTextTwo).toEqual('Dismiss Modal');
await page.keyboard.press('Tab');
const activeElementTextThree = await getActiveElementText(page);
expect(activeElementTextThree).toEqual('Dismiss Modal');
});
test('modal: basic', async () => {
await testModal(DIRECTORY, '#basic-modal');

View File

@ -21,6 +21,7 @@ import { iosLeaveAnimation } from './animations/ios.leave';
})
export class Picker implements ComponentInterface, OverlayInterface {
private durationTimeout: any;
lastFocus?: HTMLElement;
@Element() el!: HTMLIonPickerElement;
@ -236,7 +237,10 @@ export class Picker implements ComponentInterface, OverlayInterface {
tappable={this.backdropDismiss}
>
</ion-backdrop>
<div class="picker-wrapper" role="dialog">
<div tabindex="0"></div>
<div class="picker-wrapper ion-overlay-wrapper" role="dialog">
<div class="picker-toolbar">
{this.buttons.map(b => (
<div class={buttonWrapperClass(b)}>
@ -259,6 +263,8 @@ export class Picker implements ComponentInterface, OverlayInterface {
<div class="picker-below-highlight"></div>
</div>
</div>
<div tabindex="0"></div>
</Host>
);
}

View File

@ -28,6 +28,7 @@ export class Popover implements ComponentInterface, OverlayInterface {
private usersElement?: HTMLElement;
presented = false;
lastFocus?: HTMLElement;
@Element() el!: HTMLIonPopoverElement;
@ -219,10 +220,15 @@ export class Popover implements ComponentInterface, OverlayInterface {
onIonBackdropTap={this.onBackdropTap}
>
<ion-backdrop tappable={this.backdropDismiss} visible={this.showBackdrop}/>
<div class="popover-wrapper">
<div tabindex="0"></div>
<div class="popover-wrapper ion-overlay-wrapper">
<div class="popover-arrow"></div>
<div class="popover-content"></div>
</div>
<div tabindex="0"></div>
</Host>
);
}

View File

@ -1,7 +1,42 @@
import { testPopover } from '../test.utils';
import { newE2EPage } from '@stencil/core/testing';
const DIRECTORY = 'basic';
const getActiveElementText = async (page) => {
const activeElement = await page.evaluateHandle(() => document.activeElement);
return await page.evaluate(el => el && el.textContent, activeElement);
}
test('popover: focus trap', async () => {
const page = await newE2EPage({ url: '/src/components/popover/test/basic?ionic:_testing=true' });
await page.click('#basic-popover');
await page.waitForSelector('#basic-popover');
let popover = await page.find('ion-popover');
expect(popover).not.toBe(null);
await popover.waitForVisible();
await page.keyboard.press('Tab');
const activeElementText = await getActiveElementText(page);
expect(activeElementText).toEqual('Item 0');
await page.keyboard.down('Shift');
await page.keyboard.press('Tab');
await page.keyboard.up('Shift');
const activeElementTextTwo = await getActiveElementText(page);
expect(activeElementTextTwo).toEqual('Item 3');
await page.keyboard.press('Tab');
const activeElementTextThree = await getActiveElementText(page);
expect(activeElementTextThree).toEqual('Item 0');
});
test('popover: basic', async () => {
await testPopover(DIRECTORY, '#basic-popover');
});

View File

@ -75,10 +75,10 @@
<ion-content>
<ion-list>
<ion-list-header><ion-label>Ionic</ion-label></ion-list-header>
<ion-item><ion-label>Item 0</ion-label></ion-item>
<ion-item><ion-label>Item 1</ion-label></ion-item>
<ion-item><ion-label>Item 2</ion-label></ion-item>
<ion-item><ion-label>Item 3</ion-label></ion-item>
<ion-item button><ion-label>Item 0</ion-label></ion-item>
<ion-item button><ion-label>Item 1</ion-label></ion-item>
<ion-item button><ion-label>Item 2</ion-label></ion-item>
<ion-item button><ion-label>Item 3</ion-label></ion-item>
</ion-list>
</ion-content>
`;

View File

@ -35,6 +35,7 @@ export interface OverlayController {
export interface HTMLIonOverlayElement extends HTMLStencilElement {
overlayIndex: number;
backdropDismiss?: boolean;
lastFocus?: HTMLElement;
dismiss(data?: any, role?: string): Promise<boolean>;
}

View File

@ -3,6 +3,7 @@ import { getIonMode } from '../global/ionic-global';
import { ActionSheetOptions, AlertOptions, Animation, AnimationBuilder, BackButtonEvent, HTMLIonOverlayElement, IonicConfig, LoadingOptions, ModalOptions, OverlayInterface, PickerOptions, PopoverOptions, ToastOptions } from '../interface';
import { OVERLAY_BACK_BUTTON_PRIORITY } from './hardware-back-button';
import { getElementRoot } from './helpers';
let lastId = 0;
@ -62,19 +63,128 @@ export const createOverlay = <T extends HTMLIonOverlayElement>(tagName: string,
return Promise.resolve() as any;
};
const focusableQueryString = '[tabindex]:not([tabindex^="-"]), input, textarea, button, select, .ion-focusable';
const focusFirstDescendant = (ref: Element, overlay: HTMLIonOverlayElement) => {
let firstInput = ref.querySelector(focusableQueryString) as HTMLElement | null;
const shadowRoot = firstInput && firstInput.shadowRoot;
if (shadowRoot) {
firstInput = shadowRoot.querySelector('input, textarea, button, select');
}
if (firstInput) {
firstInput.focus();
} else {
// Focus overlay instead of letting focus escape
overlay.focus();
}
};
const focusLastDescendant = (ref: Element, overlay: HTMLIonOverlayElement) => {
const inputs = Array.from(ref.querySelectorAll(focusableQueryString)) as HTMLElement[];
let lastInput = inputs.length > 0 ? inputs[inputs.length - 1] : null;
const shadowRoot = lastInput && lastInput.shadowRoot;
if (shadowRoot) {
lastInput = shadowRoot.querySelector('input, textarea, button, select');
}
if (lastInput) {
lastInput.focus();
} else {
// Focus overlay instead of letting focus escape
overlay.focus();
}
};
/**
* Traps keyboard focus inside of overlay components.
* Based on https://w3c.github.io/aria-practices/examples/dialog-modal/alertdialog.html
* This includes the following components: Action Sheet, Alert, Loading, Modal,
* Picker, and Popover.
* Should NOT include: Toast
*/
const trapKeyboardFocus = (ev: Event, doc: Document) => {
const lastOverlay = getOverlay(doc);
const target = ev.target as HTMLElement | null;
// If no active overlay, ignore this event
if (!lastOverlay || !target) { return; }
/**
* If we are focusing the overlay, clear
* the last focused element so that hitting
* tab activates the first focusable element
* in the overlay wrapper.
*/
if (lastOverlay === target) {
lastOverlay.lastFocus = undefined;
/**
* Otherwise, we must be focusing an element
* inside of the overlay. The two possible options
* here are an input/button/etc or the ion-focus-trap
* element. The focus trap element is used to prevent
* the keyboard focus from leaving the overlay when
* using Tab or screen assistants.
*/
} else {
/**
* We do not want to focus the traps, so get the overlay
* wrapper element as the traps live outside of the wrapper.
*/
const overlayRoot = getElementRoot(lastOverlay);
const overlayWrapper = overlayRoot.querySelector('.ion-overlay-wrapper');
if (!overlayWrapper) { return; }
/**
* If the target is inside the wrapper, let the browser
* focus as normal and keep a log of the last focused element.
*/
if (overlayWrapper.contains(target)) {
lastOverlay.lastFocus = target;
} else {
/**
* Otherwise, we must have focused one of the focus traps.
* We need to wrap the focus to either the first element
* or the last element.
*/
/**
* Once we call `focusFirstDescendant` and focus the first
* descendant, another focus event will fire which will
* cause `lastOverlay.lastFocus` to be updated before
* we can run the code after that. We will cache the value
* here to avoid that.
*/
const lastFocus = lastOverlay.lastFocus;
// Focus the first element in the overlay wrapper
focusFirstDescendant(overlayWrapper, lastOverlay);
/**
* If the cached last focused element is the
* same as the active element, then we need
* to wrap focus to the last descendant. This happens
* when the first descendant is focused, and the user
* presses Shift + Tab. The previous line will focus
* the same descendant again (the first one), causing
* last focus to equal the active element.
*/
if (lastFocus === doc.activeElement) {
focusLastDescendant(overlayWrapper, lastOverlay);
}
lastOverlay.lastFocus = doc.activeElement as HTMLElement;
}
}
};
export const connectListeners = (doc: Document) => {
if (lastId === 0) {
lastId = 1;
// trap focus inside overlays
doc.addEventListener('focusin', ev => {
const lastOverlay = getOverlay(doc);
if (lastOverlay && lastOverlay.backdropDismiss && !isDescendant(lastOverlay, ev.target as HTMLElement)) {
const firstInput = lastOverlay.querySelector('input,button') as HTMLElement | null;
if (firstInput) {
firstInput.focus();
}
}
});
doc.addEventListener('focus', ev => trapKeyboardFocus(ev, doc), true);
// handle back-button click
doc.addEventListener('ionBackButton', ev => {
@ -247,16 +357,6 @@ export const isCancel = (role: string | undefined): boolean => {
return role === 'cancel' || role === BACKDROP;
};
const isDescendant = (parent: HTMLElement, child: HTMLElement | null) => {
while (child) {
if (child === parent) {
return true;
}
child = child.parentElement;
}
return false;
};
const defaultGate = (h: any) => h();
export const safeCall = (handler: any, arg?: any) => {