Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[BottomNavigationAction] Fix onClick doublefire in Firefox #28242

Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as React from 'react';
import PropTypes from 'prop-types';
import clsx from 'clsx';
import { unstable_composeClasses as composeClasses } from '@mui/core';
import { DELAY_RIPPLE } from '../ButtonBase/TouchRipple';
import styled from '../styles/styled';
import useThemeProps from '../styles/useThemeProps';
import ButtonBase from '../ButtonBase';
Expand Down Expand Up @@ -76,6 +77,8 @@ const BottomNavigationAction = React.forwardRef(function BottomNavigationAction(
icon,
label,
onChange,
onTouchStart,
onTouchEnd,
onClick,
// eslint-disable-next-line react/prop-types -- private, always overridden by BottomNavigation
selected,
Expand All @@ -87,7 +90,54 @@ const BottomNavigationAction = React.forwardRef(function BottomNavigationAction(
const ownerState = props;
const classes = useUtilityClasses(ownerState);

const touchStartPos = React.useRef();
const touchTimer = React.useRef();

React.useEffect(() => {
return () => {
clearTimeout(touchTimer.current);
};
}, [touchTimer]);

const handleTouchStart = (event) => {
if (onTouchStart) {
onTouchStart(event);
}

const { clientX, clientY } = event.touches[0];

touchStartPos.current = {
clientX,
clientY,
};
};

const handleTouchEnd = (event) => {
if (onTouchEnd) {
onTouchEnd(event);
}

const target = event.target;
const { clientX, clientY } = event.changedTouches[0];

if (
Math.abs(clientX - touchStartPos.current.clientX) < 10 &&
Math.abs(clientY - touchStartPos.current.clientY) < 10
) {
touchTimer.current = setTimeout(() => {
// Simulate the native tap behavior on mobile.
// On the web, a tap won't trigger a click if a container is scrolling.
//
// Note that the synthetic behavior won't trigger a native <a> nor
// it will trigger a click at all on iOS.
target.dispatchEvent(new window.Event('click', { bubbles: true }));
}, DELAY_RIPPLE);
}
};

const handleChange = (event) => {
clearTimeout(touchTimer.current);

if (onChange) {
onChange(event, value);
}
Expand All @@ -104,6 +154,8 @@ const BottomNavigationAction = React.forwardRef(function BottomNavigationAction(
focusRipple
onClick={handleChange}
ownerState={ownerState}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
{...other}
>
{icon}
Expand Down Expand Up @@ -148,6 +200,14 @@ BottomNavigationAction.propTypes /* remove-proptypes */ = {
* @ignore
*/
onClick: PropTypes.func,
/**
* @ignore
*/
onTouchEnd: PropTypes.func,
/**
* @ignore
*/
onTouchStart: PropTypes.func,
/**
* If `true`, the `BottomNavigationAction` will show its label.
* By default, only the selected `BottomNavigationAction`
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as React from 'react';
import { expect } from 'chai';
import { spy } from 'sinon';
import { describeConformance, createClientRender, within } from 'test/utils';
import { spy, useFakeTimers } from 'sinon';
import { describeConformance, createClientRender, within, fireEvent, act } from 'test/utils';
import BottomNavigationAction, {
bottomNavigationActionClasses as classes,
} from '@mui/material/BottomNavigationAction';
Expand Down Expand Up @@ -73,4 +73,167 @@ describe('<BottomNavigationAction />', () => {
expect(handleClick.callCount).to.equal(1);
});
});

describe('touch functionality', () => {
before(function test() {
// Only run in supported browsers
if (typeof Touch === 'undefined') {
this.skip();
}
});

let clock;

beforeEach(() => {
clock = useFakeTimers();
});

afterEach(() => {
clock.restore();
});

it('should fire onClick on touch tap', () => {
const clickSpy = spy();
const handleClick = () => act(clickSpy);

const { container } = render(<BottomNavigationAction onClick={handleClick} />);

fireEvent.touchStart(container.firstChild, {
touches: [
new Touch({
identifier: 1,
target: container,
clientX: 42,
clientY: 42,
}),
],
});

fireEvent.touchEnd(container.firstChild, {
changedTouches: [
new Touch({
identifier: 1,
target: container,
clientX: 42,
clientY: 42,
}),
],
});

act(() => {
clock.tick(100);
});

expect(clickSpy.callCount).to.equal(1);
});

it('should not fire onClick twice on touch tap', () => {
const clickSpy = spy();
const handleClick = () => act(clickSpy);

const { getByRole, container } = render(<BottomNavigationAction onClick={handleClick} />);

fireEvent.touchStart(container.firstChild, {
touches: [
new Touch({
identifier: 1,
target: container,
clientX: 42,
clientY: 42,
}),
],
});

fireEvent.touchEnd(container.firstChild, {
changedTouches: [
new Touch({
identifier: 1,
target: container,
clientX: 42,
clientY: 42,
}),
],
});

getByRole('button').click();

act(() => {
clock.tick(100);
});

expect(clickSpy.callCount).to.equal(1);
});

it('should not fire onClick if swiping', () => {
const clickSpy = spy();
const handleClick = () => act(clickSpy);

const { container } = render(<BottomNavigationAction onClick={handleClick} />);

fireEvent.touchStart(container.firstChild, {
touches: [
new Touch({
identifier: 1,
target: container,
clientX: 42,
clientY: 42,
}),
],
});

fireEvent.touchEnd(container.firstChild, {
changedTouches: [
new Touch({
identifier: 1,
target: container,
clientX: 84,
clientY: 84,
}),
],
});

act(() => {
clock.tick(100);
});

expect(clickSpy.callCount).to.equal(0);
});

it('should forward onTouchStart and onTouchEnd events', () => {
const touchStartSpy = spy();
const touchEndSpy = spy();
const handleTouchStart = () => act(touchStartSpy);
const handleTouchEnd = () => act(touchEndSpy);

const { container } = render(
<BottomNavigationAction onTouchStart={handleTouchStart} onTouchEnd={handleTouchEnd} />,
);

fireEvent.touchStart(container.firstChild, {
touches: [
new Touch({
identifier: 1,
target: container,
clientX: 42,
clientY: 42,
}),
],
});

expect(touchStartSpy.callCount).to.be.equals(1);

fireEvent.touchEnd(container.firstChild, {
changedTouches: [
new Touch({
identifier: 1,
target: container,
clientX: 84,
clientY: 84,
}),
],
});

expect(touchEndSpy.callCount).to.be.equals(1);
});
});
});