Skip to content

fix(react-aria): detect screen reader pointer events as virtual modality#10316

Open
AKnassa wants to merge 3 commits into
adobe:mainfrom
AKnassa:fix/interaction-modality-screen-reader-chromium
Open

fix(react-aria): detect screen reader pointer events as virtual modality#10316
AKnassa wants to merge 3 commits into
adobe:mainfrom
AKnassa:fix/interaction-modality-screen-reader-chromium

Conversation

@AKnassa

@AKnassa AKnassa commented Jul 9, 2026

Copy link
Copy Markdown

Closes #10310

✅ Pull Request Checklist:

  • Included link to corresponding React Spectrum GitHub Issue.
  • Added/updated unit tests and storybook for this change (for new code or code which already has tests).
  • Filled out test instructions.
  • Updated documentation (if it already exists for this component).
  • Looked at the Accessibility Practices for this feature - Aria Practices

🐛 Bug

useInteractionModality() reports pointer when a JAWS or NVDA user activates a control in Chromium. Screen reader users are treated as mouse users, so focus rings are suppressed and modality-dependent behavior misfires.

🔍 Root cause

handlePointerEvent in useFocusVisible.ts set currentModality = 'pointer' for every pointerdown without consulting isVirtualPointerEvent. Chromium's screen-reader-synthesized pointerdown reports pointerType: 'mouse' — not the empty string JAWS/Firefox and VoiceOver use — so isVirtualClick on the trailing click can't catch it either. Firefox and VoiceOver only work by accident: they emit no pointerdown at all.

This makes useFocusVisible the only hook that never adopted the virtual-pointer heuristic the rest of the library has used since #3524 (usePress, useDrag, DragManager, useRangeCalendar).

✅ Fix

Reuse the existing isVirtualPointerEvent helper. Per the Pointer Events spec, a UA MUST report width/height of 1 for inputs without contact geometry, so a zero-size pointerdown cannot originate from a real mouse. Also broadcasts the resolved modality rather than the literal 'pointer'.

📝 Test Instructions:

Automated: yarn jest packages/react-aria/test/interactions/useFocusVisible.test.js — twelve tests in the useInteractionModality with virtual (screen reader) pointer events block. Three prove virtual detection (including subscriber broadcast and that the paired pointerup doesn't reset modality), plus isFocusVisible() staying true after a screen-reader pointerdown; the rest guard real mouse, touch, pen, Android TalkBack, Firefox/VoiceOver empty-pointerType click, keyboard, and recovery to pointer on a real mouse move. Full react-aria (1163) and react-aria-components (1522) suites pass.

Manual (Windows + JAWS or NVDA + Chrome): start the screen reader, activate any React Aria button with Enter, and observe useInteractionModality() / getInteractionModality() now reports virtual instead of pointer.

⚠️ Verification needed

I don't have access to JAWS or NVDA. The fix assumes Chromium's synthesized pointerdown reports width: 0, height: 0. This is inferred from (a) the spec forbidding 0 for real pointers, and (b) usePress having relied on this exact signal for JAWS/NVDA browse mode since #3524 — if the geometry were different, that shipped code would already be broken. A maintainer or @jesusgalhub confirming the geometry on real hardware would close this gap.

📋 Out of scope (follow-ups)

  • handleClickEvent sets currentModality = 'virtual' but never calls triggerChangeHandlers, so the Firefox/VoiceOver virtual click never re-broadcasts to subscribers. Latent, separate.
  • The zero-geometry heuristic has drifted into several spellings across the codebase; unifying them belongs in its own PR.

🧢 Your Project:

Individual contributor

AKnassa added 2 commits July 8, 2026 20:42
JAWS and NVDA on Chromium synthesize a pointerdown whose pointerType is
'mouse' rather than the empty string other screen readers report, so
handlePointerEvent classified screen reader users as 'pointer'. The event
is only distinguishable from a real mouse by its zero contact geometry,
which is the signal usePress has used since adobe#3524; useFocusVisible never
adopted it. Per the Pointer Events spec a real mouse must report a width
and height of 1, so a zero-size pointerdown cannot originate from one.

Also broadcast the resolved modality to change handlers instead of the
literal 'pointer'.

Closes adobe#10310
…al modality

Asserts the user-visible outcome of the screen reader fix (isFocusVisible
stays true after a zero-geometry pointerdown), guards pen input against
misclassification, and pins that a real mouse move returns modality to
pointer after a virtual interaction.
@AKnassa AKnassa marked this pull request as ready for review July 9, 2026 01:21
Comment on lines +559 to +560
act(() => {
fireEvent(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fireEvent already wraps everything in an act, why the extra one?

@AKnassa AKnassa Jul 9, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, you're right, RTL's fireEvent is already act-wrapped. Removed in 61301e8; I'd copied the pattern from the older tests in this file, and left those untouched to keep the diff scoped. Happy to clean them up here too if you'd prefer.

@jesusgalhub

Copy link
Copy Markdown

@AKnassa
A coworker of me has access to JAWS. We did this screenshot interacting with a button. The event comes with width and height set to 1
image

@jesusgalhub

Copy link
Copy Markdown

@AKnassa A coworker of me has access to JAWS. We did this screenshot interacting with a button. The event comes with width and height set to 1 image

I clicked without any screen reader in the same button and the event comes with the same width and height
https://codepen.io/editor/jesusgonzaleza/pen/019f3c39-f927-7d3c-823e-4328eff26b40
2026-07-09_08-22-51

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

useInteractionModality detects JAWS and NVDA as mouse in Chromium

3 participants