Skip to content

Commit ba4ba61

Browse files
fix(overlays): ensure that only topmost overlay is announced by screen readers (#28997)
Issue number: resolves #23472 --------- <!-- Please do not submit updates to dependencies unless it fixes an issue. --> <!-- Please try to limit your pull request to one type (bugfix, feature, etc). Submit multiple pull requests if needed. --> ## What is the current behavior? <!-- Please describe the current behavior that you are modifying. --> 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? <!-- Please describe the behavior or changes that are being added by this PR. --> 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 <!-- If this introduces a breaking change: 1. Describe the impact and migration path for existing applications below. 2. Update the BREAKING.md file with the breaking change. 3. Add "BREAKING CHANGE: [...]" to the commit description when merging. See https://github.com/ionic-team/ionic-framework/blob/main/.github/CONTRIBUTING.md#footer for more information. --> ## Other information <!-- Any other information that is important to this PR such as screenshots of how the component looks before and after the change. -->
1 parent adc5655 commit ba4ba61

File tree

3 files changed

+94
-1
lines changed

3 files changed

+94
-1
lines changed

core/src/utils/overlays.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,16 @@ export const present = async <OverlayPresentOptions>(
491491

492492
setRootAriaHidden(true);
493493

494+
/**
495+
* Hide all other overlays from screen readers so only this one
496+
* can be read. Note that presenting an overlay always makes
497+
* it the topmost one.
498+
*/
499+
if (doc !== undefined) {
500+
const presentedOverlays = getPresentedOverlays(doc);
501+
presentedOverlays.forEach((o) => o.setAttribute('aria-hidden', 'true'));
502+
}
503+
494504
overlay.presented = true;
495505
overlay.willPresent.emit();
496506
overlay.willPresentShorthand?.emit();
@@ -528,6 +538,15 @@ export const present = async <OverlayPresentOptions>(
528538
if (overlay.keyboardClose && (document.activeElement === null || !overlay.el.contains(document.activeElement))) {
529539
overlay.el.focus();
530540
}
541+
542+
/**
543+
* If this overlay was previously dismissed without being
544+
* the topmost one (such as by manually calling dismiss()),
545+
* it would still have aria-hidden on being presented again.
546+
* Removing it here ensures the overlay is visible to screen
547+
* readers.
548+
*/
549+
overlay.el.removeAttribute('aria-hidden');
531550
};
532551

533552
/**
@@ -625,6 +644,15 @@ export const dismiss = async <OverlayDismissOptions>(
625644
}
626645

627646
overlay.el.remove();
647+
648+
/**
649+
* If there are other overlays presented, unhide the new
650+
* topmost one from screen readers.
651+
*/
652+
if (doc !== undefined) {
653+
getPresentedOverlay(doc)?.removeAttribute('aria-hidden');
654+
}
655+
628656
return true;
629657
};
630658

core/src/utils/test/overlays/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@
6262
</ion-toolbar>
6363
</ion-header>
6464
<ion-content class="ion-padding">
65-
Modal Content
65+
Modal ${id}
6666
6767
<ion-item>
6868
<ion-input label="Text Input" class="modal-input modal-input-${id}"></ion-input>

core/src/utils/test/overlays/overlays.spec.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,3 +129,68 @@ describe('setRootAriaHidden()', () => {
129129
expect(routerOutlet.hasAttribute('aria-hidden')).toEqual(false);
130130
});
131131
});
132+
133+
describe('aria-hidden on individual overlays', () => {
134+
it('should hide non-topmost overlays from screen readers', async () => {
135+
const page = await newSpecPage({
136+
components: [Modal],
137+
html: `
138+
<ion-modal id="one"></ion-modal>
139+
<ion-modal id="two"></ion-modal>
140+
`,
141+
});
142+
143+
const modalOne = page.body.querySelector<HTMLIonModalElement>('ion-modal#one')!;
144+
const modalTwo = page.body.querySelector<HTMLIonModalElement>('ion-modal#two')!;
145+
146+
await modalOne.present();
147+
await modalTwo.present();
148+
149+
expect(modalOne.hasAttribute('aria-hidden')).toEqual(true);
150+
expect(modalTwo.hasAttribute('aria-hidden')).toEqual(false);
151+
});
152+
153+
it('should unhide new topmost overlay from screen readers when topmost is dismissed', async () => {
154+
const page = await newSpecPage({
155+
components: [Modal],
156+
html: `
157+
<ion-modal id="one"></ion-modal>
158+
<ion-modal id="two"></ion-modal>
159+
`,
160+
});
161+
162+
const modalOne = page.body.querySelector<HTMLIonModalElement>('ion-modal#one')!;
163+
const modalTwo = page.body.querySelector<HTMLIonModalElement>('ion-modal#two')!;
164+
165+
await modalOne.present();
166+
await modalTwo.present();
167+
168+
// dismiss modalTwo so that modalOne becomes the new topmost overlay
169+
await modalTwo.dismiss();
170+
171+
expect(modalOne.hasAttribute('aria-hidden')).toEqual(false);
172+
});
173+
174+
it('should not keep overlays hidden from screen readers if presented after being dismissed while non-topmost', async () => {
175+
const page = await newSpecPage({
176+
components: [Modal],
177+
html: `
178+
<ion-modal id="one"></ion-modal>
179+
<ion-modal id="two"></ion-modal>
180+
`,
181+
});
182+
183+
const modalOne = page.body.querySelector<HTMLIonModalElement>('ion-modal#one')!;
184+
const modalTwo = page.body.querySelector<HTMLIonModalElement>('ion-modal#two')!;
185+
186+
await modalOne.present();
187+
await modalTwo.present();
188+
189+
// modalOne is not the topmost overlay at this point and is hidden from screen readers
190+
await modalOne.dismiss();
191+
192+
// modalOne will become the topmost overlay; ensure it isn't still hidden from screen readers
193+
await modalOne.present();
194+
expect(modalOne.hasAttribute('aria-hidden')).toEqual(false);
195+
});
196+
});

0 commit comments

Comments
 (0)