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): correctly re-add root to accessibility tree #28183

Merged
merged 5 commits into from
Sep 19, 2023
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
4 changes: 2 additions & 2 deletions core/src/components/menu/menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { GESTURE_CONTROLLER } from '@utils/gesture';
import type { Attributes } from '@utils/helpers';
import { inheritAriaAttributes, assert, clamp, isEndSide as isEnd } from '@utils/helpers';
import { menuController } from '@utils/menu-controller';
import { getOverlay } from '@utils/overlays';
import { getPresentedOverlay } from '@utils/overlays';

import { config } from '../../global/config';
import { getIonMode } from '../../global/ionic-global';
Expand Down Expand Up @@ -59,7 +59,7 @@ export class Menu implements ComponentInterface, MenuI {
* open does not contain this ion-menu, then ion-menu's
* focus trapping should not run.
*/
const lastOverlay = getOverlay(document);
const lastOverlay = getPresentedOverlay(document);
if (lastOverlay && !lastOverlay.contains(this.el)) {
return;
}
Expand Down
47 changes: 38 additions & 9 deletions core/src/utils/overlays.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { doc } from '@utils/browser';

import { config } from '../global/config';
import { getIonMode } from '../global/ionic-global';
import type {
Expand Down Expand Up @@ -36,7 +38,7 @@ const createController = <Opts extends object, HTMLElm>(tagName: string) => {
return dismissOverlay(document, data, role, tagName, id);
},
async getTop(): Promise<HTMLElm | undefined> {
return getOverlay(document, tagName) as any;
return getPresentedOverlay(document, tagName) as any;
},
};
};
Expand Down Expand Up @@ -173,7 +175,10 @@ const focusLastDescendant = (ref: Element, overlay: HTMLIonOverlayElement) => {
* Should NOT include: Toast
*/
const trapKeyboardFocus = (ev: Event, doc: Document) => {
const lastOverlay = getOverlay(doc, 'ion-alert,ion-action-sheet,ion-loading,ion-modal,ion-picker,ion-popover');
const lastOverlay = getPresentedOverlay(
doc,
'ion-alert,ion-action-sheet,ion-loading,ion-modal,ion-picker,ion-popover'
);
const target = ev.target as HTMLElement | null;

/**
Expand Down Expand Up @@ -344,7 +349,7 @@ const connectListeners = (doc: Document) => {

// handle back-button click
doc.addEventListener('ionBackButton', (ev) => {
const lastOverlay = getOverlay(doc);
const lastOverlay = getPresentedOverlay(doc);
if (lastOverlay?.backdropDismiss) {
(ev as BackButtonEvent).detail.register(OVERLAY_BACK_BUTTON_PRIORITY, () => {
return lastOverlay.dismiss(undefined, BACKDROP);
Expand All @@ -355,7 +360,7 @@ const connectListeners = (doc: Document) => {
// handle ESC to close overlay
doc.addEventListener('keydown', (ev) => {
if (ev.key === 'Escape') {
const lastOverlay = getOverlay(doc);
const lastOverlay = getPresentedOverlay(doc);
if (lastOverlay?.backdropDismiss) {
lastOverlay.dismiss(undefined, BACKDROP);
}
Expand All @@ -371,13 +376,16 @@ export const dismissOverlay = (
overlayTag: string,
id?: string
): Promise<boolean> => {
const overlay = getOverlay(doc, overlayTag, id);
const overlay = getPresentedOverlay(doc, overlayTag, id);
if (!overlay) {
return Promise.reject('overlay does not exist');
}
return overlay.dismiss(data, role);
};

/**
* Returns a list of all overlays in the DOM even if they are not presented.
*/
export const getOverlays = (doc: Document, selector?: string): HTMLIonOverlayElement[] => {
if (selector === undefined) {
selector = 'ion-alert,ion-action-sheet,ion-loading,ion-modal,ion-picker,ion-popover,ion-toast';
Expand All @@ -386,14 +394,29 @@ export const getOverlays = (doc: Document, selector?: string): HTMLIonOverlayEle
};

/**
* Returns an overlay element
* Returns a list of all presented overlays.
* Inline overlays can exist in the DOM but not be presented,
* so there are times when we want to exclude those.
* @param doc The document to find the element within.
* @param overlayTag The selector for the overlay, defaults to Ionic overlay components.
*/
const getPresentedOverlays = (doc: Document, overlayTag?: string): HTMLIonOverlayElement[] => {
return getOverlays(doc, overlayTag).filter((o) => !isOverlayHidden(o));
};

/**
* Returns a presented overlay element.
* @param doc The document to find the element within.
* @param overlayTag The selector for the overlay, defaults to Ionic overlay components.
* @param id The unique identifier for the overlay instance.
* @returns The overlay element or `undefined` if no overlay element is found.
*/
export const getOverlay = (doc: Document, overlayTag?: string, id?: string): HTMLIonOverlayElement | undefined => {
const overlays = getOverlays(doc, overlayTag).filter((o) => !isOverlayHidden(o));
export const getPresentedOverlay = (
doc: Document,
overlayTag?: string,
id?: string
): HTMLIonOverlayElement | undefined => {
const overlays = getPresentedOverlays(doc, overlayTag);
return id === undefined ? overlays[overlays.length - 1] : overlays.find((o) => o.id === id);
};

Expand Down Expand Up @@ -525,7 +548,13 @@ export const dismiss = async <OverlayDismissOptions>(
return false;
}

setRootAriaHidden(false);
/**
* If this is the last visible overlay then
* we want to re-add the root to the accessibility tree.
*/
if (doc !== undefined && getPresentedOverlays(doc).length === 1) {
setRootAriaHidden(false);
}

overlay.presented = false;

Expand Down
52 changes: 52 additions & 0 deletions core/src/utils/test/overlays/overlays.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { newSpecPage } from '@stencil/core/testing';

import { Nav } from '../../../components/nav/nav';
import { RouterOutlet } from '../../../components/router-outlet/router-outlet';
import { Modal } from '../../../components/modal/modal';

import { setRootAriaHidden } from '../../overlays';

describe('setRootAriaHidden()', () => {
Expand Down Expand Up @@ -77,4 +79,54 @@ describe('setRootAriaHidden()', () => {

setRootAriaHidden(true);
});

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 modal = page.body.querySelector('ion-modal');

await modal.present();

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

const routerOutlet = page.body.querySelector('ion-router-outlet');
const modalOne = page.body.querySelector('ion-modal#one');
const modalTwo = page.body.querySelector('ion-modal#two');

await modalOne.present();

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

await modalTwo.present();

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

await modalOne.dismiss();

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

await modalTwo.dismiss();

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