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.
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
…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
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)
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
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
gnbm and others added 2 commits
July 7, 2026 22:00…dals too The wrapper now carries tabindex=-1 for all modal types, not just the default modal, so present() moves focus to the element that declares the dialog role and TalkBack users can enter sheet and card modals. The previous exclusion existed because making the wrapper focusable made the 'sheet modal: drag events' e2e hang on Firefox. Root cause: Gecko treats an element with tabindex as a selection root - a pointer press inside it places a text caret, and a later press over that caret starts a native drag and drop session instead of delivering pointer events. The gesture then never receives the final pointerup. The sheet and swipe-to-close gestures now cancel any native dragstart while a drag is active, which prevents the hijack without affecting drag and drop when the modal is not being dragged. Adds wrapper-focus e2e coverage for sheet and card modals.
gnbm deleted the FW-7611_investigation branch
July 17, 2026 18:29ShaneK added a commit that referenced this pull request
Jul 23, 2026When a sheet modal defaults handleBehavior to "cycle" (the new default on major-9.0), the host becomes focusable and onModalFocus redirects focus to the drag handle. present() (from #31260) focuses the shadow-DOM dialog wrapper for screen readers, but that focus event is retargeted to the host at the shadow boundary, so onModalFocus saw ev.target === el and bounced focus onto the handle. The wrapper never kept focus, which failed the "focus the sheet modal wrapper on present" e2e on Firefox. Guard the redirect on el.shadowRoot.activeElement being null, which is true only when the host itself was focused directly (e.g. tabbing into the modal). When present() focuses the wrapper, activeElement is the wrapper, so the redirect is skipped and the dialog focus is left intact. Tabbing to the handle from outside still works.
Merged
2 tasks
pull Bot pushed a commit to goldtoad6/ionic-framework that referenced this pull request
Jul 23, 2026…1293) Issue number: internal --------- ## What is the current behavior? When a sheet modal uses `handleBehavior="cycle"`, the host element is focusable (`tabIndex=0`) and `onModalFocus` redirects focus to the drag handle whenever the host is focused. `present()` moves focus to the `.modal-wrapper` (the `role="dialog"` element) inside the shadow DOM, but that focus event is retargeted to the host, so `onModalFocus` sees `ev.target === el` and treats it as a direct host focus. It then bounces focus onto the handle. This is latent on the default `handleBehavior="none"` (the host isn't focusable, so the redirect never runs), but reproduces on any sheet modal that opts into `cycle`. ## What is the new behavior? `onModalFocus` now redirects to the handle only when the host itself was focused directly, detected by `el.shadowRoot?.activeElement` being `null`. When `present()` focuses the dialog wrapper, `activeElement` is the wrapper (not null), so the redirect is skipped and the dialog keeps focus. Tabbing into the modal from outside still lands on the handle, since the host is the focused element in that case. ## Does this introduce a breaking change? - [ ] Yes - [x] No ## Other information Related to the dialog focus work in ionic-team#31260 . That change is correct under the default `handleBehavior`, but the `cycle` path was not covered until now. Adds an e2e test to `utils/test/overlays/overlays.e2e.ts` that presents a sheet modal with `handle-behavior="cycle"` and asserts focus stays on the wrapper. The same fix ships on the major-9.0 sync (ionic-team#31290), where `cycle` is the default and this bug is hit on every sheet modal. Preview (sheet modal test page): - iOS: https://ionic-framework-git-fix-modal-focus-cycle-ionic1.vercel.app/src/components/modal/test/sheet?ionic:mode=ios - MD: https://ionic-framework-git-fix-modal-focus-cycle-ionic1.vercel.app/src/components/modal/test/sheet?ionic:mode=md
Merged
2 tasks
github-merge-queue Bot pushed a commit that referenced this pull request
Jul 28, 2026Issue number: internal --------- ## What is the current behavior? The shared `dragElementBy` Playwright helper (`core/src/utils/test/playwright/drag-element.ts`, used by every gesture e2e test) unconditionally runs `await page.evaluate(() => window.getSelection()?.removeAllRanges())` between `mouse.down()` and the drag movement. That line was added in #31260 to clear a Firefox-only text selection, but the extra `page.evaluate()` is an awaited round-trip that injects variable latency into the gesture path for all browsers. On WebKit that latency messes up the gesture's timing-derived `velocityX`, so the `item-sliding` safe-area screenshot tests in md mode settle a few pixels off and flake randomly. Since it lives on main, the flake has propagated to every other branch (next and major-9.0) and causes random test failures in PRs and nightlies. ## What is the new behavior? The selection clear now only runs on Firefox, which is the only engine that needs it (the existing comment already scoped the problem to Firefox). Chromium and WebKit skip the extra round-trip and get the same drag timing they had before #31260, so the item settles deterministically and the flake goes away. This matches the `browserType().name() === 'webkit'` gate already used elsewhere in the same file. ## Does this introduce a breaking change? - [ ] Yes - [x] No ## Other information <!-- Any other information that is important to this PR such as screenshots of how the component looks before and after the change. --> Regression was introduced by #31260 (the `removeAllRanges` line, not the modal a11y change itself). Verified by running in docker locally several times.