From ce1dc72982f33fd66d81adec12de123a29cb7b78 Mon Sep 17 00:00:00 2001 From: gnbm Date: Wed, 1 Jul 2026 19:44:24 +0100 Subject: [PATCH 1/7] fix(overlays): focus the dialog wrapper, not the role-less host, on present IONIC-91 / FW-7611: Android TalkBack users could not navigate into or interact with modal content after opening it. `present()` in overlays.ts moved DOM focus to `overlay.el` (the shadow host) when no descendant was already focused. Every overlay built on this shared utility (modal, alert, action-sheet, loading, popover) declares `role="dialog"`/`aria-modal` on an inner `.ion-overlay-wrapper` element inside its shadow root, never on the host itself. Focusing the host therefore handed assistive tech a focus target with no accessible role or name, so TalkBack's accessibility-focus never landed on the actual dialog and its linear navigation cursor never entered the overlay's content. Focus the `.ion-overlay-wrapper` instead (falling back to the host if none exists), and make modal's wrapper focusable via tabIndex={-1} so the retargeted focus() call actually takes effect. --- core/src/components/modal/modal.tsx | 7 ++++++ .../components/modal/test/a11y/modal.e2e.ts | 25 +++++++++++++++++++ core/src/utils/overlays.ts | 17 ++++++++++++- 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/core/src/components/modal/modal.tsx b/core/src/components/modal/modal.tsx index 0ea66ce8cfe..d109c1905a6 100644 --- a/core/src/components/modal/modal.tsx +++ b/core/src/components/modal/modal.tsx @@ -1644,10 +1644,17 @@ export class Modal implements ComponentInterface, OverlayInterface { same element. They must also be set inside the shadow DOM otherwise ion-button will not be highlighted when using VoiceOver: https://bugs.webkit.org/show_bug.cgi?id=247134 + + tabIndex={-1} makes this element (rather than the role-less + host) the thing that `present()` in overlays.ts focuses when + the modal opens, so assistive technologies get a focus target + that actually carries the dialog's role/label. It is not part + of the Tab order since -1 is never Tab-reachable. */ role="dialog" {...inheritedAttributes} aria-modal="true" + tabIndex={-1} class="modal-wrapper ion-overlay-wrapper" part="content" ref={(el) => (this.wrapperEl = el)} diff --git a/core/src/components/modal/test/a11y/modal.e2e.ts b/core/src/components/modal/test/a11y/modal.e2e.ts index 5aef2cda0d8..98e88b64f1a 100644 --- a/core/src/components/modal/test/a11y/modal.e2e.ts +++ b/core/src/components/modal/test/a11y/modal.e2e.ts @@ -19,5 +19,30 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => const results = await new AxeBuilder({ page }).analyze(); expect(results.violations).toEqual([]); }); + + test('should move focus to the dialog wrapper, not the role-less host, on present', async ({ page }) => { + /** + * `role="dialog"`/`aria-modal` live on `.modal-wrapper` inside the + * shadow root, not on the `ion-modal` host. Assistive technologies + * (e.g. Android TalkBack) rely on the focus target itself carrying + * that role/label to know a dialog opened. If focus lands on the + * host instead, TalkBack has no accessible dialog to land on and + * users cannot navigate into the modal's content (IONIC-91 / FW-7611). + */ + await page.goto(`/src/components/modal/test/a11y`, config); + + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + const button = page.locator('#open-modal'); + + await button.click(); + await ionModalDidPresent.next(); + + const focusedRole = await page.evaluate(() => { + const modal = document.querySelector('ion-modal')!; + return modal.shadowRoot?.activeElement?.getAttribute('role') ?? null; + }); + + expect(focusedRole).toBe('dialog'); + }); }); }); diff --git a/core/src/utils/overlays.ts b/core/src/utils/overlays.ts index 59b341a7d52..47ea28e324f 100644 --- a/core/src/utils/overlays.ts +++ b/core/src/utils/overlays.ts @@ -593,7 +593,22 @@ export const present = async ( * to the overlay container. */ if (overlay.keyboardClose && (document.activeElement === null || !overlay.el.contains(document.activeElement))) { - overlay.el.focus(); + /** + * `role="dialog"`/`role="alertdialog"`, `aria-modal`, and the overlay's + * accessible label all live on the `.ion-overlay-wrapper` element inside + * the overlay's shadow root (see modal.tsx, alert.tsx, action-sheet.tsx, + * loading.tsx, popover.tsx) -- never on the host element itself. + * + * Focusing `overlay.el` (the host) instead of this wrapper hands + * assistive technologies a focus target with no accessible role or + * name. Screen readers that rely on the focus/accessibility-focus + * event to know a dialog opened (e.g. Android TalkBack, which does + * not treat `aria-modal` alone as a navigation boundary) get no + * usable landing point, so their linear navigation cursor never + * actually enters the overlay's content. + */ + const overlayWrapper = getElementRoot(overlay.el).querySelector('.ion-overlay-wrapper'); + (overlayWrapper ?? overlay.el).focus(); } /** From deeca028076eb261863160d175a257fb79877566 Mon Sep 17 00:00:00 2001 From: gnbm Date: Wed, 1 Jul 2026 19:58:38 +0100 Subject: [PATCH 2/7] fix(overlays): only redirect present() focus to a wrapper that is actually focusable Alert declares role="alertdialog" and tabindex="0" on .alert-wrapper, so redirecting focus there (as done for modal) is correct and already works. Action-sheet, loading, and popover keep role/aria-modal on the host and never gave .ion-overlay-wrapper a tabindex, so it was never meant to be focused directly. The previous version of this fix called .focus() on that non-focusable wrapper unconditionally, which silently failed and left focus on instead of the host -- a regression against their prior, correct behavior. Guard the redirect on the wrapper actually declaring a tabindex so only overlays authored to use it are affected; others keep focusing the host exactly as before. --- core/src/utils/overlays.ts | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/core/src/utils/overlays.ts b/core/src/utils/overlays.ts index 47ea28e324f..50f9798ac41 100644 --- a/core/src/utils/overlays.ts +++ b/core/src/utils/overlays.ts @@ -594,21 +594,27 @@ export const present = async ( */ if (overlay.keyboardClose && (document.activeElement === null || !overlay.el.contains(document.activeElement))) { /** - * `role="dialog"`/`role="alertdialog"`, `aria-modal`, and the overlay's - * accessible label all live on the `.ion-overlay-wrapper` element inside - * the overlay's shadow root (see modal.tsx, alert.tsx, action-sheet.tsx, - * loading.tsx, popover.tsx) -- never on the host element itself. + * Some overlays (modal, alert) declare `role="dialog"`/`alertdialog`, + * `aria-modal`, and the accessible label on the `.ion-overlay-wrapper` + * element inside the overlay rather than on the host itself. Focusing + * `overlay.el` (the host) in that case hands assistive technologies a + * focus target with no accessible role or name -- screen readers that + * rely on the focus/accessibility-focus event to know a dialog opened + * (e.g. Android TalkBack, which does not treat `aria-modal` alone as a + * navigation boundary) get no usable landing point, so their linear + * navigation cursor never actually enters the overlay's content. * - * Focusing `overlay.el` (the host) instead of this wrapper hands - * assistive technologies a focus target with no accessible role or - * name. Screen readers that rely on the focus/accessibility-focus - * event to know a dialog opened (e.g. Android TalkBack, which does - * not treat `aria-modal` alone as a navigation boundary) get no - * usable landing point, so their linear navigation cursor never - * actually enters the overlay's content. + * Other overlays (action-sheet, loading, popover) keep those + * attributes on the host and leave `.ion-overlay-wrapper` without a + * `tabindex`, in which case it is not focusable and the host remains + * the correct target. Only redirect to the wrapper when it actually + * declares a `tabindex` (i.e. the component authored it to be + * focusable) so overlays that already focus the host correctly are + * left untouched. */ const overlayWrapper = getElementRoot(overlay.el).querySelector('.ion-overlay-wrapper'); - (overlayWrapper ?? overlay.el).focus(); + const focusTarget = overlayWrapper?.hasAttribute('tabindex') ? overlayWrapper : overlay.el; + focusTarget.focus(); } /** From 58782efe3619eb09f289ea05ff6ab014e04ac299 Mon Sep 17 00:00:00 2001 From: gnbm Date: Wed, 1 Jul 2026 20:42:35 +0100 Subject: [PATCH 3/7] fix(popover): add default role="dialog" so aria-modal is not a no-op Popover set aria-modal="true" on the host but declared no role at all. Per the ARIA spec, aria-modal is only defined on elements with role dialog or alertdialog, so assistive technologies were silently ignoring it -- popovers were never actually exposed as modal to screen readers. ion-select already declares aria-haspopup="dialog" on its trigger when using the popover interface, so this also fixes a pre-existing mismatch between what select promised and what the popover actually exposed. Default to role="dialog", placed before the htmlAttributes spread so consumers can still override it (e.g. role="menu") the same way modal and alert allow. --- core/src/components/popover/popover.tsx | 10 +++++ .../components/popover/test/a11y/index.html | 35 ++++++++++++++++++ .../popover/test/a11y/popover.e2e.ts | 37 +++++++++++++++++++ 3 files changed, 82 insertions(+) create mode 100644 core/src/components/popover/test/a11y/index.html create mode 100644 core/src/components/popover/test/a11y/popover.e2e.ts diff --git a/core/src/components/popover/popover.tsx b/core/src/components/popover/popover.tsx index af0e613297d..7f9911eb7f5 100644 --- a/core/src/components/popover/popover.tsx +++ b/core/src/components/popover/popover.tsx @@ -731,6 +731,16 @@ export class Popover implements ComponentInterface, PopoverInterface { return ( + + + + Popover - a11y + + + + + + + + +
+

Popover - a11y

+ + + + Popover Content + + + + + Menu Popover Content + +
+ + + + diff --git a/core/src/components/popover/test/a11y/popover.e2e.ts b/core/src/components/popover/test/a11y/popover.e2e.ts new file mode 100644 index 00000000000..d1ed7a8e503 --- /dev/null +++ b/core/src/components/popover/test/a11y/popover.e2e.ts @@ -0,0 +1,37 @@ +import { expect } from '@playwright/test'; +import { configs, test } from '@utils/test/playwright'; + +configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('popover: a11y'), () => { + test('should have role="dialog" and aria-modal by default', async ({ page }) => { + /** + * `aria-modal` is only meaningful on an element with role `dialog` + * or `alertdialog`; without a role, assistive technologies ignore + * it. Popover previously set `aria-modal="true"` with no role at + * all, so it was silently non-functional for screen readers. + */ + await page.goto(`/src/components/popover/test/a11y`, config); + + const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent'); + const popover = page.locator('#basic-popover'); + + await page.locator('#open-popover').click(); + await ionPopoverDidPresent.next(); + + await expect(popover).toHaveAttribute('role', 'dialog'); + await expect(popover).toHaveAttribute('aria-modal', 'true'); + }); + + test('should allow htmlAttributes to override the default role', async ({ page }) => { + await page.goto(`/src/components/popover/test/a11y`, config); + + const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent'); + const popover = page.locator('#menu-popover'); + + await page.locator('#open-menu-popover').click(); + await ionPopoverDidPresent.next(); + + await expect(popover).toHaveAttribute('role', 'menu'); + }); + }); +}); From 5b25d956131343f057693e6b1ffddd45f7225f08 Mon Sep 17 00:00:00 2001 From: gnbm Date: Wed, 1 Jul 2026 23:39:19 +0100 Subject: [PATCH 4/7] fix(modal): scope dialog-wrapper focus to default modals; keep host focus for sheet/card The initial fix made the modal's `.modal-wrapper` (which carries role="dialog") the focus target on present so Android TalkBack can enter the dialog. For sheet and iOS card modals that wrapper is also the drag-gesture surface: leaving focus on it interferes with pointer-drag recognition (observed as the sheet "drag events" e2e timing out on Firefox). Real users are unaffected (the gesture works once focus settles), but it is a genuine behavior change and broke a merge-gating test. Scope the wrapper `tabIndex={-1}` to default modals only. Sheet and card modals keep focusing the host exactly as before, so their drag gestures are untouched, while the reported IONIC-91 case (default modal) still gets the accessible focus target. Also focus the wrapper with `preventScroll` so the a11y focus move never scrolls the viewport. --- core/src/components/modal/modal.tsx | 8 +++++++- core/src/utils/overlays.ts | 6 +++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/core/src/components/modal/modal.tsx b/core/src/components/modal/modal.tsx index d109c1905a6..590fc98dc90 100644 --- a/core/src/components/modal/modal.tsx +++ b/core/src/components/modal/modal.tsx @@ -1650,11 +1650,17 @@ export class Modal implements ComponentInterface, OverlayInterface { the modal opens, so assistive technologies get a focus target that actually carries the dialog's role/label. It is not part of the Tab order since -1 is never Tab-reachable. + + This is only applied to default modals. For sheet and iOS card + modals the wrapper doubles as the drag-gesture surface, and + leaving focus on that surface interferes with the pointer + gesture (most visibly on Firefox). Those modals keep focusing + the host as before. See FW-7611. */ role="dialog" {...inheritedAttributes} aria-modal="true" - tabIndex={-1} + tabIndex={!isCardModal && !isSheetModal ? -1 : undefined} class="modal-wrapper ion-overlay-wrapper" part="content" ref={(el) => (this.wrapperEl = el)} diff --git a/core/src/utils/overlays.ts b/core/src/utils/overlays.ts index 50f9798ac41..a765ad60ccf 100644 --- a/core/src/utils/overlays.ts +++ b/core/src/utils/overlays.ts @@ -611,10 +611,14 @@ export const present = async ( * declares a `tabindex` (i.e. the component authored it to be * focusable) so overlays that already focus the host correctly are * left untouched. + * + * `preventScroll` keeps this purely an accessibility-focus move: we + * do not want focusing the wrapper to scroll it into view, since the + * host was already the scroll-neutral, full-viewport container. */ const overlayWrapper = getElementRoot(overlay.el).querySelector('.ion-overlay-wrapper'); const focusTarget = overlayWrapper?.hasAttribute('tabindex') ? overlayWrapper : overlay.el; - focusTarget.focus(); + focusTarget.focus({ preventScroll: true }); } /** From 7acf4ebeed066e8e8eb2c5b4e0914a6c71855316 Mon Sep 17 00:00:00 2001 From: gnbm Date: Thu, 2 Jul 2026 00:19:51 +0100 Subject: [PATCH 5/7] revert(popover): drop default role="dialog"; keep PR scoped to modal fix Review + a new axe scan showed that defaulting role="dialog" on ion-popover makes every *unlabeled* popover fail axe's serious `aria-dialog-name` rule (an ARIA dialog must have an accessible name) -- a consumer-facing regression. Revert the popover role change (and its tests) so this PR stays scoped to the verified modal (IONIC-91) focus fix. Popover's missing role can be revisited with a proper accessible- name strategy. Also tighten the modal a11y test comment to match the surrounding concise style. --- .../components/modal/test/a11y/modal.e2e.ts | 11 ++---- core/src/components/popover/popover.tsx | 10 ----- .../components/popover/test/a11y/index.html | 35 ------------------ .../popover/test/a11y/popover.e2e.ts | 37 ------------------- 4 files changed, 3 insertions(+), 90 deletions(-) delete mode 100644 core/src/components/popover/test/a11y/index.html delete mode 100644 core/src/components/popover/test/a11y/popover.e2e.ts diff --git a/core/src/components/modal/test/a11y/modal.e2e.ts b/core/src/components/modal/test/a11y/modal.e2e.ts index 98e88b64f1a..ac92c08d728 100644 --- a/core/src/components/modal/test/a11y/modal.e2e.ts +++ b/core/src/components/modal/test/a11y/modal.e2e.ts @@ -20,15 +20,10 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => expect(results.violations).toEqual([]); }); + // role="dialog" lives on .modal-wrapper, not the host, so focus must land + // there on present for screen readers (e.g. TalkBack) to enter the dialog + // (IONIC-91 / FW-7611). test('should move focus to the dialog wrapper, not the role-less host, on present', async ({ page }) => { - /** - * `role="dialog"`/`aria-modal` live on `.modal-wrapper` inside the - * shadow root, not on the `ion-modal` host. Assistive technologies - * (e.g. Android TalkBack) rely on the focus target itself carrying - * that role/label to know a dialog opened. If focus lands on the - * host instead, TalkBack has no accessible dialog to land on and - * users cannot navigate into the modal's content (IONIC-91 / FW-7611). - */ await page.goto(`/src/components/modal/test/a11y`, config); const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); diff --git a/core/src/components/popover/popover.tsx b/core/src/components/popover/popover.tsx index 7f9911eb7f5..af0e613297d 100644 --- a/core/src/components/popover/popover.tsx +++ b/core/src/components/popover/popover.tsx @@ -731,16 +731,6 @@ export class Popover implements ComponentInterface, PopoverInterface { return ( - - - - Popover - a11y - - - - - - - - -
-

Popover - a11y

- - - - Popover Content - - - - - Menu Popover Content - -
- - - - diff --git a/core/src/components/popover/test/a11y/popover.e2e.ts b/core/src/components/popover/test/a11y/popover.e2e.ts deleted file mode 100644 index d1ed7a8e503..00000000000 --- a/core/src/components/popover/test/a11y/popover.e2e.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { expect } from '@playwright/test'; -import { configs, test } from '@utils/test/playwright'; - -configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { - test.describe(title('popover: a11y'), () => { - test('should have role="dialog" and aria-modal by default', async ({ page }) => { - /** - * `aria-modal` is only meaningful on an element with role `dialog` - * or `alertdialog`; without a role, assistive technologies ignore - * it. Popover previously set `aria-modal="true"` with no role at - * all, so it was silently non-functional for screen readers. - */ - await page.goto(`/src/components/popover/test/a11y`, config); - - const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent'); - const popover = page.locator('#basic-popover'); - - await page.locator('#open-popover').click(); - await ionPopoverDidPresent.next(); - - await expect(popover).toHaveAttribute('role', 'dialog'); - await expect(popover).toHaveAttribute('aria-modal', 'true'); - }); - - test('should allow htmlAttributes to override the default role', async ({ page }) => { - await page.goto(`/src/components/popover/test/a11y`, config); - - const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent'); - const popover = page.locator('#menu-popover'); - - await page.locator('#open-menu-popover').click(); - await ionPopoverDidPresent.next(); - - await expect(popover).toHaveAttribute('role', 'menu'); - }); - }); -}); From b98cbb3376424a851a9ba74070a39cc401c2810d Mon Sep 17 00:00:00 2001 From: gnbm Date: Thu, 2 Jul 2026 00:29:24 +0100 Subject: [PATCH 6/7] test(modal): use idiomatic toBeFocused for the a11y focus assertion Match the focus-assertion style used across the modal suite (expect(locator).toBeFocused()) and the existing `.modal-wrapper` locator in this file, instead of a manual page.evaluate over shadowRoot.activeElement. --- core/src/components/modal/test/a11y/modal.e2e.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/core/src/components/modal/test/a11y/modal.e2e.ts b/core/src/components/modal/test/a11y/modal.e2e.ts index ac92c08d728..1734eb258cf 100644 --- a/core/src/components/modal/test/a11y/modal.e2e.ts +++ b/core/src/components/modal/test/a11y/modal.e2e.ts @@ -28,16 +28,13 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); const button = page.locator('#open-modal'); + const wrapper = page.locator('ion-modal .modal-wrapper'); await button.click(); await ionModalDidPresent.next(); - const focusedRole = await page.evaluate(() => { - const modal = document.querySelector('ion-modal')!; - return modal.shadowRoot?.activeElement?.getAttribute('role') ?? null; - }); - - expect(focusedRole).toBe('dialog'); + await expect(wrapper).toHaveAttribute('role', 'dialog'); + await expect(wrapper).toBeFocused(); }); }); }); From cbca643bbf7bfb0f33d2b15ef8962563232f69a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gon=C3=A7alo=20M=2E?= Date: Thu, 2 Jul 2026 07:26:28 +0100 Subject: [PATCH 7/7] Update comment in modal --- core/src/components/modal/modal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/components/modal/modal.tsx b/core/src/components/modal/modal.tsx index 590fc98dc90..1f30e2e439c 100644 --- a/core/src/components/modal/modal.tsx +++ b/core/src/components/modal/modal.tsx @@ -1655,7 +1655,7 @@ export class Modal implements ComponentInterface, OverlayInterface { modals the wrapper doubles as the drag-gesture surface, and leaving focus on that surface interferes with the pointer gesture (most visibly on Firefox). Those modals keep focusing - the host as before. See FW-7611. + the host as before. */ role="dialog" {...inheritedAttributes}