From ba4ba6161c1a6c67f7804b07f49c64ac9ad2b14c Mon Sep 17 00:00:00 2001 From: Amanda Johnston <90629384+amandaejohnston@users.noreply.github.com> Date: Fri, 9 Feb 2024 07:43:54 -0800 Subject: [PATCH] fix(overlays): ensure that only topmost overlay is announced by screen readers (#28997) Issue number: resolves #23472 --------- ## What is the current behavior? If multiple overlays are presented at the same time, none of them receive `aria-hidden="true"`. This means that screen readers can read contents from overlays behind the current one, which can be confusing for users. The original issue also reports router outlets getting `aria-hidden` removed when any overlay is dismissed, not just the last one, but we've since fixed that: https://github.com/ionic-team/ionic-framework/blob/35ab6b4816bd627239de8d8b25ce0c86db8c74b4/core/src/utils/overlays.ts#L573-L576 ## What is the new behavior? All overlays besides the topmost one now receive `aria-hidden="true"`. This means that screen readers will only announce the topmost overlay. ## Does this introduce a breaking change? - [ ] Yes - [x] No ## Other information --- core/src/utils/overlays.ts | 28 ++++++++ core/src/utils/test/overlays/index.html | 2 +- core/src/utils/test/overlays/overlays.spec.ts | 65 +++++++++++++++++++ 3 files changed, 94 insertions(+), 1 deletion(-) diff --git a/core/src/utils/overlays.ts b/core/src/utils/overlays.ts index f4eaad1e4c..d8f1c25745 100644 --- a/core/src/utils/overlays.ts +++ b/core/src/utils/overlays.ts @@ -491,6 +491,16 @@ 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')); + } + overlay.presented = true; overlay.willPresent.emit(); overlay.willPresentShorthand?.emit(); @@ -528,6 +538,15 @@ export const present = async ( if (overlay.keyboardClose && (document.activeElement === null || !overlay.el.contains(document.activeElement))) { overlay.el.focus(); } + + /** + * If this overlay was previously dismissed without being + * the topmost one (such as by manually calling dismiss()), + * it would still have aria-hidden on being presented again. + * Removing it here ensures the overlay is visible to screen + * readers. + */ + overlay.el.removeAttribute('aria-hidden'); }; /** @@ -625,6 +644,15 @@ 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'); + } + return true; }; diff --git a/core/src/utils/test/overlays/index.html b/core/src/utils/test/overlays/index.html index 51fa62440e..60e0a69318 100644 --- a/core/src/utils/test/overlays/index.html +++ b/core/src/utils/test/overlays/index.html @@ -62,7 +62,7 @@ - Modal Content + Modal ${id} diff --git a/core/src/utils/test/overlays/overlays.spec.ts b/core/src/utils/test/overlays/overlays.spec.ts index d5b1442e3b..7b67a22183 100644 --- a/core/src/utils/test/overlays/overlays.spec.ts +++ b/core/src/utils/test/overlays/overlays.spec.ts @@ -129,3 +129,68 @@ describe('setRootAriaHidden()', () => { expect(routerOutlet.hasAttribute('aria-hidden')).toEqual(false); }); }); + +describe('aria-hidden on individual overlays', () => { + it('should hide non-topmost overlays from screen readers', async () => { + const page = await newSpecPage({ + components: [Modal], + html: ` + + + `, + }); + + const modalOne = page.body.querySelector('ion-modal#one')!; + const modalTwo = page.body.querySelector('ion-modal#two')!; + + await modalOne.present(); + await modalTwo.present(); + + expect(modalOne.hasAttribute('aria-hidden')).toEqual(true); + expect(modalTwo.hasAttribute('aria-hidden')).toEqual(false); + }); + + it('should unhide new topmost overlay from screen readers when topmost is dismissed', async () => { + const page = await newSpecPage({ + components: [Modal], + html: ` + + + `, + }); + + const modalOne = page.body.querySelector('ion-modal#one')!; + const modalTwo = page.body.querySelector('ion-modal#two')!; + + await modalOne.present(); + await modalTwo.present(); + + // dismiss modalTwo so that modalOne becomes the new topmost overlay + await modalTwo.dismiss(); + + expect(modalOne.hasAttribute('aria-hidden')).toEqual(false); + }); + + it('should not keep overlays hidden from screen readers if presented after being dismissed while non-topmost', async () => { + const page = await newSpecPage({ + components: [Modal], + html: ` + + + `, + }); + + const modalOne = page.body.querySelector('ion-modal#one')!; + const modalTwo = page.body.querySelector('ion-modal#two')!; + + await modalOne.present(); + await modalTwo.present(); + + // modalOne is not the topmost overlay at this point and is hidden from screen readers + await modalOne.dismiss(); + + // modalOne will become the topmost overlay; ensure it isn't still hidden from screen readers + await modalOne.present(); + expect(modalOne.hasAttribute('aria-hidden')).toEqual(false); + }); +});