Skip to content

fix(modal): focus the dialog wrapper on present so TalkBack can enter default modals#31260

Open
gnbm wants to merge 7 commits into
mainfrom
FW-7611_investigation
Open

fix(modal): focus the dialog wrapper on present so TalkBack can enter default modals#31260
gnbm wants to merge 7 commits into
mainfrom
FW-7611_investigation

Conversation

@gnbm

@gnbm gnbm commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

What is the current behavior?

When an ion-modal is opened with Android TalkBack enabled, the screen-reader user cannot navigate into or interact with any of the modal's content. Reported against a stock Ionic Angular Quickstart + the docs modal example (Pixel 9 / Android 16 / TalkBack 17.0.1).

Root cause: present() in core/src/utils/overlays.ts moves DOM focus to overlay.el (the shadow host) when no descendant is already focused. ion-modal declares role="dialog", aria-modal, and its accessible label on the inner .ion-overlay-wrapper element inside the shadow root — not on the host (the role must live in the shadow DOM for VoiceOver; see the existing note in modal.tsx). Focusing the role-less host 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 — TalkBack in particular, which does not treat aria-modal alone as a navigation boundary — get no usable landing point, so their linear-navigation cursor never enters the modal's content.

What is the new behavior?

  • overlays.ts present() now focuses the .ion-overlay-wrapper (which carries the dialog role/label) instead of the role-less host — but only when that wrapper actually declares a tabindex (i.e. the component authored it to be focusable). Overlays that keep the role on the host and leave the wrapper non-focusable (action-sheet, loading, popover) continue to focus the host exactly as before. Focus is moved with preventScroll so it never scrolls the viewport.
  • ion-modal wrapper gains tabIndex={-1} so the retargeted focus() lands on the role="dialog" element — scoped to default modals. For sheet and iOS card modals the wrapper doubles as the drag-gesture surface, and leaving focus on it interferes with pointer-drag recognition (it made the existing sheet modal: drag events e2e time out on Firefox). Those modals keep focusing the host as before, so their gestures are unaffected. Improving programmatic focus for sheet/card modals is left as follow-up.

Scope notes (verified during review, intentionally not included here)

  • ion-alert already manages its own focus in present().then() (it focuses the single action button, or the .alert-wrapper otherwise) and is unaffected by this change — it was already accessible, so no alert code/tests are touched.
  • ion-popover has a pre-existing aria-modal with no role. Adding a default role="dialog" was explored but reverted: an axe scan showed it makes every unlabeled popover fail the serious aria-dialog-name rule (a dialog must have an accessible name), a consumer-facing regression. It needs a dedicated accessible-name strategy and is out of scope here.

Tests

  • Run the project on an Android device
  • Enable TalkBack
  • Open the modal
  • Check that we can reach content

Does this introduce a breaking change?

  • Yes
  • No

No public API changes. Focus targeting is an internal a11y detail.

gnbm added 3 commits July 1, 2026 20:44
…resent

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.
…ually 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 <body> 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.
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.
@vercel

vercel Bot commented Jul 1, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
ionic-framework Ready Ready Preview, Comment Jul 2, 2026 6:31am

Request Review

@github-actions github-actions Bot added the package: core @ionic/core package label Jul 1, 2026
@gnbm gnbm changed the title fix(overlays): focus dialog wrapper on present so TalkBack can enter overlays (FW-7611) fix(overlays): focus dialog wrapper on present so TalkBack can enter overlays Jul 1, 2026
…ocus 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.
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.
@gnbm gnbm changed the title fix(overlays): focus dialog wrapper on present so TalkBack can enter overlays fix(modal): focus the dialog wrapper on present so TalkBack can enter default modals (FW-7611) Jul 1, 2026
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.
@gnbm gnbm changed the title fix(modal): focus the dialog wrapper on present so TalkBack can enter default modals (FW-7611) fix(modal): focus the dialog wrapper on present so TalkBack can enter default modals Jul 1, 2026
Comment thread core/src/components/modal/modal.tsx Outdated
@gnbm gnbm marked this pull request as ready for review July 2, 2026 11:14
@gnbm gnbm requested a review from a team as a code owner July 2, 2026 11:14
@gnbm gnbm requested review from BenOsodrac and removed request for BenOsodrac July 2, 2026 11:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

package: core @ionic/core package

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants