Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(overlays): ensure that only topmost overlay is announced by screen readers #28997

Merged
merged 18 commits into from
Feb 9, 2024
Merged
Show file tree
Hide file tree
Changes from 15 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
28 changes: 28 additions & 0 deletions core/src/utils/overlays.ts
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,16 @@ 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'));
}

overlay.presented = true;
overlay.willPresent.emit();
overlay.willPresentShorthand?.emit();
Expand Down Expand Up @@ -528,6 +538,15 @@ export const present = async <OverlayPresentOptions>(
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');
};

/**
Expand Down Expand Up @@ -625,6 +644,15 @@ 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');
}

return true;
};

Expand Down
2 changes: 1 addition & 1 deletion core/src/utils/test/overlays/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
Modal Content
Modal ${id}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't necessary for the fix, but I found it very useful while debugging since the page lets you create as many modals as you want, and this makes it easier to tell which one you're looking at. I can revert if we'd rather keep things slim.


<ion-item>
<ion-input label="Text Input" class="modal-input modal-input-${id}"></ion-input>
Expand Down
235 changes: 151 additions & 84 deletions core/src/utils/test/overlays/overlays.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,127 +5,194 @@ import { Nav } from '../../../components/nav/nav';
import { RouterOutlet } from '../../../components/router-outlet/router-outlet';
import { setRootAriaHidden } from '../../overlays';

describe('setRootAriaHidden()', () => {
it('should correctly remove and re-add router outlet from accessibility tree', async () => {
const page = await newSpecPage({
components: [RouterOutlet],
html: `
<ion-router-outlet></ion-router-outlet>
`,
describe('overlays', () => {
describe('setRootAriaHidden()', () => {
it('should correctly remove and re-add router outlet from accessibility tree', async () => {
const page = await newSpecPage({
components: [RouterOutlet],
html: `
<ion-router-outlet></ion-router-outlet>
`,
});

const routerOutlet = page.body.querySelector('ion-router-outlet')!;

expect(routerOutlet.hasAttribute('aria-hidden')).toEqual(false);

setRootAriaHidden(true);
expect(routerOutlet.hasAttribute('aria-hidden')).toEqual(true);

setRootAriaHidden(false);
expect(routerOutlet.hasAttribute('aria-hidden')).toEqual(false);
});

const routerOutlet = page.body.querySelector('ion-router-outlet')!;
it('should correctly remove and re-add nav from accessibility tree', async () => {
const page = await newSpecPage({
components: [Nav],
html: `
<ion-nav></ion-nav>
`,
});

expect(routerOutlet.hasAttribute('aria-hidden')).toEqual(false);
const nav = page.body.querySelector('ion-nav')!;

setRootAriaHidden(true);
expect(routerOutlet.hasAttribute('aria-hidden')).toEqual(true);
expect(nav.hasAttribute('aria-hidden')).toEqual(false);

setRootAriaHidden(false);
expect(routerOutlet.hasAttribute('aria-hidden')).toEqual(false);
});
setRootAriaHidden(true);
expect(nav.hasAttribute('aria-hidden')).toEqual(true);

it('should correctly remove and re-add nav from accessibility tree', async () => {
const page = await newSpecPage({
components: [Nav],
html: `
<ion-nav></ion-nav>
`,
setRootAriaHidden(false);
expect(nav.hasAttribute('aria-hidden')).toEqual(false);
});

const nav = page.body.querySelector('ion-nav')!;
it('should correctly remove and re-add custom container from accessibility tree', async () => {
const page = await newSpecPage({
components: [],
html: `
<div id="ion-view-container-root"></div>
<div id="not-container-root"></div>
`,
});

expect(nav.hasAttribute('aria-hidden')).toEqual(false);
const containerRoot = page.body.querySelector('#ion-view-container-root')!;
const notContainerRoot = page.body.querySelector('#not-container-root')!;

setRootAriaHidden(true);
expect(nav.hasAttribute('aria-hidden')).toEqual(true);
expect(containerRoot.hasAttribute('aria-hidden')).toEqual(false);
expect(notContainerRoot.hasAttribute('aria-hidden')).toEqual(false);

setRootAriaHidden(false);
expect(nav.hasAttribute('aria-hidden')).toEqual(false);
});
setRootAriaHidden(true);
expect(containerRoot.hasAttribute('aria-hidden')).toEqual(true);
expect(notContainerRoot.hasAttribute('aria-hidden')).toEqual(false);

it('should correctly remove and re-add custom container from accessibility tree', async () => {
const page = await newSpecPage({
components: [],
html: `
<div id="ion-view-container-root"></div>
<div id="not-container-root"></div>
`,
setRootAriaHidden(false);
expect(containerRoot.hasAttribute('aria-hidden')).toEqual(false);
expect(notContainerRoot.hasAttribute('aria-hidden')).toEqual(false);
});

const containerRoot = page.body.querySelector('#ion-view-container-root')!;
const notContainerRoot = page.body.querySelector('#not-container-root')!;
it('should not error if router outlet was not found', async () => {
await newSpecPage({
components: [],
html: `
<div></div>
`,
});

setRootAriaHidden(true);
});

expect(containerRoot.hasAttribute('aria-hidden')).toEqual(false);
expect(notContainerRoot.hasAttribute('aria-hidden')).toEqual(false);
it('should remove router-outlet from accessibility tree when overlay is presented', async () => {
const page = await newSpecPage({
components: [RouterOutlet, Modal],
html: `
<ion-router-outlet>
<ion-modal></ion-modal>
</ion-router-outlet>
`,
});

setRootAriaHidden(true);
expect(containerRoot.hasAttribute('aria-hidden')).toEqual(true);
expect(notContainerRoot.hasAttribute('aria-hidden')).toEqual(false);
const routerOutlet = page.body.querySelector('ion-router-outlet')!;
const modal = page.body.querySelector('ion-modal')!;

setRootAriaHidden(false);
expect(containerRoot.hasAttribute('aria-hidden')).toEqual(false);
expect(notContainerRoot.hasAttribute('aria-hidden')).toEqual(false);
});
await modal.present();

it('should not error if router outlet was not found', async () => {
await newSpecPage({
components: [],
html: `
<div></div>
`,
expect(routerOutlet.hasAttribute('aria-hidden')).toEqual(true);
});

setRootAriaHidden(true);
});
it('should add router-outlet from accessibility tree when then final overlay is dismissed', async () => {
const page = await newSpecPage({
components: [RouterOutlet, Modal],
html: `
<ion-router-outlet>
<ion-modal id="one"></ion-modal>
<ion-modal id="two"></ion-modal>
</ion-router-outlet>
`,
});

it('should remove router-outlet from accessibility tree when overlay is presented', async () => {
const page = await newSpecPage({
components: [RouterOutlet, Modal],
html: `
<ion-router-outlet>
<ion-modal></ion-modal>
</ion-router-outlet>
`,
});
const routerOutlet = page.body.querySelector('ion-router-outlet')!;
const modalOne = page.body.querySelector<HTMLIonModalElement>('ion-modal#one')!;
const modalTwo = page.body.querySelector<HTMLIonModalElement>('ion-modal#two')!;

await modalOne.present();

expect(routerOutlet.hasAttribute('aria-hidden')).toEqual(true);

await modalTwo.present();

const routerOutlet = page.body.querySelector('ion-router-outlet')!;
const modal = page.body.querySelector('ion-modal')!;
expect(routerOutlet.hasAttribute('aria-hidden')).toEqual(true);

await modal.present();
await modalOne.dismiss();

expect(routerOutlet.hasAttribute('aria-hidden')).toEqual(true);
expect(routerOutlet.hasAttribute('aria-hidden')).toEqual(true);

await modalTwo.dismiss();

expect(routerOutlet.hasAttribute('aria-hidden')).toEqual(false);
});
});

it('should add router-outlet from accessibility tree when then final overlay is dismissed', async () => {
const page = await newSpecPage({
components: [RouterOutlet, Modal],
html: `
<ion-router-outlet>
describe('aria-hidden on individual overlays', () => {
it('should hide non-topmost overlays from screen readers', async () => {
const page = await newSpecPage({
components: [Modal],
html: `
<ion-modal id="one"></ion-modal>
<ion-modal id="two"></ion-modal>
</ion-router-outlet>
`,
`,
});

const modalOne = page.body.querySelector<HTMLIonModalElement>('ion-modal#one')!;
const modalTwo = page.body.querySelector<HTMLIonModalElement>('ion-modal#two')!;

await modalOne.present();
await modalTwo.present();

expect(modalOne.hasAttribute('aria-hidden')).toEqual(true);
expect(modalTwo.hasAttribute('aria-hidden')).toEqual(false);
});

const routerOutlet = page.body.querySelector('ion-router-outlet')!;
const modalOne = page.body.querySelector<HTMLIonModalElement>('ion-modal#one')!;
const modalTwo = page.body.querySelector<HTMLIonModalElement>('ion-modal#two')!;
it('should unhide new topmost overlay from screen readers when topmost is dismissed', async () => {
const page = await newSpecPage({
components: [Modal],
html: `
<ion-modal id="one"></ion-modal>
<ion-modal id="two"></ion-modal>
`,
});

const modalOne = page.body.querySelector<HTMLIonModalElement>('ion-modal#one')!;
const modalTwo = page.body.querySelector<HTMLIonModalElement>('ion-modal#two')!;

await modalOne.present();
await modalOne.present();
await modalTwo.present();

expect(routerOutlet.hasAttribute('aria-hidden')).toEqual(true);
// dismiss modalTwo so that modalOne becomes the new topmost overlay
await modalTwo.dismiss();

await modalTwo.present();
expect(modalOne.hasAttribute('aria-hidden')).toEqual(false);
});

expect(routerOutlet.hasAttribute('aria-hidden')).toEqual(true);
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: `
<ion-modal id="one"></ion-modal>
<ion-modal id="two"></ion-modal>
`,
});

await modalOne.dismiss();
const modalOne = page.body.querySelector<HTMLIonModalElement>('ion-modal#one')!;
const modalTwo = page.body.querySelector<HTMLIonModalElement>('ion-modal#two')!;

expect(routerOutlet.hasAttribute('aria-hidden')).toEqual(true);
await modalOne.present();
await modalTwo.present();

await modalTwo.dismiss();
// modalOne is not the topmost overlay at this point and is hidden from screen readers
await modalOne.dismiss();

expect(routerOutlet.hasAttribute('aria-hidden')).toEqual(false);
// 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);
});
});
});
Loading