Skip to content

Commit 6251eb8

Browse files
gnbmbrandyscarney
andauthored
fix(modal): focus the dialog wrapper on present so screen readers can enter the modal (#31260)
Issue number: internal --------- ## What is the current behavior? When a modal is opened with Android TalkBack enabled, the screen reader cannot enter or navigate the modal's content. On present, focus is moved to the modal host, but the host is role-less. The `role="dialog"`, `aria-modal`, and the label live on the inner `.ion-overlay-wrapper` so assistive technologies have no dialog to land on. ## What is the new behavior? - `present()` now focuses the overlay's `[role="dialog"]` element (the wrapper) instead of the role-less host, falling back to the host when the role is on the host (action-sheet, loading). - The modal wrapper gets `tabIndex="-1"` so it can receive that programmatic focus (an element with only `role="dialog"` is not focusable). Its focus ring is suppressed since the focus is programmatic, not keyboard-driven. - Clears text selection when gestures start to prevent Firefox from interrupting swipe-to-close and sheet gestures. - Adds an e2e test to `modal/test/a11y/modal.e2e.ts` to verify no visible outline added to the wrapper. - Adds e2e tests to `utils/test/overlays/overlays.e2e.ts` to verify the focus is on the wrapper instead of the host for all modals. - Fixes Playwright test helper to clear text selection immediately after `mouse.down()` to prevent the selection from blocking gesture detection in the test environment. --------- Co-authored-by: Brandy Smith <brandyscarney@users.noreply.github.com> Co-authored-by: Brandy Smith <6577830+brandyscarney@users.noreply.github.com>
1 parent fcab9ea commit 6251eb8

8 files changed

Lines changed: 157 additions & 1 deletion

File tree

core/src/components/modal/gestures/sheet.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,13 @@ export const createSheetGesture = (
299299
};
300300

301301
const onStart = (detail: GestureDetail) => {
302+
/**
303+
* Firefox automatically selects the header text during drag
304+
* due to the focusable wrapper (tabindex="-1"). Remove any
305+
* selection that may have occurred.
306+
*/
307+
window.getSelection()?.removeAllRanges();
308+
302309
/**
303310
* If canDismiss is anything other than `true`
304311
* then users should be able to swipe down

core/src/components/modal/gestures/swipe-to-close.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,13 @@ export const createSwipeToCloseGesture = (
116116
const onStart = (detail: GestureDetail) => {
117117
const { deltaY } = detail;
118118

119+
/**
120+
* Firefox automatically selects the header text during drag
121+
* due to the focusable wrapper (tabindex="-1"). Remove any
122+
* selection that may have occurred.
123+
*/
124+
window.getSelection()?.removeAllRanges();
125+
119126
/**
120127
* Get the initial scrollY value so
121128
* that we can correctly reset the scrollY

core/src/components/modal/modal.scss

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,14 @@ ion-backdrop {
8484
z-index: 10;
8585
}
8686

87+
/**
88+
* The wrapper receives programmatic focus for screen readers but should not
89+
* show a visible focus ring, which is meant only for keyboard navigation.
90+
*/
91+
.modal-wrapper {
92+
outline: none;
93+
}
94+
8795
.modal-shadow {
8896
position: absolute;
8997

core/src/components/modal/modal.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1644,10 +1644,17 @@ export class Modal implements ComponentInterface, OverlayInterface {
16441644
same element. They must also be set inside the
16451645
shadow DOM otherwise ion-button will not be highlighted
16461646
when using VoiceOver: https://bugs.webkit.org/show_bug.cgi?id=247134
1647+
1648+
tabIndex={-1} is required so present() can move focus to this
1649+
element (which carries the dialog role) instead of the role-less
1650+
host. role="dialog" alone does not make an element focusable, so
1651+
without the tabindex focus() would be a no-op and screen readers
1652+
may not properly announce the dialog and its content when it opens.
16471653
*/
16481654
role="dialog"
16491655
{...inheritedAttributes}
16501656
aria-modal="true"
1657+
tabIndex={-1}
16511658
class="modal-wrapper ion-overlay-wrapper"
16521659
part="content"
16531660
ref={(el) => (this.wrapperEl = el)}

core/src/components/modal/test/a11y/modal.e2e.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,22 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
1919
const results = await new AxeBuilder({ page }).analyze();
2020
expect(results.violations).toEqual([]);
2121
});
22+
23+
// The focused wrapper must not show a focus ring when opened via keyboard.
24+
test('should not render a focus ring on the wrapper when presented via keyboard', async ({ page }) => {
25+
await page.goto(`/src/components/modal/test/a11y`, config);
26+
27+
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
28+
const button = page.locator('#open-modal');
29+
const wrapper = page.locator('ion-modal .modal-wrapper');
30+
31+
// Open with the keyboard so :focus-visible applies to the wrapper.
32+
await button.focus();
33+
await page.keyboard.press('Enter');
34+
await ionModalDidPresent.next();
35+
36+
await expect(wrapper).toBeFocused();
37+
await expect(wrapper).toHaveCSS('outline-style', 'none');
38+
});
2239
});
2340
});

core/src/utils/overlays.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -593,7 +593,29 @@ export const present = async <OverlayPresentOptions>(
593593
* to the overlay container.
594594
*/
595595
if (overlay.keyboardClose && (document.activeElement === null || !overlay.el.contains(document.activeElement))) {
596-
overlay.el.focus();
596+
/**
597+
* Some overlays (e.g. modal) put the dialog role and accessible label
598+
* on the `.ion-overlay-wrapper` instead of the host. Screen readers
599+
* need focus on the element with `role="dialog"` to properly announce
600+
* and navigate the dialog.
601+
*
602+
* We only target wrappers with `tabindex`, since `role="dialog"` alone
603+
* does not make an element focusable. If no focusable dialog wrapper
604+
* exists (e.g. picker-legacy), we fall back to the host.
605+
*/
606+
const overlayWrapper = getElementRoot(overlay.el).querySelector<HTMLElement>('[role="dialog"][tabindex]');
607+
const focusTarget = overlayWrapper ?? overlay.el;
608+
/**
609+
* `preventScroll` keeps this a pure focus move so the viewport does not
610+
* jump when the wrapper is partially off-screen (e.g. a sheet modal).
611+
* Guard the options call so an older engine that mishandles it can never
612+
* reject present(); we fall back to a plain focus() in that case.
613+
*/
614+
try {
615+
focusTarget.focus({ preventScroll: true });
616+
} catch {
617+
focusTarget.focus();
618+
}
597619
}
598620

599621
/**

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

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,5 +421,87 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
421421
// verify focus is in correct location
422422
await expect(input).toBeFocused();
423423
});
424+
425+
// Focus the role="dialog" wrapper on present so screen readers can enter.
426+
test('should focus the modal wrapper on present', async ({ page }, testInfo) => {
427+
testInfo.annotations.push({
428+
type: 'issue',
429+
description: 'FW-7611',
430+
});
431+
await page.setContent(
432+
`
433+
<ion-modal>
434+
<ion-content>Modal Content</ion-content>
435+
</ion-modal>
436+
`,
437+
config
438+
);
439+
440+
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
441+
const modal = page.locator('ion-modal');
442+
const wrapper = page.locator('ion-modal .modal-wrapper');
443+
444+
await modal.evaluate((el: HTMLIonModalElement) => el.present());
445+
await ionModalDidPresent.next();
446+
447+
await expect(wrapper).toHaveAttribute('role', 'dialog');
448+
await expect(wrapper).toBeFocused();
449+
});
450+
451+
test('should focus the sheet modal wrapper on present', async ({ page }, testInfo) => {
452+
testInfo.annotations.push({
453+
type: 'issue',
454+
description: 'FW-7611',
455+
});
456+
await page.setContent(
457+
`
458+
<ion-modal initial-breakpoint="0.5" breakpoints="[0, 0.5, 1]">
459+
<ion-content>Sheet Modal Content</ion-content>
460+
</ion-modal>
461+
`,
462+
config
463+
);
464+
465+
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
466+
const modal = page.locator('ion-modal');
467+
const wrapper = page.locator('ion-modal .modal-wrapper');
468+
469+
await modal.evaluate((el: HTMLIonModalElement) => el.present());
470+
await ionModalDidPresent.next();
471+
472+
await expect(wrapper).toHaveAttribute('role', 'dialog');
473+
await expect(wrapper).toBeFocused();
474+
});
475+
476+
test('should focus the card modal wrapper on present', async ({ page }, testInfo) => {
477+
testInfo.annotations.push({
478+
type: 'issue',
479+
description: 'FW-7611',
480+
});
481+
await page.setContent(
482+
`
483+
<div class="ion-page">
484+
<ion-content>Root Content</ion-content>
485+
</div>
486+
<ion-modal>
487+
<ion-content>Card Modal Content</ion-content>
488+
</ion-modal>
489+
`,
490+
config
491+
);
492+
493+
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
494+
const modal = page.locator('ion-modal');
495+
const wrapper = page.locator('ion-modal .modal-wrapper');
496+
497+
await modal.evaluate((el: HTMLIonModalElement) => {
498+
el.presentingElement = document.querySelector<HTMLElement>('.ion-page')!;
499+
return el.present();
500+
});
501+
await ionModalDidPresent.next();
502+
503+
await expect(wrapper).toHaveAttribute('role', 'dialog');
504+
await expect(wrapper).toBeFocused();
505+
});
424506
});
425507
});

core/src/utils/test/playwright/drag-element.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ export const dragElementBy = async (
3535

3636
await page.mouse.down();
3737

38+
// Firefox treats focusable elements (tabindex="-1") as draggable text and
39+
// initiates text selection on mouse.down(). In Playwright, this selection
40+
// locks in before the gesture detector fires, blocking pointer events.
41+
// Clearing it here allows the drag gesture to proceed normally.
42+
await page.evaluate(() => window.getSelection()?.removeAllRanges());
43+
3844
// Drag the element.
3945
await moveElement(page, startX, startY, dragByX, dragByY);
4046

0 commit comments

Comments
 (0)