Skip to content

Commit

Permalink
feat: add react-mouse-outside pkg
Browse files Browse the repository at this point in the history
  • Loading branch information
Federico Zivolo committed Dec 17, 2018
1 parent 89432d6 commit 4e281c1
Show file tree
Hide file tree
Showing 7 changed files with 306 additions and 0 deletions.
38 changes: 38 additions & 0 deletions packages/react-mouse-outside/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
`react-mouse-outside` enables to listen for mouse events that happen
to the target reference elements.

```jsx
const MouseOutside = require('./src').default;

initialState = { txt: 'click outside' };
const callback = () =>
setState({ txt: 'clicked!' }, () =>
setTimeout(() => setState({ txt: 'click outside' }), 300)
);

<MouseOutside onClickOutside={callback}>
{getRef => <div ref={getRef}>{state.txt}</div>}
</MouseOutside>;
```

The component accepts 3 properties, included `children`:

##### `onClickOutside: Event => void`

A function that will take as first argument the event object
and gets triggered anytime the user clicks outside.

##### `onMoveOutside: Event => void`

A function that will take as first argument the event object
and gets triggered anytime the user moves the mouse outside.

##### `children: React.ElementRef => void`

It takes as `children` a function with a `React.createRef` function
as only argument. You can assign it as `ref` to any React element.

#### Migration from @quid/react-components#MouseOutside

The component takes as child a render-prop rather than a React element.
This allows for more flexibility and removes the requirement of an extra wrapper.
22 changes: 22 additions & 0 deletions packages/react-mouse-outside/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"name": "@quid/react-mouse-outside",
"version": "1.0.0",
"description": "Detect clicks and other mouse events outside of a React element",
"main": "dist/index.js",
"main:umd": "dist/index.umd.js",
"module": "dist/index.es.js",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/quid/ui-framework.git"
},
"scripts": {
"start": "microbundle watch",
"prepare": "microbundle build --jsx React.createElement && flow-copy-source --ignore '{__mocks__/*,*.test}.js' src dist",
"test": "cd ../.. && yarn test --testPathPattern packages/react-mouse-outside"
},
"devDependencies": {
"flow-copy-source": "^2.0.2",
"microbundle": "^0.8.3"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`should render expected markup 1`] = `<div />`;
14 changes: 14 additions & 0 deletions packages/react-mouse-outside/src/debounce.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// @flow
export const debounce = (
callback: Function,
delay: number = 250,
interval?: TimeoutID
) => (...args: any) =>
// $FlowFixMe(fzivolo): Flow definition doesn't support the advanced usage of setTimeout
clearTimeout(interval, (interval = setTimeout(callback, delay, ...args)));

// This is mostly to avoid async troubles during tests, but it also
// prevents to make asynchronous the callback when the delay is set to 0
// and it should actually be synchronous
export const conditionalDebounce = (callback: Function, delay: number) =>
delay > 0 ? debounce(callback, delay) : callback;
51 changes: 51 additions & 0 deletions packages/react-mouse-outside/src/debounce.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// @flow
import { debounce, conditionalDebounce } from './debounce';

it('should debounce the function with custom delay', async done => {
const fn = jest.fn();
const debFn = debounce(fn, 5);

debFn();
debFn();

setTimeout(() => {
expect(fn.mock.calls.length).toBe(1);
done();
}, 20);
});

it('should debounce the function with default delay', async done => {
const fn = jest.fn();
const debFn = debounce(fn);

debFn();
debFn();

setTimeout(() => {
expect(fn.mock.calls.length).toBe(1);
done();
}, 500);
});

it('should not debounce if delay is zero', () => {
const fn = jest.fn();
const debFn = conditionalDebounce(fn, 0);

debFn();
debFn();

expect(fn.mock.calls.length).toBe(2);
});

it('should debounce if delay is non zero', async done => {
const fn = jest.fn();
const debFn = conditionalDebounce(fn, 10);

debFn();
debFn();

setTimeout(() => {
expect(fn.mock.calls.length).toBe(1);
done();
}, 20);
});
61 changes: 61 additions & 0 deletions packages/react-mouse-outside/src/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// @flow
import * as React from 'react';
import { conditionalDebounce } from './debounce';

type RenderProp<P> = P => React.Node;

type Props = {
onClickOutside?: Function,
onMoveOutside?: Function,
delay: number,
children: RenderProp<React.ElementRef<any>>,
};

export default class MouseOutside extends React.Component<Props> {
static defaultProps = {
delay: 0,
tag: 'div',
};

container = React.createRef /*:: <HTMLElement> */();

isTargetOutside = (target: EventTarget) => {
return (
// $FlowFixMe HTMLDocument isn't supported (https://github.com/facebook/flow/issues/2839)
(target instanceof HTMLElement || target instanceof HTMLDocument) &&
(this.container.current instanceof HTMLElement &&
!this.container.current.contains(target) &&
document.contains(target))
);
};

handleClickOutside = (evt: Event) => {
if (this.props.onClickOutside && this.isTargetOutside(evt.target)) {
this.props.onClickOutside(evt);
}
};

handleMoveOutside = conditionalDebounce((evt: Event) => {
if (this.props.onMoveOutside && this.isTargetOutside(evt.target)) {
this.props.onMoveOutside(evt);
}
}, this.props.delay);

componentDidMount() {
if (this.props.onClickOutside) {
document.addEventListener('click', this.handleClickOutside, true);
}
if (this.props.onMoveOutside) {
document.addEventListener('mousemove', this.handleMoveOutside, true);
}
}

componentWillUnmount() {
document.removeEventListener('click', this.handleClickOutside, true);
document.removeEventListener('mousemove', this.handleMoveOutside, true);
}

render() {
return this.props.children(this.container);
}
}
117 changes: 117 additions & 0 deletions packages/react-mouse-outside/src/index.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// @flow
import * as React from 'react';
import { shallow, mount } from 'enzyme';
import MouseOutside from './index';

// $FlowFixMe(fzivolo): forwardRef not yet supported
const Child = React.forwardRef((props, ref) => <div ref={ref} />);

it('should render expected markup', () => {
const wrapper = shallow(
<MouseOutside onClickOutside={f => f}>
{ref => <div ref={ref} />}
</MouseOutside>
);
expect(wrapper).toMatchSnapshot();
});

it('should not call onClickOutside on click inside', () => {
const handleClickOutside = jest.fn();
const wrapper = mount(
<MouseOutside onClickOutside={handleClickOutside}>
{ref => <Child ref={ref} />}
</MouseOutside>
);

wrapper.find(Child).simulate('click');
expect(handleClickOutside).not.toBeCalled();
});

it('should call onClickOutside on... click outside', () => {
const handleClickOutside = jest.fn();
mount(
<MouseOutside onClickOutside={handleClickOutside}>
{ref => <Child ref={ref} />}
</MouseOutside>
);

document.dispatchEvent(new Event('click'));
expect(handleClickOutside).toBeCalled();
});

it('should not call onClickOutside on click outside if callback pro has been removed', () => {
const handleClickOutside = jest.fn();
const wrapper = mount(
<MouseOutside onClickOutside={handleClickOutside}>
{ref => <Child ref={ref} />}
</MouseOutside>
);

wrapper.setProps({ onClickOutside: undefined });

document.dispatchEvent(new Event('click'));
expect(handleClickOutside).not.toBeCalled();
});

it('should not call onMoveOutside on click outside if callback pro has been removed', () => {
const handleMouseMoveOutside = jest.fn();
const wrapper = mount(
<MouseOutside onMoveOutside={handleMouseMoveOutside}>
{ref => <div ref={ref} />}
</MouseOutside>
);

wrapper.setProps({ onMoveOutside: undefined });

document.dispatchEvent(new Event('mousemove'));
expect(handleMouseMoveOutside).not.toBeCalled();
});

it('should remove event listener on unmount', () => {
const handleClickOutside = jest.fn();
const wrapper = mount(
<MouseOutside onClickOutside={handleClickOutside}>
{ref => <div ref={ref} />}
</MouseOutside>
);
wrapper.unmount();
document.dispatchEvent(new Event('click'));
expect(handleClickOutside).not.toBeCalled();
});

it('should not call onMoveOutside on mouse move inside', () => {
const handleMouseMoveOutside = jest.fn();
const wrapper = mount(
<MouseOutside onMoveOutside={handleMouseMoveOutside}>
{ref => <Child ref={ref} />}
</MouseOutside>
);

const child = wrapper.find(Child);
child.simulate('mousemove', { target: child });
expect(handleMouseMoveOutside).not.toBeCalled();
});

it('should call onMoveOutside when mouse moves outside', () => {
const handleMouseMoveOutside = jest.fn();
mount(
<MouseOutside onMoveOutside={handleMouseMoveOutside}>
{ref => <Child ref={ref} />}
</MouseOutside>
);

document.dispatchEvent(new Event('mousemove'));
expect(handleMouseMoveOutside).toBeCalled();
});

it('should remove event listener on unmount', () => {
const handleMouseMoveOutside = jest.fn();
const wrapper = mount(
<MouseOutside onMoveOutside={handleMouseMoveOutside}>
{ref => <div ref={ref} />}
</MouseOutside>
);
wrapper.unmount();
document.dispatchEvent(new Event('mousemove'));
expect(handleMouseMoveOutside).not.toBeCalled();
});

0 comments on commit 4e281c1

Please sign in to comment.