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

[ClickAwayListener] Fix preventDefault logic #18768

Merged
merged 4 commits into from
Dec 11, 2019
Merged
Show file tree
Hide file tree
Changes from 3 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
1 change: 1 addition & 0 deletions docs/pages/api/click-away-listener.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ For instance, if you need to hide a menu when people click anywhere else on your

| Name | Type | Default | Description |
|:-----|:-----|:--------|:------------|
| <span class="prop-name">allowPreventDefaultEvents</span> | <span class="prop-type">bool</span> | <span class="prop-default">false</span> | If `true`, the component skips the check to see if events used event.preventDefault().<br>The usage of this prop is recommended. It will become the default and unique behavior in v5. |
| <span class="prop-name required">children&nbsp;*</span> | <span class="prop-type">element</span> | | The wrapped element.<br>⚠️ [Needs to be able to hold a ref](/guides/composition/#caveat-with-refs). |
| <span class="prop-name">mouseEvent</span> | <span class="prop-type">'onClick'<br>&#124;&nbsp;'onMouseDown'<br>&#124;&nbsp;'onMouseUp'<br>&#124;&nbsp;false</span> | <span class="prop-default">'onClick'</span> | The mouse event to listen to. You can disable the listener by providing `false`. |
| <span class="prop-name required">onClickAway&nbsp;*</span> | <span class="prop-type">func</span> | | Callback fired when a "click away" event is detected. |
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as React from 'react';

export interface ClickAwayListenerProps {
allowPreventDefaultEvents?: boolean;
children: React.ReactNode;
mouseEvent?: 'onClick' | 'onMouseDown' | 'onMouseUp' | false;
onClickAway: (event: React.MouseEvent<Document>) => void;
Expand Down
20 changes: 17 additions & 3 deletions packages/material-ui/src/ClickAwayListener/ClickAwayListener.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,13 @@ function mapEventPropToEvent(eventProp) {
* For instance, if you need to hide a menu when people click anywhere else on your page.
*/
const ClickAwayListener = React.forwardRef(function ClickAwayListener(props, ref) {
const { children, mouseEvent = 'onClick', touchEvent = 'onTouchEnd', onClickAway } = props;
const {
allowPreventDefaultEvents = false,
children,
mouseEvent = 'onClick',
touchEvent = 'onTouchEnd',
onClickAway,
} = props;
const movedRef = React.useRef(false);
const nodeRef = React.useRef(null);
const mountedRef = React.useRef(false);
Expand All @@ -40,8 +46,9 @@ const ClickAwayListener = React.forwardRef(function ClickAwayListener(props, ref
const handleRef = useForkRef(children.ref, handleOwnRef);

const handleClickAway = useEventCallback(event => {
// Ignore events that have been `event.preventDefault()` marked.
if (event.defaultPrevented) {
// Ignore events that have been `event.preventDefault()` marked unless
// allowPreventDefaultEvents is true.
if (!allowPreventDefaultEvents && event.defaultPrevented) {
return;
}

Expand Down Expand Up @@ -113,6 +120,13 @@ const ClickAwayListener = React.forwardRef(function ClickAwayListener(props, ref
});

ClickAwayListener.propTypes = {
/**
* If `true`, the component skips the check to see if events used event.preventDefault().
*
* The usage of this prop is recommended.
* It will become the default and unique behavior in v5.
*/
allowPreventDefaultEvents: PropTypes.bool,
/**
* The wrapped element.
*/
Expand Down
161 changes: 74 additions & 87 deletions packages/material-ui/src/ClickAwayListener/ClickAwayListener.test.js
Original file line number Diff line number Diff line change
@@ -1,87 +1,74 @@
import React from 'react';
import { assert } from 'chai';
import { expect } from 'chai';
import { spy } from 'sinon';
import { createMount } from '@material-ui/core/test-utils';
import { createClientRender, fireEvent } from 'test/utils/createClientRender';
import ClickAwayListener from './ClickAwayListener';

function fireBodyMouseEvent(name, properties = {}) {
const event = document.createEvent('MouseEvents');
event.initEvent(name, true, true);
Object.keys(properties).forEach(key => {
event[key] = properties[key];
});
document.body.dispatchEvent(event);
return event;
}

describe('<ClickAwayListener />', () => {
let mount;
let wrapper;

before(() => {
mount = createMount({ strict: true });
});

afterEach(() => {
wrapper.unmount();
});

after(() => {
mount.cleanUp();
});
const render = createClientRender();

it('should render the children', () => {
const children = <span>Hello</span>;
wrapper = mount(<ClickAwayListener onClickAway={() => {}}>{children}</ClickAwayListener>);
assert.strictEqual(wrapper.contains(children), true);
const children = <span />;
const { container } = render(
<ClickAwayListener onClickAway={() => {}}>{children}</ClickAwayListener>,
);
expect(container.querySelectorAll('span').length).to.equal(1);
});

describe('prop: onClickAway', () => {
it('should be call when clicking away', () => {
it('should be called when clicking away', () => {
const handleClickAway = spy();
wrapper = mount(
render(
<ClickAwayListener onClickAway={handleClickAway}>
<span>Hello</span>
<span />
</ClickAwayListener>,
);

const event = fireBodyMouseEvent('click');
fireEvent.click(document.body);
expect(handleClickAway.callCount).to.equal(1);
expect(handleClickAway.args[0].length).to.equal(1);
});

assert.strictEqual(handleClickAway.callCount, 1);
assert.deepEqual(handleClickAway.args[0], [event]);
it('should not be called when clicking inside', () => {
const handleClickAway = spy();
const { container } = render(
<ClickAwayListener onClickAway={handleClickAway}>
<span />
</ClickAwayListener>,
);

fireEvent.click(container.querySelector('span'));
expect(handleClickAway.callCount).to.equal(0);
});

it('should not be call when clicking inside', () => {
it('should not be called when defaultPrevented', () => {
const handleClickAway = spy();
const ref = React.createRef();
wrapper = mount(
render(
<ClickAwayListener onClickAway={handleClickAway}>
<span>Hello</span>
<span />
</ClickAwayListener>,
);
const preventDefault = event => event.preventDefault();
document.body.addEventListener('click', preventDefault);

const event = new window.Event('click', { view: window, bubbles: true, cancelable: true });
const el = ref.current;
if (el) {
el.dispatchEvent(event);
}
fireEvent.click(document.body);
expect(handleClickAway.callCount).to.equal(0);

assert.strictEqual(handleClickAway.callCount, 0);
document.body.removeEventListener('click', preventDefault);
});

it('should not be call when defaultPrevented', () => {
it('should be called when preventDefault and allowPreventDefaultEvents is `true`', () => {
const handleClickAway = spy();
wrapper = mount(
<ClickAwayListener onClickAway={handleClickAway}>
<span>Hello</span>
render(
<ClickAwayListener allowPreventDefaultEvents onClickAway={handleClickAway}>
<span />
</ClickAwayListener>,
);
const preventDefault = event => event.preventDefault();
document.body.addEventListener('click', preventDefault);

const event = new window.Event('click', { view: window, bubbles: true, cancelable: true });
document.body.dispatchEvent(event);
assert.strictEqual(handleClickAway.callCount, 0);
fireEvent.click(document.body);
expect(handleClickAway.callCount).to.equal(1);

document.body.removeEventListener('click', preventDefault);
});
Expand All @@ -90,83 +77,83 @@ describe('<ClickAwayListener />', () => {
describe('prop: mouseEvent', () => {
it('should not call `props.onClickAway` when `props.mouseEvent` is `false`', () => {
const handleClickAway = spy();
wrapper = mount(
render(
<ClickAwayListener onClickAway={handleClickAway} mouseEvent={false}>
<span>Hello</span>
<span />
</ClickAwayListener>,
);
fireBodyMouseEvent('click');
assert.strictEqual(handleClickAway.callCount, 0);
fireEvent.click(document.body);
expect(handleClickAway.callCount).to.equal(0);
});

it('should call `props.onClickAway` when the appropriate mouse event is triggered', () => {
const handleClickAway = spy();
wrapper = mount(
render(
<ClickAwayListener onClickAway={handleClickAway} mouseEvent="onMouseDown">
<span>Hello</span>
<span />
</ClickAwayListener>,
);
fireBodyMouseEvent('mouseup');
assert.strictEqual(handleClickAway.callCount, 0);
const mouseDownEvent = fireBodyMouseEvent('mousedown');
assert.strictEqual(handleClickAway.callCount, 1);
assert.deepEqual(handleClickAway.args[0], [mouseDownEvent]);
fireEvent.mouseUp(document.body);
expect(handleClickAway.callCount).to.equal(0);
fireEvent.mouseDown(document.body);
expect(handleClickAway.callCount).to.equal(1);
expect(handleClickAway.args[0].length).to.equal(1);
});
});

describe('prop: touchEvent', () => {
it('should not call `props.onClickAway` when `props.touchEvent` is `false`', () => {
const handleClickAway = spy();
wrapper = mount(
render(
<ClickAwayListener onClickAway={handleClickAway} touchEvent={false}>
<span>Hello</span>
<span />
</ClickAwayListener>,
);
fireBodyMouseEvent('touchend');
assert.strictEqual(handleClickAway.callCount, 0);
fireEvent.touchEnd(document.body);
expect(handleClickAway.callCount).to.equal(0);
});

it('should call `props.onClickAway` when the appropriate touch event is triggered', () => {
const handleClickAway = spy();
wrapper = mount(
render(
<ClickAwayListener onClickAway={handleClickAway} touchEvent="onTouchStart">
<span>Hello</span>
<span />
</ClickAwayListener>,
);
fireBodyMouseEvent('touchend');
assert.strictEqual(handleClickAway.callCount, 0);
const touchStartEvent = fireBodyMouseEvent('touchstart');
assert.strictEqual(handleClickAway.callCount, 1);
assert.deepEqual(handleClickAway.args[0], [touchStartEvent]);
fireEvent.touchEnd(document.body);
expect(handleClickAway.callCount).to.equal(0);
fireEvent.touchStart(document.body);
expect(handleClickAway.callCount).to.equal(1);
expect(handleClickAway.args[0].length).to.equal(1);
});

it('should ignore `touchend` when preceded by `touchmove` event', () => {
const handleClickAway = spy();
wrapper = mount(
render(
<ClickAwayListener onClickAway={handleClickAway} touchEvent="onTouchEnd">
<span>Hello</span>
<span />
</ClickAwayListener>,
);
fireBodyMouseEvent('touchstart');
fireBodyMouseEvent('touchmove');
fireBodyMouseEvent('touchend');
assert.strictEqual(handleClickAway.callCount, 0);

const touchEndEvent = fireBodyMouseEvent('touchend');
assert.strictEqual(handleClickAway.callCount, 1);
assert.deepEqual(handleClickAway.args[0], [touchEndEvent]);
fireEvent.touchStart(document.body);
fireEvent.touchMove(document.body);
fireEvent.touchEnd(document.body);
expect(handleClickAway.callCount).to.equal(0);

fireEvent.touchEnd(document.body);
expect(handleClickAway.callCount).to.equal(1);
expect(handleClickAway.args[0].length).to.equal(1);
});
});

it('should handle null child', () => {
const Child = React.forwardRef(() => null);
const handleClickAway = spy();
wrapper = mount(
render(
<ClickAwayListener onClickAway={handleClickAway}>
<Child />
</ClickAwayListener>,
);
fireBodyMouseEvent('click');
assert.strictEqual(handleClickAway.callCount, 0);
fireEvent.click(document.body);
expect(handleClickAway.callCount).to.equal(0);
});
});