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: 1 addition & 3 deletions core/src/components/toast/toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,9 +140,7 @@ export class Toast implements ComponentInterface, OverlayInterface {
@Event({ eventName: 'ionToastDidDismiss' }) didDismiss!: EventEmitter<OverlayEventDetail>;

connectedCallback() {
prepareOverlay(this.el, {
trapKeyboardFocus: false
});
prepareOverlay(this.el);
}

/**
Expand Down
30 changes: 7 additions & 23 deletions core/src/utils/overlays.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,10 @@ export const pickerController = /*@__PURE__*/createController<PickerOptions, HTM
export const popoverController = /*@__PURE__*/createController<PopoverOptions, HTMLIonPopoverElement>('ion-popover');
export const toastController = /*@__PURE__*/createController<ToastOptions, HTMLIonToastElement>('ion-toast');

export interface OverlayListenerOptions {
trapKeyboardFocus: boolean;
}

export const prepareOverlay = <T extends HTMLIonOverlayElement>(el: T, options: OverlayListenerOptions = {
trapKeyboardFocus: true
}) => {
export const prepareOverlay = <T extends HTMLIonOverlayElement>(el: T) => {
/* tslint:disable-next-line */
if (typeof document !== 'undefined') {
connectListeners(document, options);
connectListeners(document);
}
const overlayIndex = lastId++;
el.overlayIndex = overlayIndex;
Expand Down Expand Up @@ -119,7 +113,7 @@ const focusLastDescendant = (ref: Element, overlay: HTMLIonOverlayElement) => {
* Should NOT include: Toast
*/
const trapKeyboardFocus = (ev: Event, doc: Document) => {
const lastOverlay = getOverlay(doc);
const lastOverlay = getOverlay(doc, 'ion-alert,ion-action-sheet,ion-loading,ion-modal,ion-picker,ion-popover');
const target = ev.target as HTMLElement | null;

/**
Expand Down Expand Up @@ -256,22 +250,12 @@ const trapKeyboardFocus = (ev: Event, doc: Document) => {
}
};

const connectListeners = (doc: Document, options: OverlayListenerOptions) => {
const connectListeners = (doc: Document) => {
if (lastId === 0) {
lastId = 1;
if (options.trapKeyboardFocus) {
doc.addEventListener('focus', (ev: FocusEvent) => {
/**
* ion-menu has its own focus trapping listener
* so we do not want the two listeners to conflict
* with each other.
*/
if (ev.target && (ev.target as HTMLElement).tagName === 'ION-MENU') {
return;
}
trapKeyboardFocus(ev, doc);
}, true);
}
doc.addEventListener('focus', (ev: FocusEvent) => {
trapKeyboardFocus(ev, doc);
}, true);

// handle back-button click
doc.addEventListener('ionBackButton', ev => {
Expand Down
37 changes: 36 additions & 1 deletion core/src/utils/test/overlays/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet">
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
<script type="module">
import { modalController, createAnimation } from '../../../../../dist/ionic/index.esm.js';
import { modalController, toastController, createAnimation } from '../../../../../dist/ionic/index.esm.js';
window.modalController = modalController;
window.toastController = toastController;
</script>
</head>

Expand All @@ -30,18 +31,27 @@
</ion-header>

<ion-content class="ion-padding">
<ion-item>
<ion-label>Text Input</ion-label>
<ion-input id="root-input"></ion-input>
</ion-item>

<ion-button id="create" onclick="createModal()">Create a Modal</ion-button>
<ion-button id="create-nested" onclick="createNestedOverlayModal()">Create Nested Overlay Modal</ion-button>
<ion-button id="present" onclick="presentHiddenModal()">Present a Hidden Modal</ion-button>
<ion-button id="create-and-present" onclick="createAndPresentModal()">Create and Present a Modal</ion-button>
<ion-button id="simulate" onclick="backButton()">Simulate Hardware Back Button</ion-button>
<ion-button id="create-and-present-toast" onclick="createAndPresentToast()">Create and Present Toast</ion-button>
</ion-content>
</div>
</ion-app>

<script>
let modals = 0;
const createModal = async () => {
const div = document.createElement('div');
const id = modals++;
div.classList.add(`modal-${id}`);
div.innerHTML = `
<ion-header>
<ion-toolbar>
Expand All @@ -51,8 +61,15 @@
<ion-content class="ion-padding">
Modal Content

<ion-item>
<ion-label>Text Input</ion-label>
<ion-input class="modal-input modal-input-${id}"></ion-input>
</ion-item>

<ion-button id="modal-create">Create a Modal</ion-button>
<ion-button id="modal-create-and-present">Create and Present a Modal</ion-button>
<ion-button id="modal-simulate">Simulate Hardware Back Button</ion-button>
<ion-button id="modal-toast">Present a Toast</ion-button>

</ion-content>
`;
Expand All @@ -62,11 +79,21 @@
createModal();
}

const createAndPresentButton = div.querySelector('ion-button#modal-create-and-present');
createAndPresentButton.onclick = () => {
createAndPresentModal();
}

const simulateButton = div.querySelector('ion-button#modal-simulate');
simulateButton.onclick = () => {
backButton();
}

const presentToast = div.querySelector('ion-button#modal-toast');
presentToast.onclick = () => {
createAndPresentToast();
}

const modal = await modalController.create({
component: div
});
Expand All @@ -91,6 +118,14 @@
}
}

const createAndPresentToast = async () => {
const toast = await toastController.create({
message: 'This is a toast!'
});

await toast.present();
}

const createNestedOverlayModal = async () => {
const div = document.createElement('div');
div.innerHTML = `
Expand Down
51 changes: 51 additions & 0 deletions core/src/utils/test/overlays/overlays.e2e.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { newE2EPage } from '@stencil/core/testing';
import { getActiveElementParent } from '../utils';

test('overlays: hardware back button: should dismiss a presented overlay', async () => {
const page = await newE2EPage({ url: '/src/utils/test/overlays?ionic:_testing=true' });
Expand Down Expand Up @@ -125,5 +126,55 @@ test('overlays: Nested: should dismiss the top overlay', async () => {

const modals = await page.$$('ion-modal');
expect(modals.length).toEqual(0);
});

test('toast should not cause focus trapping', async () => {
const page = await newE2EPage({ url: '/src/utils/test/overlays?ionic:_testing=true' });
const ionToastDidPresent = await page.spyOnEvent('ionToastDidPresent');

await page.click('#create-and-present-toast');
await ionToastDidPresent.next();

await page.click('#root-input');

const parentEl = await getActiveElementParent(page);
expect(parentEl.id).toEqual('root-input');
});

test('toast should not cause focus trapping even when opened from a focus trapping overlay', async () => {
const page = await newE2EPage({ url: '/src/utils/test/overlays?ionic:_testing=true' });
const ionToastDidPresent = await page.spyOnEvent('ionToastDidPresent');
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');

await page.click('#create-and-present');
await ionModalDidPresent.next();

await page.click('#modal-toast');
await ionToastDidPresent.next();

await page.click('.modal-input');

const parentEl = await getActiveElementParent(page);
expect(parentEl.className).toContain('modal-input-0');
});

test('focus trapping should only run on the top-most overlay', async () => {
const page = await newE2EPage({ url: '/src/utils/test/overlays?ionic:_testing=true' });
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');

await page.click('#create-and-present');
await ionModalDidPresent.next();

await page.click('.modal-0 .modal-input');

const parentEl = await getActiveElementParent(page);
expect(parentEl.className).toContain('modal-input-0');

await page.click('#modal-create-and-present');
await ionModalDidPresent.next();

await page.click('.modal-1 .modal-input');

const parentElAgain = await getActiveElementParent(page);
expect(parentElAgain.className).toContain('modal-input-1');
})
19 changes: 19 additions & 0 deletions core/src/utils/test/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,25 @@
import { E2EElement, E2EPage } from '@stencil/core/testing';
import { ElementHandle } from 'puppeteer';

/**
* page.evaluate can only return a serializable value,
* so it is not possible to return the full element.
* Instead, we return an object with some common
* properties that you may want to access in a test.
*/
export const getActiveElementParent = async (page) => {
const activeElement = await page.evaluateHandle(() => document.activeElement);
return await page.evaluate(el => {
const { parentElement } = el;
const { className, tagName, id } = parentElement;
return {
className,
tagName,
id
}
}, activeElement);
}

export const generateE2EUrl = (component: string, type: string, rtl = false): string => {
let url = `/src/components/${component}/test/${type}?ionic:_testing=true`;
if (rtl) {
Expand Down