Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions packages/react-aria/src/interactions/useFocusVisible.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}
}

Expand Down
205 changes: 204 additions & 1 deletion packages/react-aria/test/interactions/useFocusVisible.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,26 @@ import {
} from '@react-spectrum/test-utils-internal';
import {
addWindowFocusTracking,
getInteractionModality,
isFocusVisible,
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 (
Expand Down Expand Up @@ -513,3 +523,196 @@ 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(

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.

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');
});

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');
});
});