mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-11-09 08:09:32 +08:00
fix(toast): toast is now correctly excluded from focus trapping (#24816)
resolves #24733
This commit is contained in:
@ -140,9 +140,7 @@ export class Toast implements ComponentInterface, OverlayInterface {
|
|||||||
@Event({ eventName: 'ionToastDidDismiss' }) didDismiss!: EventEmitter<OverlayEventDetail>;
|
@Event({ eventName: 'ionToastDidDismiss' }) didDismiss!: EventEmitter<OverlayEventDetail>;
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
prepareOverlay(this.el, {
|
prepareOverlay(this.el);
|
||||||
trapKeyboardFocus: false
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -31,16 +31,10 @@ export const pickerController = /*@__PURE__*/createController<PickerOptions, HTM
|
|||||||
export const popoverController = /*@__PURE__*/createController<PopoverOptions, HTMLIonPopoverElement>('ion-popover');
|
export const popoverController = /*@__PURE__*/createController<PopoverOptions, HTMLIonPopoverElement>('ion-popover');
|
||||||
export const toastController = /*@__PURE__*/createController<ToastOptions, HTMLIonToastElement>('ion-toast');
|
export const toastController = /*@__PURE__*/createController<ToastOptions, HTMLIonToastElement>('ion-toast');
|
||||||
|
|
||||||
export interface OverlayListenerOptions {
|
export const prepareOverlay = <T extends HTMLIonOverlayElement>(el: T) => {
|
||||||
trapKeyboardFocus: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const prepareOverlay = <T extends HTMLIonOverlayElement>(el: T, options: OverlayListenerOptions = {
|
|
||||||
trapKeyboardFocus: true
|
|
||||||
}) => {
|
|
||||||
/* tslint:disable-next-line */
|
/* tslint:disable-next-line */
|
||||||
if (typeof document !== 'undefined') {
|
if (typeof document !== 'undefined') {
|
||||||
connectListeners(document, options);
|
connectListeners(document);
|
||||||
}
|
}
|
||||||
const overlayIndex = lastId++;
|
const overlayIndex = lastId++;
|
||||||
el.overlayIndex = overlayIndex;
|
el.overlayIndex = overlayIndex;
|
||||||
@ -119,7 +113,7 @@ const focusLastDescendant = (ref: Element, overlay: HTMLIonOverlayElement) => {
|
|||||||
* Should NOT include: Toast
|
* Should NOT include: Toast
|
||||||
*/
|
*/
|
||||||
const trapKeyboardFocus = (ev: Event, doc: Document) => {
|
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;
|
const target = ev.target as HTMLElement | null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -256,22 +250,12 @@ const trapKeyboardFocus = (ev: Event, doc: Document) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const connectListeners = (doc: Document, options: OverlayListenerOptions) => {
|
const connectListeners = (doc: Document) => {
|
||||||
if (lastId === 0) {
|
if (lastId === 0) {
|
||||||
lastId = 1;
|
lastId = 1;
|
||||||
if (options.trapKeyboardFocus) {
|
|
||||||
doc.addEventListener('focus', (ev: FocusEvent) => {
|
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);
|
trapKeyboardFocus(ev, doc);
|
||||||
}, true);
|
}, true);
|
||||||
}
|
|
||||||
|
|
||||||
// handle back-button click
|
// handle back-button click
|
||||||
doc.addEventListener('ionBackButton', ev => {
|
doc.addEventListener('ionBackButton', ev => {
|
||||||
|
|||||||
@ -10,8 +10,9 @@
|
|||||||
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet">
|
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet">
|
||||||
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
|
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
|
||||||
<script type="module">
|
<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.modalController = modalController;
|
||||||
|
window.toastController = toastController;
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@ -30,18 +31,27 @@
|
|||||||
</ion-header>
|
</ion-header>
|
||||||
|
|
||||||
<ion-content class="ion-padding">
|
<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" onclick="createModal()">Create a Modal</ion-button>
|
||||||
<ion-button id="create-nested" onclick="createNestedOverlayModal()">Create Nested Overlay 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="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="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="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>
|
</ion-content>
|
||||||
</div>
|
</div>
|
||||||
</ion-app>
|
</ion-app>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
let modals = 0;
|
||||||
const createModal = async () => {
|
const createModal = async () => {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
|
const id = modals++;
|
||||||
|
div.classList.add(`modal-${id}`);
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
<ion-header>
|
<ion-header>
|
||||||
<ion-toolbar>
|
<ion-toolbar>
|
||||||
@ -51,8 +61,15 @@
|
|||||||
<ion-content class="ion-padding">
|
<ion-content class="ion-padding">
|
||||||
Modal Content
|
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">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-simulate">Simulate Hardware Back Button</ion-button>
|
||||||
|
<ion-button id="modal-toast">Present a Toast</ion-button>
|
||||||
|
|
||||||
</ion-content>
|
</ion-content>
|
||||||
`;
|
`;
|
||||||
@ -62,11 +79,21 @@
|
|||||||
createModal();
|
createModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const createAndPresentButton = div.querySelector('ion-button#modal-create-and-present');
|
||||||
|
createAndPresentButton.onclick = () => {
|
||||||
|
createAndPresentModal();
|
||||||
|
}
|
||||||
|
|
||||||
const simulateButton = div.querySelector('ion-button#modal-simulate');
|
const simulateButton = div.querySelector('ion-button#modal-simulate');
|
||||||
simulateButton.onclick = () => {
|
simulateButton.onclick = () => {
|
||||||
backButton();
|
backButton();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const presentToast = div.querySelector('ion-button#modal-toast');
|
||||||
|
presentToast.onclick = () => {
|
||||||
|
createAndPresentToast();
|
||||||
|
}
|
||||||
|
|
||||||
const modal = await modalController.create({
|
const modal = await modalController.create({
|
||||||
component: div
|
component: div
|
||||||
});
|
});
|
||||||
@ -91,6 +118,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const createAndPresentToast = async () => {
|
||||||
|
const toast = await toastController.create({
|
||||||
|
message: 'This is a toast!'
|
||||||
|
});
|
||||||
|
|
||||||
|
await toast.present();
|
||||||
|
}
|
||||||
|
|
||||||
const createNestedOverlayModal = async () => {
|
const createNestedOverlayModal = async () => {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { newE2EPage } from '@stencil/core/testing';
|
import { newE2EPage } from '@stencil/core/testing';
|
||||||
|
import { getActiveElementParent } from '../utils';
|
||||||
|
|
||||||
test('overlays: hardware back button: should dismiss a presented overlay', async () => {
|
test('overlays: hardware back button: should dismiss a presented overlay', async () => {
|
||||||
const page = await newE2EPage({ url: '/src/utils/test/overlays?ionic:_testing=true' });
|
const page = await newE2EPage({ url: '/src/utils/test/overlays?ionic:_testing=true' });
|
||||||
@ -125,5 +126,55 @@ test('overlays: Nested: should dismiss the top overlay', async () => {
|
|||||||
|
|
||||||
const modals = await page.$$('ion-modal');
|
const modals = await page.$$('ion-modal');
|
||||||
expect(modals.length).toEqual(0);
|
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');
|
||||||
|
})
|
||||||
|
|||||||
@ -1,6 +1,25 @@
|
|||||||
import { E2EElement, E2EPage } from '@stencil/core/testing';
|
import { E2EElement, E2EPage } from '@stencil/core/testing';
|
||||||
import { ElementHandle } from 'puppeteer';
|
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 => {
|
export const generateE2EUrl = (component: string, type: string, rtl = false): string => {
|
||||||
let url = `/src/components/${component}/test/${type}?ionic:_testing=true`;
|
let url = `/src/components/${component}/test/${type}?ionic:_testing=true`;
|
||||||
if (rtl) {
|
if (rtl) {
|
||||||
|
|||||||
Reference in New Issue
Block a user