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

Introduce useClickOutside hook #48

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
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
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,24 @@ module.exports = enhanceWithClickOutside(Dropdown);
**Note:** There will be no error thrown if `handleClickOutside` is not
implemented.

### hook

```js
const { useClickOutside } = require('react-click-outside');
const React = require('react');

const Dropdown = () => {
const [isOpened, setIsOpened] = React.useState(false);
const onClickOutside = React.useCallback((e) => {
setIsOpened(false);
}, [isOpened]);
const clickOutsideRef = useClickOutside(onClickOutside);
return <div ref={clickOutsideRef}>...</div>;
};

module.exports = Dropdown;
```

### `wrappedRef` prop

Use the `wrappedRef` prop to get access to the wrapped component instance. For
Expand Down
37 changes: 37 additions & 0 deletions demo/hook.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
const React = require('react');
const ReactDOM = require('react-dom');
const { useClickOutside } = require('../index');

const style = {
backgroundColor: '#fff',
border: '1px solid #000',
height: 100,
width: 100,
};

const Target = () => {
const handleClickOutside = React.useCallback(() => {
const hue = Math.floor(Math.random() * 360);
document.body.style.backgroundColor = `hsl(${hue}, 100%, 87.5%)`;
}, []);
const clickOutsideRef = useClickOutside(handleClickOutside);
const isMobile = 'ontouchstart' in document.body;
return <div ref={clickOutsideRef} style={style}>{`mobile: ${isMobile}`}</div>;
};

const Root = () => (
<div>
<Target />
<button style={style}>Button Element</button>
</div>
);

if ('ontouchstart' in document.documentElement) {
document.body.style.cursor = 'pointer';
document.documentElement.style.touchAction = 'manipulation';
}

const root = document.createElement('div');
document.body.appendChild(root);

ReactDOM.render(<Root />, root);
35 changes: 31 additions & 4 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ const React = require('react');
const ReactDOM = require('react-dom');

function enhanceWithClickOutside(Component: React.ComponentType<*>) {
const componentName = Component.displayName || Component.name;
const componentName =
Component.displayName || Component.name || 'WrappedComponent';

class EnhancedComponent extends React.Component<*> {
__domNode: *;
Expand All @@ -27,12 +28,13 @@ function enhanceWithClickOutside(Component: React.ComponentType<*>) {

handleClickOutside(e) {
const domNode = this.__domNode;
const wrappedInstance: any = this.__wrappedInstance;
if (
(!domNode || !domNode.contains(e.target)) &&
this.__wrappedInstance &&
typeof this.__wrappedInstance.handleClickOutside === 'function'
wrappedInstance &&
typeof wrappedInstance.handleClickOutside === 'function'
) {
this.__wrappedInstance.handleClickOutside(e);
wrappedInstance.handleClickOutside(e);
}
}

Expand All @@ -57,4 +59,29 @@ function enhanceWithClickOutside(Component: React.ComponentType<*>) {
return hoistNonReactStatic(EnhancedComponent, Component);
}

function useClickOutside(onClickOutside: (e: Event) => void) {
const [domNode, setDomNode] = React.useState(null);

React.useEffect(
() => {
const onClick = (e: Event) => {
if ((!domNode || !domNode.contains(e.target)) && onClickOutside)
onClickOutside(e);
};

document.addEventListener('click', onClick, true);
return () => {
document.removeEventListener('click', onClick, true);
};
},
[domNode, onClickOutside]
);

const refCallback = React.useCallback(setDomNode, [onClickOutside]);

return refCallback;
}

enhanceWithClickOutside.useClickOutside = useClickOutside;

module.exports = enhanceWithClickOutside;
11 changes: 6 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"scripts": {
"build": "npm test && babel -d dist index.js",
"demo": "budo demo/app.js -- -t babelify",
"demo:hook": "budo demo/hook.js -- -t babelify",
"lint": "eslint .",
"test": "jest",
"test:ci": "flow && npm run lint && npm run test"
Expand Down Expand Up @@ -38,14 +39,14 @@
"budo": "10.0.4",
"create-react-class": "15.6.2",
"enzyme": "3.0.0",
"enzyme-adapter-react-16": "1.0.0",
"enzyme-adapter-react-16": "^1.13.2",
"eslint": "4.8.0",
"eslint-config-kentor": "5.1.0",
"flow-bin": "0.65.0",
"flow-bin": "^0.98.1",
"jest": "21.2.1",
"react": "16.0.0",
"react-dom": "16.0.0",
"react-test-renderer": "16.0.0"
"react": "^16.8.6",
"react-dom": "^16.8.6",
"react-test-renderer": "^16.8.6"
},
"dependencies": {
"hoist-non-react-statics": "^2.1.1"
Expand Down
126 changes: 126 additions & 0 deletions test/hook.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
global.requestAnimationFrame = callback => setTimeout(callback, 0);

const Adapter = require('enzyme-adapter-react-16');
const { configure } = require('enzyme');

configure({ adapter: new Adapter() });

const enzyme = require('enzyme');
const React = require('react');
const { useClickOutside } = require('../index');

const mountNode = document.createElement('div');
document.body.appendChild(mountNode);

function mount(element) {
return enzyme.mount(element, { attachTo: mountNode });
}

function simulateClick(node) {
const event = new window.MouseEvent('click', {
bubbles: true,
cancelable: true,
view: window,
});
node.dispatchEvent(event);
return event;
}

describe('useClickOutside', () => {
let wrapper;

beforeEach(() => {
wrapper = undefined;
});

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

it('calls handleClickOutside when clicked outside of component', () => {
const clickInsideSpy = jest.fn();
const clickOutsideSpy = jest.fn();

const EnhancedComponent = () => {
const ref = useClickOutside(clickOutsideSpy);
return (
<div ref={ref} id="enhancedNode" onClick={clickInsideSpy}>
<div id="nestedNode" />
</div>
);
};

class Root extends React.Component {
render() {
return (
<div>
<EnhancedComponent />
<div id="outsideNode" />
</div>
);
}
}

wrapper = mount(<Root />);

const enhancedNode = wrapper.find('#enhancedNode').getDOMNode();
const nestedNode = wrapper.find('#nestedNode').getDOMNode();
const outsideNode = wrapper.find('#outsideNode').getDOMNode();

simulateClick(enhancedNode);
expect(clickInsideSpy.mock.calls.length).toBe(1);
expect(clickOutsideSpy.mock.calls.length).toBe(0);

simulateClick(nestedNode);
expect(clickInsideSpy.mock.calls.length).toBe(2);
expect(clickOutsideSpy.mock.calls.length).toBe(0);

// Stop propagation in the outside node should not prevent the
// handleOutsideClick handler from being called
outsideNode.addEventListener('click', e => e.stopPropagation());

const event = simulateClick(outsideNode);
expect(clickOutsideSpy).toHaveBeenCalledWith(event);
});

it('does not call handleClickOutside when unmounted', () => {
const clickOutsideSpy = jest.fn();

const EnhancedComponent = () => {
const ref = useClickOutside(clickOutsideSpy);
return <div ref={ref} />;
};

class Root extends React.Component {
constructor() {
super();
this.state = {
showEnhancedComponent: true,
};
}

render() {
return (
<div>
{this.state.showEnhancedComponent && <EnhancedComponent />}
<div id="outsideNode" />
</div>
);
}
}

wrapper = mount(<Root />);
const outsideNode = wrapper.find('#outsideNode').getDOMNode();

expect(clickOutsideSpy.mock.calls.length).toBe(0);
simulateClick(outsideNode);
expect(clickOutsideSpy.mock.calls.length).toBe(1);

wrapper.setState({ showEnhancedComponent: false });

simulateClick(outsideNode);
expect(clickOutsideSpy.mock.calls.length).toBe(1);
});
});
File renamed without changes.
Loading