Skip to content

Commit 81714d4

Browse files
authored
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`
1 parent 4e0b522 commit 81714d4

File tree

3 files changed

+92
-11
lines changed

3 files changed

+92
-11
lines changed

core/src/components/menu/menu.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { GESTURE_CONTROLLER } from '@utils/gesture';
66
import type { Attributes } from '@utils/helpers';
77
import { inheritAriaAttributes, assert, clamp, isEndSide as isEnd } from '@utils/helpers';
88
import { menuController } from '@utils/menu-controller';
9-
import { getOverlay } from '@utils/overlays';
9+
import { getPresentedOverlay } from '@utils/overlays';
1010

1111
import { config } from '../../global/config';
1212
import { getIonMode } from '../../global/ionic-global';
@@ -59,7 +59,7 @@ export class Menu implements ComponentInterface, MenuI {
5959
* open does not contain this ion-menu, then ion-menu's
6060
* focus trapping should not run.
6161
*/
62-
const lastOverlay = getOverlay(document);
62+
const lastOverlay = getPresentedOverlay(document);
6363
if (lastOverlay && !lastOverlay.contains(this.el)) {
6464
return;
6565
}

core/src/utils/overlays.ts

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { doc } from '@utils/browser';
2+
13
import { config } from '../global/config';
24
import { getIonMode } from '../global/ionic-global';
35
import type {
@@ -36,7 +38,7 @@ const createController = <Opts extends object, HTMLElm>(tagName: string) => {
3638
return dismissOverlay(document, data, role, tagName, id);
3739
},
3840
async getTop(): Promise<HTMLElm | undefined> {
39-
return getOverlay(document, tagName) as any;
41+
return getPresentedOverlay(document, tagName) as any;
4042
},
4143
};
4244
};
@@ -173,7 +175,10 @@ const focusLastDescendant = (ref: Element, overlay: HTMLIonOverlayElement) => {
173175
* Should NOT include: Toast
174176
*/
175177
const trapKeyboardFocus = (ev: Event, doc: Document) => {
176-
const lastOverlay = getOverlay(doc, 'ion-alert,ion-action-sheet,ion-loading,ion-modal,ion-picker,ion-popover');
178+
const lastOverlay = getPresentedOverlay(
179+
doc,
180+
'ion-alert,ion-action-sheet,ion-loading,ion-modal,ion-picker,ion-popover'
181+
);
177182
const target = ev.target as HTMLElement | null;
178183

179184
/**
@@ -344,7 +349,7 @@ const connectListeners = (doc: Document) => {
344349

345350
// handle back-button click
346351
doc.addEventListener('ionBackButton', (ev) => {
347-
const lastOverlay = getOverlay(doc);
352+
const lastOverlay = getPresentedOverlay(doc);
348353
if (lastOverlay?.backdropDismiss) {
349354
(ev as BackButtonEvent).detail.register(OVERLAY_BACK_BUTTON_PRIORITY, () => {
350355
return lastOverlay.dismiss(undefined, BACKDROP);
@@ -355,7 +360,7 @@ const connectListeners = (doc: Document) => {
355360
// handle ESC to close overlay
356361
doc.addEventListener('keydown', (ev) => {
357362
if (ev.key === 'Escape') {
358-
const lastOverlay = getOverlay(doc);
363+
const lastOverlay = getPresentedOverlay(doc);
359364
if (lastOverlay?.backdropDismiss) {
360365
lastOverlay.dismiss(undefined, BACKDROP);
361366
}
@@ -371,13 +376,16 @@ export const dismissOverlay = (
371376
overlayTag: string,
372377
id?: string
373378
): Promise<boolean> => {
374-
const overlay = getOverlay(doc, overlayTag, id);
379+
const overlay = getPresentedOverlay(doc, overlayTag, id);
375380
if (!overlay) {
376381
return Promise.reject('overlay does not exist');
377382
}
378383
return overlay.dismiss(data, role);
379384
};
380385

386+
/**
387+
* Returns a list of all overlays in the DOM even if they are not presented.
388+
*/
381389
export const getOverlays = (doc: Document, selector?: string): HTMLIonOverlayElement[] => {
382390
if (selector === undefined) {
383391
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
386394
};
387395

388396
/**
389-
* Returns an overlay element
397+
* Returns a list of all presented overlays.
398+
* Inline overlays can exist in the DOM but not be presented,
399+
* so there are times when we want to exclude those.
400+
* @param doc The document to find the element within.
401+
* @param overlayTag The selector for the overlay, defaults to Ionic overlay components.
402+
*/
403+
const getPresentedOverlays = (doc: Document, overlayTag?: string): HTMLIonOverlayElement[] => {
404+
return getOverlays(doc, overlayTag).filter((o) => !isOverlayHidden(o));
405+
};
406+
407+
/**
408+
* Returns a presented overlay element.
390409
* @param doc The document to find the element within.
391410
* @param overlayTag The selector for the overlay, defaults to Ionic overlay components.
392411
* @param id The unique identifier for the overlay instance.
393412
* @returns The overlay element or `undefined` if no overlay element is found.
394413
*/
395-
export const getOverlay = (doc: Document, overlayTag?: string, id?: string): HTMLIonOverlayElement | undefined => {
396-
const overlays = getOverlays(doc, overlayTag).filter((o) => !isOverlayHidden(o));
414+
export const getPresentedOverlay = (
415+
doc: Document,
416+
overlayTag?: string,
417+
id?: string
418+
): HTMLIonOverlayElement | undefined => {
419+
const overlays = getPresentedOverlays(doc, overlayTag);
397420
return id === undefined ? overlays[overlays.length - 1] : overlays.find((o) => o.id === id);
398421
};
399422

@@ -525,7 +548,13 @@ export const dismiss = async <OverlayDismissOptions>(
525548
return false;
526549
}
527550

528-
setRootAriaHidden(false);
551+
/**
552+
* If this is the last visible overlay then
553+
* we want to re-add the root to the accessibility tree.
554+
*/
555+
if (doc !== undefined && getPresentedOverlays(doc).length === 1) {
556+
setRootAriaHidden(false);
557+
}
529558

530559
overlay.presented = false;
531560

core/src/utils/test/overlays/overlays.spec.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { newSpecPage } from '@stencil/core/testing';
22

33
import { Nav } from '../../../components/nav/nav';
44
import { RouterOutlet } from '../../../components/router-outlet/router-outlet';
5+
import { Modal } from '../../../components/modal/modal';
6+
57
import { setRootAriaHidden } from '../../overlays';
68

79
describe('setRootAriaHidden()', () => {
@@ -77,4 +79,54 @@ describe('setRootAriaHidden()', () => {
7779

7880
setRootAriaHidden(true);
7981
});
82+
83+
it('should remove router-outlet from accessibility tree when overlay is presented', async () => {
84+
const page = await newSpecPage({
85+
components: [RouterOutlet, Modal],
86+
html: `
87+
<ion-router-outlet>
88+
<ion-modal></ion-modal>
89+
</ion-router-outlet>
90+
`,
91+
});
92+
93+
const routerOutlet = page.body.querySelector('ion-router-outlet');
94+
const modal = page.body.querySelector('ion-modal');
95+
96+
await modal.present();
97+
98+
expect(routerOutlet.hasAttribute('aria-hidden')).toEqual(true);
99+
});
100+
101+
it('should add router-outlet from accessibility tree when then final overlay is dismissed', async () => {
102+
const page = await newSpecPage({
103+
components: [RouterOutlet, Modal],
104+
html: `
105+
<ion-router-outlet>
106+
<ion-modal id="one"></ion-modal>
107+
<ion-modal id="two"></ion-modal>
108+
</ion-router-outlet>
109+
`,
110+
});
111+
112+
const routerOutlet = page.body.querySelector('ion-router-outlet');
113+
const modalOne = page.body.querySelector('ion-modal#one');
114+
const modalTwo = page.body.querySelector('ion-modal#two');
115+
116+
await modalOne.present();
117+
118+
expect(routerOutlet.hasAttribute('aria-hidden')).toEqual(true);
119+
120+
await modalTwo.present();
121+
122+
expect(routerOutlet.hasAttribute('aria-hidden')).toEqual(true);
123+
124+
await modalOne.dismiss();
125+
126+
expect(routerOutlet.hasAttribute('aria-hidden')).toEqual(true);
127+
128+
await modalTwo.dismiss();
129+
130+
expect(routerOutlet.hasAttribute('aria-hidden')).toEqual(false);
131+
});
80132
});

0 commit comments

Comments
 (0)