Skip to content
Merged
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
14 changes: 9 additions & 5 deletions packages/react-aria/src/interactions/useHover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export interface HoverResult {
// We want to ignore these emulated events so they do not trigger hover behavior.
// See https://bugs.webkit.org/show_bug.cgi?id=214609.
let globalIgnoreEmulatedMouseEvents = false;
let globalIgnoreEmulatedMouseEventsTimeout: ReturnType<typeof setTimeout> | null = null;
let hoverCount = 0;

function setGlobalIgnoreEmulatedMouseEvents() {
Expand All @@ -45,8 +46,12 @@ function setGlobalIgnoreEmulatedMouseEvents() {
// with pointerType="mouse" immediately after onPointerUp and before onFocus. On other
// devices that don't have this quirk, we don't want to ignore a mouse hover sometime in
// the distant future because a user previously touched the element.
setTimeout(() => {
if (globalIgnoreEmulatedMouseEventsTimeout != null) {
clearTimeout(globalIgnoreEmulatedMouseEventsTimeout);
}
globalIgnoreEmulatedMouseEventsTimeout = setTimeout(() => {
globalIgnoreEmulatedMouseEvents = false;
globalIgnoreEmulatedMouseEventsTimeout = null;
}, 500);
}

Expand Down Expand Up @@ -105,7 +110,6 @@ export function useHover(props: HoverProps): HoverResult {

let {hoverProps, triggerHoverEnd} = useMemo(() => {
let triggerHoverStart = (event, pointerType) => {
state.pointerType = pointerType;
if (
isDisabled ||
pointerType === 'touch' ||
Expand All @@ -115,6 +119,7 @@ export function useHover(props: HoverProps): HoverResult {
return;
}

state.pointerType = pointerType;
state.isHovered = true;
let target = event.currentTarget;
state.target = target;
Expand Down Expand Up @@ -156,13 +161,12 @@ export function useHover(props: HoverProps): HoverResult {

let triggerHoverEnd = (event, pointerType) => {
let target = state.target;
state.pointerType = '';
state.target = null;

if (pointerType === 'touch' || !state.isHovered || !target) {
return;
}

state.pointerType = '';
state.target = null;
state.isHovered = false;
removeAllGlobalListeners();

Expand Down
100 changes: 100 additions & 0 deletions packages/react-aria/test/interactions/useHover.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,106 @@ describe('useHover', function () {
}
]);
});

describe('mixed mouse and touch', function () {
function Item(props) {
let {hoverProps, isHovered} = useHover(props);
return (
<div {...hoverProps} data-testid={props.id} data-hovered={isHovered || undefined}>
{props.id}
</div>
);
}

afterEach(() => {
// Reset the global emulated mouse event suppression between tests.
act(() => {
jest.advanceTimersByTime(1000);
});
});

it('should end mouse hover after touching another element', function () {
let res = render(
<>
<Item id="a" />
<Item id="b" />
</>
);
let a = res.getByTestId('a');
let b = res.getByTestId('b');

fireEvent(a, pointerEvent('pointerover', {pointerType: 'mouse'}));
expect(a).toHaveAttribute('data-hovered', 'true');

fireEvent(b, pointerEvent('pointerover', {pointerType: 'touch'}));
fireEvent(b, pointerEvent('pointerout', {pointerType: 'touch'}));
expect(b).not.toHaveAttribute('data-hovered');

fireEvent(a, pointerEvent('pointerout', {pointerType: 'mouse', relatedTarget: b}));
fireEvent(b, pointerEvent('pointerover', {pointerType: 'mouse', relatedTarget: a}));
expect(a).not.toHaveAttribute('data-hovered');
expect(b).toHaveAttribute('data-hovered', 'true');
});

it('should end mouse hover after touching the hovered element', function () {
let res = render(<Item id="a" />);
let a = res.getByTestId('a');

fireEvent(a, pointerEvent('pointerover', {pointerType: 'mouse'}));
fireEvent(a, pointerEvent('pointerover', {pointerType: 'touch'}));
fireEvent(a, pointerEvent('pointerout', {pointerType: 'touch'}));
expect(a).toHaveAttribute('data-hovered', 'true');

fireEvent(a, pointerEvent('pointerout', {pointerType: 'mouse'}));
expect(a).not.toHaveAttribute('data-hovered');
});

it('should end mouse hover when disabled after a touch enters the hovered element', function () {
let events = [];
let addEvent = e => events.push(e);
let res = render(<Item id="a" onHoverEnd={addEvent} />);
let a = res.getByTestId('a');

fireEvent(a, pointerEvent('pointerover', {pointerType: 'mouse'}));
fireEvent(a, pointerEvent('pointerover', {pointerType: 'touch'}));

res.rerender(<Item id="a" isDisabled onHoverEnd={addEvent} />);
expect(a).not.toHaveAttribute('data-hovered');
expect(events).toEqual([
{
type: 'hoverend',
target: a,
pointerType: 'mouse'
}
]);
});

it('should extend emulated mouse event suppression on repeated touches', function () {
let res = render(<Item id="a" />);
let a = res.getByTestId('a');

fireEvent(a, pointerEvent('pointerup', {pointerType: 'touch'}));
act(() => {
jest.advanceTimersByTime(400);
});
fireEvent(a, pointerEvent('pointerup', {pointerType: 'touch'}));
act(() => {
jest.advanceTimersByTime(200);
});

// 600ms after the first touch, but only 200ms after the second.
fireEvent(a, pointerEvent('pointerover', {pointerType: 'mouse'}));
expect(a).not.toHaveAttribute('data-hovered');
fireEvent(a, pointerEvent('pointerout', {pointerType: 'mouse'}));

act(() => {
jest.advanceTimersByTime(400);
});
fireEvent(a, pointerEvent('pointerover', {pointerType: 'mouse'}));
expect(a).toHaveAttribute('data-hovered', 'true');
fireEvent(a, pointerEvent('pointerout', {pointerType: 'mouse'}));
});
});
});

describe('mouse events', function () {
Expand Down
Loading