Skip to content

Commit 4a28ed7

Browse files
committed
Introduce useClickOutside hook
1 parent 1930ee9 commit 4a28ed7

File tree

7 files changed

+439
-36
lines changed

7 files changed

+439
-36
lines changed

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,24 @@ module.exports = enhanceWithClickOutside(Dropdown);
5353
**Note:** There will be no error thrown if `handleClickOutside` is not
5454
implemented.
5555

56+
### hook
57+
58+
```js
59+
const { useClickOutside } = require('react-click-outside');
60+
const React = require('react');
61+
62+
const Dropdown = () => {
63+
const [isOpened, setIsOpened] = React.useState(false);
64+
const onClickOutside = React.useCallback((e) => {
65+
setIsOpened(false);
66+
}, [isOpened]);
67+
const clickOutsideRef = useClickOutside(onClickOutside);
68+
return <div ref={clickOutsideRef}>...</div>;
69+
};
70+
71+
module.exports = Dropdown;
72+
```
73+
5674
### `wrappedRef` prop
5775

5876
Use the `wrappedRef` prop to get access to the wrapped component instance. For

demo/hook.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
const React = require('react');
2+
const ReactDOM = require('react-dom');
3+
const { useClickOutside } = require('../index');
4+
5+
const style = {
6+
backgroundColor: '#fff',
7+
border: '1px solid #000',
8+
height: 100,
9+
width: 100,
10+
};
11+
12+
const Target = () => {
13+
const handleClickOutside = React.useCallback(() => {
14+
const hue = Math.floor(Math.random() * 360);
15+
document.body.style.backgroundColor = `hsl(${hue}, 100%, 87.5%)`;
16+
}, []);
17+
const clickOutsideRef = useClickOutside(handleClickOutside);
18+
const isMobile = 'ontouchstart' in document.body;
19+
return <div ref={clickOutsideRef} style={style}>{`mobile: ${isMobile}`}</div>;
20+
};
21+
22+
const Root = () => (
23+
<div>
24+
<Target />
25+
<button style={style}>Button Element</button>
26+
</div>
27+
);
28+
29+
if ('ontouchstart' in document.documentElement) {
30+
document.body.style.cursor = 'pointer';
31+
document.documentElement.style.touchAction = 'manipulation';
32+
}
33+
34+
const root = document.createElement('div');
35+
document.body.appendChild(root);
36+
37+
ReactDOM.render(<Root />, root);

index.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,4 +59,29 @@ function enhanceWithClickOutside(Component: React.ComponentType<*>) {
5959
return hoistNonReactStatic(EnhancedComponent, Component);
6060
}
6161

62+
function useClickOutside(onClickOutside: (e: Event) => void) {
63+
const [domNode, setDomNode] = React.useState(null);
64+
65+
React.useEffect(
66+
() => {
67+
const onClick = (e: Event) => {
68+
if ((!domNode || !domNode.contains(e.target)) && onClickOutside)
69+
onClickOutside(e);
70+
};
71+
72+
document.addEventListener('click', onClick, true);
73+
return () => {
74+
document.removeEventListener('click', onClick, true);
75+
};
76+
},
77+
[domNode, onClickOutside]
78+
);
79+
80+
const refCallback = React.useCallback(setDomNode, [onClickOutside]);
81+
82+
return refCallback;
83+
}
84+
85+
enhanceWithClickOutside.useClickOutside = useClickOutside;
86+
6287
module.exports = enhanceWithClickOutside;

package.json

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"scripts": {
77
"build": "npm test && babel -d dist index.js",
88
"demo": "budo demo/app.js -- -t babelify",
9+
"demo:hook": "budo demo/hook.js -- -t babelify",
910
"lint": "eslint .",
1011
"test": "jest",
1112
"test:ci": "flow && npm run lint && npm run test"
@@ -38,14 +39,14 @@
3839
"budo": "10.0.4",
3940
"create-react-class": "15.6.2",
4041
"enzyme": "3.0.0",
41-
"enzyme-adapter-react-16": "1.0.0",
42+
"enzyme-adapter-react-16": "^1.13.2",
4243
"eslint": "4.8.0",
4344
"eslint-config-kentor": "5.1.0",
4445
"flow-bin": "^0.98.1",
4546
"jest": "21.2.1",
46-
"react": "16.0.0",
47-
"react-dom": "16.0.0",
48-
"react-test-renderer": "16.0.0"
47+
"react": "^16.8.6",
48+
"react-dom": "^16.8.6",
49+
"react-test-renderer": "^16.8.6"
4950
},
5051
"dependencies": {
5152
"hoist-non-react-statics": "^2.1.1"

test/hook.test.js

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
global.requestAnimationFrame = callback => setTimeout(callback, 0);
2+
3+
const Adapter = require('enzyme-adapter-react-16');
4+
const { configure } = require('enzyme');
5+
6+
configure({ adapter: new Adapter() });
7+
8+
const enzyme = require('enzyme');
9+
const React = require('react');
10+
const { useClickOutside } = require('../index');
11+
12+
const mountNode = document.createElement('div');
13+
document.body.appendChild(mountNode);
14+
15+
function mount(element) {
16+
return enzyme.mount(element, { attachTo: mountNode });
17+
}
18+
19+
function simulateClick(node) {
20+
const event = new window.MouseEvent('click', {
21+
bubbles: true,
22+
cancelable: true,
23+
view: window,
24+
});
25+
node.dispatchEvent(event);
26+
return event;
27+
}
28+
29+
describe('useClickOutside', () => {
30+
let wrapper;
31+
32+
beforeEach(() => {
33+
wrapper = undefined;
34+
});
35+
36+
afterEach(() => {
37+
if (wrapper && wrapper.unmount) {
38+
wrapper.unmount();
39+
}
40+
});
41+
42+
it('calls handleClickOutside when clicked outside of component', () => {
43+
const clickInsideSpy = jest.fn();
44+
const clickOutsideSpy = jest.fn();
45+
46+
const EnhancedComponent = () => {
47+
const ref = useClickOutside(clickOutsideSpy);
48+
return (
49+
<div ref={ref} id="enhancedNode" onClick={clickInsideSpy}>
50+
<div id="nestedNode" />
51+
</div>
52+
);
53+
};
54+
55+
class Root extends React.Component {
56+
render() {
57+
return (
58+
<div>
59+
<EnhancedComponent />
60+
<div id="outsideNode" />
61+
</div>
62+
);
63+
}
64+
}
65+
66+
wrapper = mount(<Root />);
67+
68+
const enhancedNode = wrapper.find('#enhancedNode').getDOMNode();
69+
const nestedNode = wrapper.find('#nestedNode').getDOMNode();
70+
const outsideNode = wrapper.find('#outsideNode').getDOMNode();
71+
72+
simulateClick(enhancedNode);
73+
expect(clickInsideSpy.mock.calls.length).toBe(1);
74+
expect(clickOutsideSpy.mock.calls.length).toBe(0);
75+
76+
simulateClick(nestedNode);
77+
expect(clickInsideSpy.mock.calls.length).toBe(2);
78+
expect(clickOutsideSpy.mock.calls.length).toBe(0);
79+
80+
// Stop propagation in the outside node should not prevent the
81+
// handleOutsideClick handler from being called
82+
outsideNode.addEventListener('click', e => e.stopPropagation());
83+
84+
const event = simulateClick(outsideNode);
85+
expect(clickOutsideSpy).toHaveBeenCalledWith(event);
86+
});
87+
88+
it('does not call handleClickOutside when unmounted', () => {
89+
const clickOutsideSpy = jest.fn();
90+
91+
const EnhancedComponent = () => {
92+
const ref = useClickOutside(clickOutsideSpy);
93+
return <div ref={ref} />;
94+
};
95+
96+
class Root extends React.Component {
97+
constructor() {
98+
super();
99+
this.state = {
100+
showEnhancedComponent: true,
101+
};
102+
}
103+
104+
render() {
105+
return (
106+
<div>
107+
{this.state.showEnhancedComponent && <EnhancedComponent />}
108+
<div id="outsideNode" />
109+
</div>
110+
);
111+
}
112+
}
113+
114+
wrapper = mount(<Root />);
115+
const outsideNode = wrapper.find('#outsideNode').getDOMNode();
116+
117+
expect(clickOutsideSpy.mock.calls.length).toBe(0);
118+
simulateClick(outsideNode);
119+
expect(clickOutsideSpy.mock.calls.length).toBe(1);
120+
121+
wrapper.setState({ showEnhancedComponent: false });
122+
123+
simulateClick(outsideNode);
124+
expect(clickOutsideSpy.mock.calls.length).toBe(1);
125+
});
126+
});
File renamed without changes.

0 commit comments

Comments
 (0)