Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
177 changes: 176 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,168 @@ 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 () {
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());

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 () {
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);

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 () {
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());

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 () {
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 () {
fireEvent(
document.body,
pointerEvent('pointerdown', {pointerType: 'mouse', width: 1, height: 1})
);
expect(getInteractionModality()).toBe('pointer');

fireEvent(document.body, pointerEvent('click', {pointerType: '', detail: 0}));

expect(getInteractionModality()).toBe('virtual');
});

it('reports keyboard modality for a keydown', function () {
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.
fireEvent(
document.body,
pointerEvent('pointerdown', {pointerType: 'mouse', width: 0, height: 0})
);

expect(isFocusVisible()).toBe(true);
});

it('reports pointer modality for a pen pointerdown', function () {
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 () {
fireEvent(
document.body,
pointerEvent('pointerdown', {pointerType: 'mouse', width: 0, height: 0})
);
expect(getInteractionModality()).toBe('virtual');

fireEvent(
document.body,
pointerEvent('pointermove', {pointerType: 'mouse', width: 1, height: 1})
);

expect(getInteractionModality()).toBe('pointer');
});
});