Skip to content
81 changes: 64 additions & 17 deletions core/src/utils/overlays.ts
Original file line number Diff line number Diff line change
Expand Up @@ -541,16 +541,7 @@ export const present = async <OverlayPresentOptions>(
}

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();
Expand Down Expand Up @@ -723,13 +714,7 @@ export const dismiss = async <OverlayDismissOptions>(

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;
};
Expand Down Expand Up @@ -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;
}
}
};
67 changes: 67 additions & 0 deletions core/src/utils/test/overlays/overlays.spec.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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: `
<ion-modal id="m-one"></ion-modal>
<ion-modal id="m-two"></ion-modal>
<ion-toast id="t-one"></ion-toast>
<ion-toast id="t-two"></ion-toast>
`,
});

const modalOne = page.body.querySelector<HTMLIonModalElement>('ion-modal#m-one')!;
const modalTwo = page.body.querySelector<HTMLIonModalElement>('ion-modal#m-two')!;
const toastOne = page.body.querySelector<HTMLIonModalElement>('ion-toast#t-one')!;
const toastTwo = page.body.querySelector<HTMLIonModalElement>('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: `
<ion-modal id="m-one"></ion-modal>
<ion-toast id="t-one"></ion-toast>
<ion-modal id="m-two"></ion-modal>
`,
});

const modalOne = page.body.querySelector<HTMLIonModalElement>('ion-modal#m-one')!;
const modalTwo = page.body.querySelector<HTMLIonModalElement>('ion-modal#m-two')!;
const toastOne = page.body.querySelector<HTMLIonModalElement>('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);
});
});