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:
Liam DeBeasi
2023-09-19 10:46:14 -04:00
committed by GitHub
parent 4e0b522728
commit 81714d45bd
3 changed files with 92 additions and 11 deletions

View File

@ -6,7 +6,7 @@ import { GESTURE_CONTROLLER } from '@utils/gesture';
import type { Attributes } from '@utils/helpers'; import type { Attributes } from '@utils/helpers';
import { inheritAriaAttributes, assert, clamp, isEndSide as isEnd } from '@utils/helpers'; import { inheritAriaAttributes, assert, clamp, isEndSide as isEnd } from '@utils/helpers';
import { menuController } from '@utils/menu-controller'; import { menuController } from '@utils/menu-controller';
import { getOverlay } from '@utils/overlays'; import { getPresentedOverlay } from '@utils/overlays';
import { config } from '../../global/config'; import { config } from '../../global/config';
import { getIonMode } from '../../global/ionic-global'; 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 * open does not contain this ion-menu, then ion-menu's
* focus trapping should not run. * focus trapping should not run.
*/ */
const lastOverlay = getOverlay(document); const lastOverlay = getPresentedOverlay(document);
if (lastOverlay && !lastOverlay.contains(this.el)) { if (lastOverlay && !lastOverlay.contains(this.el)) {
return; return;
} }

View File

@ -1,3 +1,5 @@
import { doc } from '@utils/browser';
import { config } from '../global/config'; import { config } from '../global/config';
import { getIonMode } from '../global/ionic-global'; import { getIonMode } from '../global/ionic-global';
import type { import type {
@ -36,7 +38,7 @@ const createController = <Opts extends object, HTMLElm>(tagName: string) => {
return dismissOverlay(document, data, role, tagName, id); return dismissOverlay(document, data, role, tagName, id);
}, },
async getTop(): Promise<HTMLElm | undefined> { 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 * Should NOT include: Toast
*/ */
const trapKeyboardFocus = (ev: Event, doc: Document) => { 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; const target = ev.target as HTMLElement | null;
/** /**
@ -344,7 +349,7 @@ const connectListeners = (doc: Document) => {
// handle back-button click // handle back-button click
doc.addEventListener('ionBackButton', (ev) => { doc.addEventListener('ionBackButton', (ev) => {
const lastOverlay = getOverlay(doc); const lastOverlay = getPresentedOverlay(doc);
if (lastOverlay?.backdropDismiss) { if (lastOverlay?.backdropDismiss) {
(ev as BackButtonEvent).detail.register(OVERLAY_BACK_BUTTON_PRIORITY, () => { (ev as BackButtonEvent).detail.register(OVERLAY_BACK_BUTTON_PRIORITY, () => {
return lastOverlay.dismiss(undefined, BACKDROP); return lastOverlay.dismiss(undefined, BACKDROP);
@ -355,7 +360,7 @@ const connectListeners = (doc: Document) => {
// handle ESC to close overlay // handle ESC to close overlay
doc.addEventListener('keydown', (ev) => { doc.addEventListener('keydown', (ev) => {
if (ev.key === 'Escape') { if (ev.key === 'Escape') {
const lastOverlay = getOverlay(doc); const lastOverlay = getPresentedOverlay(doc);
if (lastOverlay?.backdropDismiss) { if (lastOverlay?.backdropDismiss) {
lastOverlay.dismiss(undefined, BACKDROP); lastOverlay.dismiss(undefined, BACKDROP);
} }
@ -371,13 +376,16 @@ export const dismissOverlay = (
overlayTag: string, overlayTag: string,
id?: string id?: string
): Promise<boolean> => { ): Promise<boolean> => {
const overlay = getOverlay(doc, overlayTag, id); const overlay = getPresentedOverlay(doc, overlayTag, id);
if (!overlay) { if (!overlay) {
return Promise.reject('overlay does not exist'); return Promise.reject('overlay does not exist');
} }
return overlay.dismiss(data, role); 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[] => { export const getOverlays = (doc: Document, selector?: string): HTMLIonOverlayElement[] => {
if (selector === undefined) { if (selector === undefined) {
selector = 'ion-alert,ion-action-sheet,ion-loading,ion-modal,ion-picker,ion-popover,ion-toast'; 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 doc The document to find the element within.
* @param overlayTag The selector for the overlay, defaults to Ionic overlay components. * @param overlayTag The selector for the overlay, defaults to Ionic overlay components.
* @param id The unique identifier for the overlay instance. * @param id The unique identifier for the overlay instance.
* @returns The overlay element or `undefined` if no overlay element is found. * @returns The overlay element or `undefined` if no overlay element is found.
*/ */
export const getOverlay = (doc: Document, overlayTag?: string, id?: string): HTMLIonOverlayElement | undefined => { export const getPresentedOverlay = (
const overlays = getOverlays(doc, overlayTag).filter((o) => !isOverlayHidden(o)); 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); return id === undefined ? overlays[overlays.length - 1] : overlays.find((o) => o.id === id);
}; };
@ -525,7 +548,13 @@ export const dismiss = async <OverlayDismissOptions>(
return false; 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; overlay.presented = false;

View File

@ -2,6 +2,8 @@ import { newSpecPage } from '@stencil/core/testing';
import { Nav } from '../../../components/nav/nav'; import { Nav } from '../../../components/nav/nav';
import { RouterOutlet } from '../../../components/router-outlet/router-outlet'; import { RouterOutlet } from '../../../components/router-outlet/router-outlet';
import { Modal } from '../../../components/modal/modal';
import { setRootAriaHidden } from '../../overlays'; import { setRootAriaHidden } from '../../overlays';
describe('setRootAriaHidden()', () => { describe('setRootAriaHidden()', () => {
@ -77,4 +79,54 @@ describe('setRootAriaHidden()', () => {
setRootAriaHidden(true); 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);
});
}); });