From 81714d45bd97f0ba91729959b60a0dc1d1d06533 Mon Sep 17 00:00:00 2001 From: Liam DeBeasi Date: Tue, 19 Sep 2023 10:46:14 -0400 Subject: [PATCH] fix(overlays): correctly re-add root to accessibility tree (#28183) Issue number: resolves #28180 --------- ## What is the current behavior? 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? - 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 ## Other information Dev build: `7.4.1-dev.11694783260.13da477f` --- core/src/components/menu/menu.tsx | 4 +- core/src/utils/overlays.ts | 47 +++++++++++++---- core/src/utils/test/overlays/overlays.spec.ts | 52 +++++++++++++++++++ 3 files changed, 92 insertions(+), 11 deletions(-) diff --git a/core/src/components/menu/menu.tsx b/core/src/components/menu/menu.tsx index 5072bb74cd..ec12c98210 100644 --- a/core/src/components/menu/menu.tsx +++ b/core/src/components/menu/menu.tsx @@ -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; } diff --git a/core/src/utils/overlays.ts b/core/src/utils/overlays.ts index 7bde820990..9a01438aca 100644 --- a/core/src/utils/overlays.ts +++ b/core/src/utils/overlays.ts @@ -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 = (tagName: string) => { return dismissOverlay(document, data, role, tagName, id); }, async getTop(): Promise { - 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 => { - 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 ( 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; diff --git a/core/src/utils/test/overlays/overlays.spec.ts b/core/src/utils/test/overlays/overlays.spec.ts index 94dc7a815f..3fecba0edd 100644 --- a/core/src/utils/test/overlays/overlays.spec.ts +++ b/core/src/utils/test/overlays/overlays.spec.ts @@ -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: ` + + + + `, + }); + + 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: ` + + + + + `, + }); + + 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); + }); });