Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
});
});