From c0f5e5ebc0c9d45d71e10e09903b00b3ba8e6bba Mon Sep 17 00:00:00 2001 From: Liam DeBeasi Date: Tue, 12 Mar 2024 17:34:55 -0400 Subject: [PATCH] fix(overlay): do not hide overlay if toast is presented (#29140) Issue number: resolves #29139 --------- ## What is the current behavior? When implementing https://github.com/ionic-team/ionic-framework/pull/28997 we did not consider the case where a Toast could be presented. When presenting a Toast after presenting a Modal the linked change causes the Modal to be hidden from screen readers. ## What is the new behavior? - If the top-most overlay is a Toast then the closest non-Toast overlay is also not hidden from screen readers. ## Does this introduce a breaking change? - [ ] Yes - [x] No ## Other information Dev build: `7.7.5-dev.11710260658.1fc29a6c` --------- Co-authored-by: Amanda Johnston <90629384+amandaejohnston@users.noreply.github.com> --- core/src/utils/overlays.ts | 81 +++++++++++++++---- core/src/utils/test/overlays/overlays.spec.ts | 67 +++++++++++++++ 2 files changed, 131 insertions(+), 17 deletions(-) diff --git a/core/src/utils/overlays.ts b/core/src/utils/overlays.ts index 11ec2f56698..b248206151a 100644 --- a/core/src/utils/overlays.ts +++ b/core/src/utils/overlays.ts @@ -541,16 +541,7 @@ export const present = async ( } setRootAriaHidden(true); - - /** - * Hide all other overlays from screen readers so only this one - * can be read. Note that presenting an overlay always makes - * it the topmost one. - */ - if (doc !== undefined) { - const presentedOverlays = getPresentedOverlays(doc); - presentedOverlays.forEach((o) => o.setAttribute('aria-hidden', 'true')); - } + hideOverlaysFromScreenReaders(overlay.el); overlay.presented = true; overlay.willPresent.emit(); @@ -723,13 +714,7 @@ export const dismiss = async ( overlay.el.remove(); - /** - * If there are other overlays presented, unhide the new - * topmost one from screen readers. - */ - if (doc !== undefined) { - getPresentedOverlay(doc)?.removeAttribute('aria-hidden'); - } + revealOverlaysToScreenReaders(); return true; }; @@ -966,3 +951,65 @@ export const createTriggerController = () => { removeClickListener, }; }; + +/** + * Ensure that underlying overlays have aria-hidden if necessary so that screen readers + * cannot move focus to these elements. Note that we cannot rely on focus/focusin/focusout + * events here because those events do not fire when the screen readers moves to a non-focusable + * element such as text. + * Without this logic screen readers would be able to move focus outside of the top focus-trapped overlay. + * + * @param newTopMostOverlay - The overlay that is being presented. Since the overlay has not been + * fully presented yet at the time this function is called it will not be included in the getPresentedOverlays result. + */ +const hideOverlaysFromScreenReaders = (newTopMostOverlay: HTMLIonOverlayElement) => { + if (doc === undefined) return; + + const overlays = getPresentedOverlays(doc); + + for (let i = overlays.length - 1; i >= 0; i--) { + const presentedOverlay = overlays[i]; + const nextPresentedOverlay = overlays[i + 1] ?? newTopMostOverlay; + + /** + * If next overlay has aria-hidden then all remaining overlays will have it too. + * Or, if the next overlay is a Toast that does not have aria-hidden then current overlay + * should not have aria-hidden either so focus can remain in the current overlay. + */ + if (nextPresentedOverlay.hasAttribute('aria-hidden') || nextPresentedOverlay.tagName !== 'ION-TOAST') { + presentedOverlay.setAttribute('aria-hidden', 'true'); + } + } +}; + +/** + * When dismissing an overlay we need to reveal the new top-most overlay to screen readers. + * If the top-most overlay is a Toast we potentially need to reveal more overlays since + * focus is never automatically moved to the Toast. + */ +const revealOverlaysToScreenReaders = () => { + if (doc === undefined) return; + + const overlays = getPresentedOverlays(doc); + + for (let i = overlays.length - 1; i >= 0; i--) { + const currentOverlay = overlays[i]; + + /** + * If the current we are looking at is a Toast then we can remove aria-hidden. + * However, we potentially need to keep looking at the overlay stack because there + * could be more Toasts underneath. Additionally, we need to unhide the closest non-Toast + * overlay too so focus can move there since focus is never automatically moved to the Toast. + */ + currentOverlay.removeAttribute('aria-hidden'); + + /** + * If we found a non-Toast element then we can just remove aria-hidden and stop searching entirely + * since this overlay should always receive focus. As a result, all underlying overlays should still + * be hidden from screen readers. + */ + if (currentOverlay.tagName !== 'ION-TOAST') { + break; + } + } +}; diff --git a/core/src/utils/test/overlays/overlays.spec.ts b/core/src/utils/test/overlays/overlays.spec.ts index 7b67a221832..29a77c3c268 100644 --- a/core/src/utils/test/overlays/overlays.spec.ts +++ b/core/src/utils/test/overlays/overlays.spec.ts @@ -1,6 +1,7 @@ import { newSpecPage } from '@stencil/core/testing'; import { Modal } from '../../../components/modal/modal'; +import { Toast } from '../../../components/toast/toast'; import { Nav } from '../../../components/nav/nav'; import { RouterOutlet } from '../../../components/router-outlet/router-outlet'; import { setRootAriaHidden } from '../../overlays'; @@ -193,4 +194,70 @@ describe('aria-hidden on individual overlays', () => { await modalOne.present(); expect(modalOne.hasAttribute('aria-hidden')).toEqual(false); }); + + it('should not hide previous overlay if top-most overlay is toast', async () => { + const page = await newSpecPage({ + components: [Modal, Toast], + html: ` + + + + + `, + }); + + const modalOne = page.body.querySelector('ion-modal#m-one')!; + const modalTwo = page.body.querySelector('ion-modal#m-two')!; + const toastOne = page.body.querySelector('ion-toast#t-one')!; + const toastTwo = page.body.querySelector('ion-toast#t-two')!; + + await modalOne.present(); + await modalTwo.present(); + await toastOne.present(); + await toastTwo.present(); + + expect(modalOne.hasAttribute('aria-hidden')).toEqual(true); + expect(modalTwo.hasAttribute('aria-hidden')).toEqual(false); + expect(toastOne.hasAttribute('aria-hidden')).toEqual(false); + expect(toastTwo.hasAttribute('aria-hidden')).toEqual(false); + + await toastTwo.dismiss(); + + expect(modalOne.hasAttribute('aria-hidden')).toEqual(true); + expect(modalTwo.hasAttribute('aria-hidden')).toEqual(false); + expect(toastOne.hasAttribute('aria-hidden')).toEqual(false); + + await toastOne.dismiss(); + + expect(modalOne.hasAttribute('aria-hidden')).toEqual(true); + expect(modalTwo.hasAttribute('aria-hidden')).toEqual(false); + }); + + it('should hide previous overlay even with a toast that is not the top-most overlay', async () => { + const page = await newSpecPage({ + components: [Modal, Toast], + html: ` + + + + `, + }); + + const modalOne = page.body.querySelector('ion-modal#m-one')!; + const modalTwo = page.body.querySelector('ion-modal#m-two')!; + const toastOne = page.body.querySelector('ion-toast#t-one')!; + + await modalOne.present(); + await toastOne.present(); + await modalTwo.present(); + + expect(modalOne.hasAttribute('aria-hidden')).toEqual(true); + expect(toastOne.hasAttribute('aria-hidden')).toEqual(true); + expect(modalTwo.hasAttribute('aria-hidden')).toEqual(false); + + await modalTwo.dismiss(); + + expect(modalOne.hasAttribute('aria-hidden')).toEqual(false); + expect(toastOne.hasAttribute('aria-hidden')).toEqual(false); + }); });