diff --git a/core/src/utils/overlays.ts b/core/src/utils/overlays.ts index f4eaad1e4cf..d8f1c257456 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 51fa62440e7..60e0a693180 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 d5b1442e3bb..7b67a221832 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); + }); +});