diff --git a/packages/react-aria/src/interactions/useHover.ts b/packages/react-aria/src/interactions/useHover.ts index 43830b02713..bd6b9f5e94f 100644 --- a/packages/react-aria/src/interactions/useHover.ts +++ b/packages/react-aria/src/interactions/useHover.ts @@ -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 | null = null; let hoverCount = 0; function setGlobalIgnoreEmulatedMouseEvents() { @@ -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); } @@ -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' || @@ -115,6 +119,7 @@ export function useHover(props: HoverProps): HoverResult { return; } + state.pointerType = pointerType; state.isHovered = true; let target = event.currentTarget; state.target = target; @@ -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(); diff --git a/packages/react-aria/test/interactions/useHover.test.js b/packages/react-aria/test/interactions/useHover.test.js index 1edde7c3461..fcb26ab5c5a 100644 --- a/packages/react-aria/test/interactions/useHover.test.js +++ b/packages/react-aria/test/interactions/useHover.test.js @@ -377,6 +377,106 @@ describe('useHover', function () { } ]); }); + + describe('mixed mouse and touch', function () { + function Item(props) { + let {hoverProps, isHovered} = useHover(props); + return ( +
+ {props.id} +
+ ); + } + + 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( + <> + + + + ); + 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(); + 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(); + let a = res.getByTestId('a'); + + fireEvent(a, pointerEvent('pointerover', {pointerType: 'mouse'})); + fireEvent(a, pointerEvent('pointerover', {pointerType: 'touch'})); + + res.rerender(); + 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(); + 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 () {