From bf61cd6a5592b4fb98e56e2557fbda17c19323bf Mon Sep 17 00:00:00 2001 From: AK <144495202+AKnassa@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:42:07 -0400 Subject: [PATCH 1/3] fix(react-aria): detect screen reader pointer events as virtual modality 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 #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 #10310 --- .../src/interactions/useFocusVisible.ts | 18 +- .../test/interactions/useFocusVisible.test.js | 161 +++++++++++++++++- 2 files changed, 174 insertions(+), 5 deletions(-) diff --git a/packages/react-aria/src/interactions/useFocusVisible.ts b/packages/react-aria/src/interactions/useFocusVisible.ts index 9e992f5334f..5c27e9710e8 100644 --- a/packages/react-aria/src/interactions/useFocusVisible.ts +++ b/packages/react-aria/src/interactions/useFocusVisible.ts @@ -20,7 +20,7 @@ import {getActiveElement, getEventTarget} from '../utils/shadowdom/DOMFunctions' import {getOwnerDocument, getOwnerWindow} from '../utils/domHelpers'; import {ignoreFocusEvent} from './utils'; import {isMac} from '../utils/platform'; -import {isVirtualClick} from '../utils/isVirtualEvent'; +import {isVirtualClick, isVirtualPointerEvent} from '../utils/isVirtualEvent'; import {openLink} from '../utils/openLink'; import {PointerType} from '@react-types/shared'; import {useEffect, useState} from 'react'; @@ -93,11 +93,21 @@ function handleKeyboardEvent(e: KeyboardEvent) { } function handlePointerEvent(e: PointerEvent | MouseEvent) { - currentModality = 'pointer'; - currentPointerType = 'pointerType' in e ? (e.pointerType as PointerType) : 'mouse'; + // JAWS and NVDA on Chromium synthesize a pointer event with pointerType 'mouse' rather than + // the empty string other screen readers use, so it is only distinguishable from a real mouse + // by its zero contact geometry. This is the same signal usePress relies on. The pointerType + // check keeps the mousedown fallback path, which has no width or height, out of this branch. + if ('pointerType' in e && isVirtualPointerEvent(e)) { + currentModality = 'virtual'; + currentPointerType = 'virtual'; + } else { + currentModality = 'pointer'; + currentPointerType = 'pointerType' in e ? (e.pointerType as PointerType) : 'mouse'; + } + if (e.type === 'mousedown' || e.type === 'pointerdown') { hasEventBeforeFocus = true; - triggerChangeHandlers('pointer', e); + triggerChangeHandlers(currentModality, e); } } diff --git a/packages/react-aria/test/interactions/useFocusVisible.test.js b/packages/react-aria/test/interactions/useFocusVisible.test.js index d469996ee35..580caddb73c 100644 --- a/packages/react-aria/test/interactions/useFocusVisible.test.js +++ b/packages/react-aria/test/interactions/useFocusVisible.test.js @@ -20,16 +20,25 @@ import { } from '@react-spectrum/test-utils-internal'; import { addWindowFocusTracking, + getInteractionModality, + setInteractionModality, useFocusVisible, - useFocusVisibleListener + useFocusVisibleListener, + useInteractionModality } from '../../src/interactions/useFocusVisible'; import {changeHandlers, hasSetupGlobalListeners} from '../../src/interactions/useFocusVisible'; import {mergeProps} from '../../src/utils/mergeProps'; +import * as platform from '../../src/utils/platform'; import React from 'react'; import {useButton} from '../../src/button/useButton'; import {useFocusRing} from '../../src/focus/useFocusRing'; import userEvent from '@testing-library/user-event'; +jest.mock('../../src/utils/platform', () => ({ + ...jest.requireActual('../../src/utils/platform'), + isAndroid: jest.fn(() => false) +})); + function Example(props) { const {isFocusVisible} = useFocusVisible(); return ( @@ -513,3 +522,153 @@ describe('useFocusVisibleListener', function () { }); }); }); + +// Screen readers synthesize pointer events with zero contact geometry. Build the events +// by hand rather than via the FakePointerEvent shim so width/height/pressure read back +// exactly as written (the shim has no pressure getter, and returns undefined when unset). +function pointerEvent(type, opts) { + let evt = new Event(type, {bubbles: true, cancelable: true, composed: true}); + Object.assign(evt, {button: 0, width: 1, height: 1}, opts); + return evt; +} + +describe('useInteractionModality with virtual (screen reader) pointer events', function () { + let cleanup; + + beforeAll(() => { + // The module registered the mousedown fallback listeners at import time, since jsdom has + // no PointerEvent. Remove them while PointerEvent is still undefined, then define it and + // re-register so the real pointerdown/pointermove/pointerup listeners are attached. + addWindowFocusTracking()(); + global.PointerEvent = class FakePointerEvent extends MouseEvent {}; + cleanup = addWindowFocusTracking(); + }); + + afterAll(() => { + cleanup(); + delete global.PointerEvent; + addWindowFocusTracking(); + }); + + beforeEach(() => { + setInteractionModality('keyboard'); + }); + + it('reports virtual modality for a JAWS/NVDA Chromium screen reader pointerdown', function () { + act(() => { + fireEvent( + document.body, + pointerEvent('pointerdown', {pointerType: 'mouse', width: 0, height: 0}) + ); + }); + + expect(getInteractionModality()).toBe('virtual'); + }); + + it('notifies useInteractionModality subscribers of virtual modality on a screen reader pointerdown', function () { + let {result} = renderHook(() => useInteractionModality()); + + act(() => { + fireEvent( + document.body, + pointerEvent('pointerdown', {pointerType: 'mouse', width: 0, height: 0}) + ); + }); + + expect(result.current).toBe('virtual'); + }); + + it('stays virtual through the pointerup paired with a screen reader pointerdown', function () { + act(() => { + fireEvent( + document.body, + pointerEvent('pointerdown', {pointerType: 'mouse', width: 0, height: 0}) + ); + fireEvent( + document.body, + pointerEvent('pointerup', {pointerType: 'mouse', width: 0, height: 0}) + ); + }); + + expect(getInteractionModality()).toBe('virtual'); + }); + + it('reports virtual modality for an Android TalkBack pointerdown', function () { + platform.isAndroid.mockReturnValue(true); + + act(() => { + fireEvent( + document.body, + pointerEvent('pointerdown', { + pointerType: 'mouse', + width: 1, + height: 1, + pressure: 0, + detail: 0 + }) + ); + }); + + expect(getInteractionModality()).toBe('virtual'); + platform.isAndroid.mockReturnValue(false); + }); + + it('reports pointer modality for a real mouse pointerdown', function () { + act(() => { + fireEvent( + document.body, + pointerEvent('pointerdown', {pointerType: 'mouse', width: 1, height: 1}) + ); + }); + + expect(getInteractionModality()).toBe('pointer'); + }); + + it('notifies useInteractionModality subscribers of pointer modality on a real mouse pointerdown', function () { + let {result} = renderHook(() => useInteractionModality()); + + act(() => { + fireEvent( + document.body, + pointerEvent('pointerdown', {pointerType: 'mouse', width: 1, height: 1}) + ); + }); + + expect(result.current).toBe('pointer'); + }); + + it('reports pointer modality for a real touch pointerdown', function () { + act(() => { + fireEvent( + document.body, + pointerEvent('pointerdown', {pointerType: 'touch', width: 20, height: 20}) + ); + }); + + expect(getInteractionModality()).toBe('pointer'); + }); + + it('reports virtual modality for a JAWS/VoiceOver click with no preceding pointerdown', function () { + act(() => { + fireEvent( + document.body, + pointerEvent('pointerdown', {pointerType: 'mouse', width: 1, height: 1}) + ); + }); + expect(getInteractionModality()).toBe('pointer'); + + act(() => { + fireEvent(document.body, pointerEvent('click', {pointerType: '', detail: 0})); + }); + + expect(getInteractionModality()).toBe('virtual'); + }); + + it('reports keyboard modality for a keydown', function () { + act(() => { + fireEvent.keyDown(document.body, {key: 'Tab'}); + }); + + expect(getInteractionModality()).toBe('keyboard'); + }); +}); From 9afca24dee1b5646c8adf898456b17d5eb75ea15 Mon Sep 17 00:00:00 2001 From: AK <144495202+AKnassa@users.noreply.github.com> Date: Wed, 8 Jul 2026 21:03:13 -0400 Subject: [PATCH 2/3] test: cover focus visibility, pen, and pointermove recovery for virtual 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. --- .../test/interactions/useFocusVisible.test.js | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/packages/react-aria/test/interactions/useFocusVisible.test.js b/packages/react-aria/test/interactions/useFocusVisible.test.js index 580caddb73c..3e3bcea9de6 100644 --- a/packages/react-aria/test/interactions/useFocusVisible.test.js +++ b/packages/react-aria/test/interactions/useFocusVisible.test.js @@ -21,6 +21,7 @@ import { import { addWindowFocusTracking, getInteractionModality, + isFocusVisible, setInteractionModality, useFocusVisible, useFocusVisibleListener, @@ -671,4 +672,47 @@ describe('useInteractionModality with virtual (screen reader) pointer events', f expect(getInteractionModality()).toBe('keyboard'); }); + + it('keeps focus visible after a screen reader pointerdown', function () { + // The user-visible consequence of the modality: isFocusVisible() is false only + // for 'pointer', so a screen reader activation must not suppress the focus ring. + act(() => { + fireEvent( + document.body, + pointerEvent('pointerdown', {pointerType: 'mouse', width: 0, height: 0}) + ); + }); + + expect(isFocusVisible()).toBe(true); + }); + + it('reports pointer modality for a pen pointerdown', function () { + act(() => { + fireEvent( + document.body, + pointerEvent('pointerdown', {pointerType: 'pen', width: 1, height: 1}) + ); + }); + + expect(getInteractionModality()).toBe('pointer'); + }); + + it('returns to pointer modality when a real mouse moves after a screen reader pointerdown', function () { + act(() => { + fireEvent( + document.body, + pointerEvent('pointerdown', {pointerType: 'mouse', width: 0, height: 0}) + ); + }); + expect(getInteractionModality()).toBe('virtual'); + + act(() => { + fireEvent( + document.body, + pointerEvent('pointermove', {pointerType: 'mouse', width: 1, height: 1}) + ); + }); + + expect(getInteractionModality()).toBe('pointer'); + }); }); From 61301e85ac668cda736f22c7c9bb710441369496 Mon Sep 17 00:00:00 2001 From: AK <144495202+AKnassa@users.noreply.github.com> Date: Wed, 8 Jul 2026 21:55:04 -0400 Subject: [PATCH 3/3] test: drop redundant act wrappers around fireEvent --- .../test/interactions/useFocusVisible.test.js | 148 +++++++----------- 1 file changed, 60 insertions(+), 88 deletions(-) diff --git a/packages/react-aria/test/interactions/useFocusVisible.test.js b/packages/react-aria/test/interactions/useFocusVisible.test.js index 3e3bcea9de6..f6f7fab8931 100644 --- a/packages/react-aria/test/interactions/useFocusVisible.test.js +++ b/packages/react-aria/test/interactions/useFocusVisible.test.js @@ -556,12 +556,10 @@ describe('useInteractionModality with virtual (screen reader) pointer events', f }); it('reports virtual modality for a JAWS/NVDA Chromium screen reader pointerdown', function () { - act(() => { - fireEvent( - document.body, - pointerEvent('pointerdown', {pointerType: 'mouse', width: 0, height: 0}) - ); - }); + fireEvent( + document.body, + pointerEvent('pointerdown', {pointerType: 'mouse', width: 0, height: 0}) + ); expect(getInteractionModality()).toBe('virtual'); }); @@ -569,27 +567,23 @@ describe('useInteractionModality with virtual (screen reader) pointer events', f it('notifies useInteractionModality subscribers of virtual modality on a screen reader pointerdown', function () { let {result} = renderHook(() => useInteractionModality()); - act(() => { - fireEvent( - document.body, - pointerEvent('pointerdown', {pointerType: 'mouse', width: 0, height: 0}) - ); - }); + fireEvent( + document.body, + pointerEvent('pointerdown', {pointerType: 'mouse', width: 0, height: 0}) + ); expect(result.current).toBe('virtual'); }); it('stays virtual through the pointerup paired with a screen reader pointerdown', function () { - act(() => { - fireEvent( - document.body, - pointerEvent('pointerdown', {pointerType: 'mouse', width: 0, height: 0}) - ); - fireEvent( - document.body, - pointerEvent('pointerup', {pointerType: 'mouse', width: 0, height: 0}) - ); - }); + fireEvent( + document.body, + pointerEvent('pointerdown', {pointerType: 'mouse', width: 0, height: 0}) + ); + fireEvent( + document.body, + pointerEvent('pointerup', {pointerType: 'mouse', width: 0, height: 0}) + ); expect(getInteractionModality()).toBe('virtual'); }); @@ -597,30 +591,26 @@ describe('useInteractionModality with virtual (screen reader) pointer events', f it('reports virtual modality for an Android TalkBack pointerdown', function () { platform.isAndroid.mockReturnValue(true); - act(() => { - fireEvent( - document.body, - pointerEvent('pointerdown', { - pointerType: 'mouse', - width: 1, - height: 1, - pressure: 0, - detail: 0 - }) - ); - }); + fireEvent( + document.body, + pointerEvent('pointerdown', { + pointerType: 'mouse', + width: 1, + height: 1, + pressure: 0, + detail: 0 + }) + ); expect(getInteractionModality()).toBe('virtual'); platform.isAndroid.mockReturnValue(false); }); it('reports pointer modality for a real mouse pointerdown', function () { - act(() => { - fireEvent( - document.body, - pointerEvent('pointerdown', {pointerType: 'mouse', width: 1, height: 1}) - ); - }); + fireEvent( + document.body, + pointerEvent('pointerdown', {pointerType: 'mouse', width: 1, height: 1}) + ); expect(getInteractionModality()).toBe('pointer'); }); @@ -628,47 +618,37 @@ describe('useInteractionModality with virtual (screen reader) pointer events', f it('notifies useInteractionModality subscribers of pointer modality on a real mouse pointerdown', function () { let {result} = renderHook(() => useInteractionModality()); - act(() => { - fireEvent( - document.body, - pointerEvent('pointerdown', {pointerType: 'mouse', width: 1, height: 1}) - ); - }); + fireEvent( + document.body, + pointerEvent('pointerdown', {pointerType: 'mouse', width: 1, height: 1}) + ); expect(result.current).toBe('pointer'); }); it('reports pointer modality for a real touch pointerdown', function () { - act(() => { - fireEvent( - document.body, - pointerEvent('pointerdown', {pointerType: 'touch', width: 20, height: 20}) - ); - }); + fireEvent( + document.body, + pointerEvent('pointerdown', {pointerType: 'touch', width: 20, height: 20}) + ); expect(getInteractionModality()).toBe('pointer'); }); it('reports virtual modality for a JAWS/VoiceOver click with no preceding pointerdown', function () { - act(() => { - fireEvent( - document.body, - pointerEvent('pointerdown', {pointerType: 'mouse', width: 1, height: 1}) - ); - }); + fireEvent( + document.body, + pointerEvent('pointerdown', {pointerType: 'mouse', width: 1, height: 1}) + ); expect(getInteractionModality()).toBe('pointer'); - act(() => { - fireEvent(document.body, pointerEvent('click', {pointerType: '', detail: 0})); - }); + fireEvent(document.body, pointerEvent('click', {pointerType: '', detail: 0})); expect(getInteractionModality()).toBe('virtual'); }); it('reports keyboard modality for a keydown', function () { - act(() => { - fireEvent.keyDown(document.body, {key: 'Tab'}); - }); + fireEvent.keyDown(document.body, {key: 'Tab'}); expect(getInteractionModality()).toBe('keyboard'); }); @@ -676,42 +656,34 @@ describe('useInteractionModality with virtual (screen reader) pointer events', f it('keeps focus visible after a screen reader pointerdown', function () { // The user-visible consequence of the modality: isFocusVisible() is false only // for 'pointer', so a screen reader activation must not suppress the focus ring. - act(() => { - fireEvent( - document.body, - pointerEvent('pointerdown', {pointerType: 'mouse', width: 0, height: 0}) - ); - }); + fireEvent( + document.body, + pointerEvent('pointerdown', {pointerType: 'mouse', width: 0, height: 0}) + ); expect(isFocusVisible()).toBe(true); }); it('reports pointer modality for a pen pointerdown', function () { - act(() => { - fireEvent( - document.body, - pointerEvent('pointerdown', {pointerType: 'pen', width: 1, height: 1}) - ); - }); + fireEvent( + document.body, + pointerEvent('pointerdown', {pointerType: 'pen', width: 1, height: 1}) + ); expect(getInteractionModality()).toBe('pointer'); }); it('returns to pointer modality when a real mouse moves after a screen reader pointerdown', function () { - act(() => { - fireEvent( - document.body, - pointerEvent('pointerdown', {pointerType: 'mouse', width: 0, height: 0}) - ); - }); + fireEvent( + document.body, + pointerEvent('pointerdown', {pointerType: 'mouse', width: 0, height: 0}) + ); expect(getInteractionModality()).toBe('virtual'); - act(() => { - fireEvent( - document.body, - pointerEvent('pointermove', {pointerType: 'mouse', width: 1, height: 1}) - ); - }); + fireEvent( + document.body, + pointerEvent('pointermove', {pointerType: 'mouse', width: 1, height: 1}) + ); expect(getInteractionModality()).toBe('pointer'); });