mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-11-10 00:27:41 +08:00
fix(overlays): correctly re-add root to accessibility tree (#28183)
Issue number: resolves #28180 --------- <!-- Please do not submit updates to dependencies unless it fixes an issue. --> <!-- Please try to limit your pull request to one type (bugfix, feature, etc). Submit multiple pull requests if needed. --> ## What is the current behavior? <!-- Please describe the current behavior that you are modifying. --> When presenting an overlay, we remove the root (usually `ion-router-outlet`) from the accessibility tree. This makes it so you cannot accidentally focus elements behind the overlay. When dismissing an overlay we re-add the root to the accessibility tree. However, we fail to consider if there are multiple presented overlays. For example, if you present a modal, then an alert, then dismiss the alert, then the root is re-added to the accessibility tree even though the modal is still presented. ## What is the new behavior? <!-- Please describe the behavior or changes that are being added by this PR. --> - The root is now re-added to the accessibility tree only if it is the last presented overlay. ## Does this introduce a breaking change? - [ ] Yes - [x] No <!-- If this introduces a breaking change, please describe the impact and migration path for existing applications below. --> ## Other information <!-- Any other information that is important to this PR such as screenshots of how the component looks before and after the change. --> Dev build: `7.4.1-dev.11694783260.13da477f`
This commit is contained in:
@ -6,7 +6,7 @@ import { GESTURE_CONTROLLER } from '@utils/gesture';
|
||||
import type { Attributes } from '@utils/helpers';
|
||||
import { inheritAriaAttributes, assert, clamp, isEndSide as isEnd } from '@utils/helpers';
|
||||
import { menuController } from '@utils/menu-controller';
|
||||
import { getOverlay } from '@utils/overlays';
|
||||
import { getPresentedOverlay } from '@utils/overlays';
|
||||
|
||||
import { config } from '../../global/config';
|
||||
import { getIonMode } from '../../global/ionic-global';
|
||||
@ -59,7 +59,7 @@ export class Menu implements ComponentInterface, MenuI {
|
||||
* open does not contain this ion-menu, then ion-menu's
|
||||
* focus trapping should not run.
|
||||
*/
|
||||
const lastOverlay = getOverlay(document);
|
||||
const lastOverlay = getPresentedOverlay(document);
|
||||
if (lastOverlay && !lastOverlay.contains(this.el)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { doc } from '@utils/browser';
|
||||
|
||||
import { config } from '../global/config';
|
||||
import { getIonMode } from '../global/ionic-global';
|
||||
import type {
|
||||
@ -36,7 +38,7 @@ const createController = <Opts extends object, HTMLElm>(tagName: string) => {
|
||||
return dismissOverlay(document, data, role, tagName, id);
|
||||
},
|
||||
async getTop(): Promise<HTMLElm | undefined> {
|
||||
return getOverlay(document, tagName) as any;
|
||||
return getPresentedOverlay(document, tagName) as any;
|
||||
},
|
||||
};
|
||||
};
|
||||
@ -173,7 +175,10 @@ const focusLastDescendant = (ref: Element, overlay: HTMLIonOverlayElement) => {
|
||||
* Should NOT include: Toast
|
||||
*/
|
||||
const trapKeyboardFocus = (ev: Event, doc: Document) => {
|
||||
const lastOverlay = getOverlay(doc, 'ion-alert,ion-action-sheet,ion-loading,ion-modal,ion-picker,ion-popover');
|
||||
const lastOverlay = getPresentedOverlay(
|
||||
doc,
|
||||
'ion-alert,ion-action-sheet,ion-loading,ion-modal,ion-picker,ion-popover'
|
||||
);
|
||||
const target = ev.target as HTMLElement | null;
|
||||
|
||||
/**
|
||||
@ -344,7 +349,7 @@ const connectListeners = (doc: Document) => {
|
||||
|
||||
// handle back-button click
|
||||
doc.addEventListener('ionBackButton', (ev) => {
|
||||
const lastOverlay = getOverlay(doc);
|
||||
const lastOverlay = getPresentedOverlay(doc);
|
||||
if (lastOverlay?.backdropDismiss) {
|
||||
(ev as BackButtonEvent).detail.register(OVERLAY_BACK_BUTTON_PRIORITY, () => {
|
||||
return lastOverlay.dismiss(undefined, BACKDROP);
|
||||
@ -355,7 +360,7 @@ const connectListeners = (doc: Document) => {
|
||||
// handle ESC to close overlay
|
||||
doc.addEventListener('keydown', (ev) => {
|
||||
if (ev.key === 'Escape') {
|
||||
const lastOverlay = getOverlay(doc);
|
||||
const lastOverlay = getPresentedOverlay(doc);
|
||||
if (lastOverlay?.backdropDismiss) {
|
||||
lastOverlay.dismiss(undefined, BACKDROP);
|
||||
}
|
||||
@ -371,13 +376,16 @@ export const dismissOverlay = (
|
||||
overlayTag: string,
|
||||
id?: string
|
||||
): Promise<boolean> => {
|
||||
const overlay = getOverlay(doc, overlayTag, id);
|
||||
const overlay = getPresentedOverlay(doc, overlayTag, id);
|
||||
if (!overlay) {
|
||||
return Promise.reject('overlay does not exist');
|
||||
}
|
||||
return overlay.dismiss(data, role);
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a list of all overlays in the DOM even if they are not presented.
|
||||
*/
|
||||
export const getOverlays = (doc: Document, selector?: string): HTMLIonOverlayElement[] => {
|
||||
if (selector === undefined) {
|
||||
selector = 'ion-alert,ion-action-sheet,ion-loading,ion-modal,ion-picker,ion-popover,ion-toast';
|
||||
@ -386,14 +394,29 @@ export const getOverlays = (doc: Document, selector?: string): HTMLIonOverlayEle
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns an overlay element
|
||||
* Returns a list of all presented overlays.
|
||||
* Inline overlays can exist in the DOM but not be presented,
|
||||
* so there are times when we want to exclude those.
|
||||
* @param doc The document to find the element within.
|
||||
* @param overlayTag The selector for the overlay, defaults to Ionic overlay components.
|
||||
*/
|
||||
const getPresentedOverlays = (doc: Document, overlayTag?: string): HTMLIonOverlayElement[] => {
|
||||
return getOverlays(doc, overlayTag).filter((o) => !isOverlayHidden(o));
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a presented overlay element.
|
||||
* @param doc The document to find the element within.
|
||||
* @param overlayTag The selector for the overlay, defaults to Ionic overlay components.
|
||||
* @param id The unique identifier for the overlay instance.
|
||||
* @returns The overlay element or `undefined` if no overlay element is found.
|
||||
*/
|
||||
export const getOverlay = (doc: Document, overlayTag?: string, id?: string): HTMLIonOverlayElement | undefined => {
|
||||
const overlays = getOverlays(doc, overlayTag).filter((o) => !isOverlayHidden(o));
|
||||
export const getPresentedOverlay = (
|
||||
doc: Document,
|
||||
overlayTag?: string,
|
||||
id?: string
|
||||
): HTMLIonOverlayElement | undefined => {
|
||||
const overlays = getPresentedOverlays(doc, overlayTag);
|
||||
return id === undefined ? overlays[overlays.length - 1] : overlays.find((o) => o.id === id);
|
||||
};
|
||||
|
||||
@ -525,7 +548,13 @@ export const dismiss = async <OverlayDismissOptions>(
|
||||
return false;
|
||||
}
|
||||
|
||||
setRootAriaHidden(false);
|
||||
/**
|
||||
* If this is the last visible overlay then
|
||||
* we want to re-add the root to the accessibility tree.
|
||||
*/
|
||||
if (doc !== undefined && getPresentedOverlays(doc).length === 1) {
|
||||
setRootAriaHidden(false);
|
||||
}
|
||||
|
||||
overlay.presented = false;
|
||||
|
||||
|
||||
@ -2,6 +2,8 @@ import { newSpecPage } from '@stencil/core/testing';
|
||||
|
||||
import { Nav } from '../../../components/nav/nav';
|
||||
import { RouterOutlet } from '../../../components/router-outlet/router-outlet';
|
||||
import { Modal } from '../../../components/modal/modal';
|
||||
|
||||
import { setRootAriaHidden } from '../../overlays';
|
||||
|
||||
describe('setRootAriaHidden()', () => {
|
||||
@ -77,4 +79,54 @@ describe('setRootAriaHidden()', () => {
|
||||
|
||||
setRootAriaHidden(true);
|
||||
});
|
||||
|
||||
it('should remove router-outlet from accessibility tree when overlay is presented', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [RouterOutlet, Modal],
|
||||
html: `
|
||||
<ion-router-outlet>
|
||||
<ion-modal></ion-modal>
|
||||
</ion-router-outlet>
|
||||
`,
|
||||
});
|
||||
|
||||
const routerOutlet = page.body.querySelector('ion-router-outlet');
|
||||
const modal = page.body.querySelector('ion-modal');
|
||||
|
||||
await modal.present();
|
||||
|
||||
expect(routerOutlet.hasAttribute('aria-hidden')).toEqual(true);
|
||||
});
|
||||
|
||||
it('should add router-outlet from accessibility tree when then final overlay is dismissed', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [RouterOutlet, Modal],
|
||||
html: `
|
||||
<ion-router-outlet>
|
||||
<ion-modal id="one"></ion-modal>
|
||||
<ion-modal id="two"></ion-modal>
|
||||
</ion-router-outlet>
|
||||
`,
|
||||
});
|
||||
|
||||
const routerOutlet = page.body.querySelector('ion-router-outlet');
|
||||
const modalOne = page.body.querySelector('ion-modal#one');
|
||||
const modalTwo = page.body.querySelector('ion-modal#two');
|
||||
|
||||
await modalOne.present();
|
||||
|
||||
expect(routerOutlet.hasAttribute('aria-hidden')).toEqual(true);
|
||||
|
||||
await modalTwo.present();
|
||||
|
||||
expect(routerOutlet.hasAttribute('aria-hidden')).toEqual(true);
|
||||
|
||||
await modalOne.dismiss();
|
||||
|
||||
expect(routerOutlet.hasAttribute('aria-hidden')).toEqual(true);
|
||||
|
||||
await modalTwo.dismiss();
|
||||
|
||||
expect(routerOutlet.hasAttribute('aria-hidden')).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user