diff --git a/package.json b/package.json
index 983c717..40bceb6 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@nearform/react-browser-hooks",
- "version": "2.1.0",
+ "version": "2.2.0",
"description": "react-browser-hooks React component",
"main": "lib/index.js",
"module": "es/index.js",
diff --git a/src/hooks/click-outside.js b/src/hooks/click-outside.js
new file mode 100644
index 0000000..042ee4b
--- /dev/null
+++ b/src/hooks/click-outside.js
@@ -0,0 +1,32 @@
+import { useEffect } from 'react'
+
+export function useClickOutside(el, options = {}, onClick) {
+ const els = [].concat(el)
+ let active = true
+
+ if (!onClick && typeof options === 'function') {
+ onClick = options
+ } else {
+ active = options.active
+ }
+
+ const handler = (ev) => {
+ const target = ev.target
+
+ if (els.every((ref) => !ref.current || !ref.current.contains(target))) {
+ onClick(ev)
+ }
+ }
+
+ const cleanup = () => window.removeEventListener('click', handler)
+
+ useEffect(() => {
+ if (active) {
+ window.addEventListener('click', handler)
+ } else {
+ cleanup()
+ }
+
+ return cleanup
+ })
+}
diff --git a/src/index.js b/src/index.js
index 0a35c65..74a6f81 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1,3 +1,4 @@
+export * from './hooks/click-outside'
export * from './hooks/fullscreen'
export * from './hooks/geolocation'
export * from './hooks/mouse-position'
diff --git a/storybook/stories/click-outside/README.md b/storybook/stories/click-outside/README.md
new file mode 100644
index 0000000..d84178d
--- /dev/null
+++ b/storybook/stories/click-outside/README.md
@@ -0,0 +1,35 @@
+## Click Outside Hook
+
+The Click Outside Hook attaches a listener which will callback the target component with the event object on any click which is not on the target component, or a child of the target component.
+
+Import as follows:
+
+```javascript
+import { useClickOutside } from '@nearform/react-browser-hooks'
+```
+
+Example of usage:
+
+```javascript
+useClickOutside(ref, onClick)
+```
+
+Callback is invoked with the original event as the only argument
+
+Also supported is passing an array of refs, where `onClick` will only be called if the click target is outside _all_ of the components referenced.
+
+```javascript
+useClickOutside([ref, siblingRef], onClick)
+```
+
+Avoiding unnecessary callbacks:
+
+If you have a large app with many components using this hook, you may wish to avoid calling the callback when not necessary. In this situation you can pass options as the second argument, and include an `active` property. The callback will not be invoked if this is falsey. For example, if you you had a dropdown where you are only interested in receiving a callback if the options are visible, you might use like:
+
+```javascript
+const [optionsVisible, setOptionsVisible] = useState(false)
+const hideOptions = () => setOptionsVisible(false)
+useClickOutside(ref, { active: optionsVisible }, hideOptions)
+```
+
+In this example, `hideOptions` will never be called if `optionsVisible` is already `false`.
diff --git a/storybook/stories/click-outside/click-outside.stories.js b/storybook/stories/click-outside/click-outside.stories.js
new file mode 100644
index 0000000..1f4a989
--- /dev/null
+++ b/storybook/stories/click-outside/click-outside.stories.js
@@ -0,0 +1,46 @@
+import React, { useRef, useState } from 'react'
+import { storiesOf } from '@storybook/react'
+import { withReadme } from 'storybook-readme'
+import { useClickOutside } from '../../../src'
+import readme from './README.md'
+
+function ClickOutside() {
+ const elRef = useRef(null)
+ const [clickEvent, setClickEvent] = useState(null)
+ useClickOutside(elRef, (ev) => {
+ setClickEvent(ev)
+ })
+
+ const message =
+ clickEvent === null
+ ? ''
+ : `Clicked outside (x: ${clickEvent.clientX}, y: ${clickEvent.clientY})`
+
+ return (
+ <>
+
Click Outside Demo
+ Click outside target component receives full event
+ setClickEvent(null)}>
+ {message}
+
+ >
+ )
+}
+
+storiesOf('Click Outside', module).add(
+ 'Default',
+ withReadme(readme, () => )
+)
diff --git a/test/unit/hooks/click-outside.test.js b/test/unit/hooks/click-outside.test.js
new file mode 100644
index 0000000..f1f2093
--- /dev/null
+++ b/test/unit/hooks/click-outside.test.js
@@ -0,0 +1,215 @@
+import React, { forwardRef, createRef } from 'react'
+import { cleanup, fireEvent, render } from 'react-testing-library'
+import { act } from 'react-dom/test-utils'
+
+import { useClickOutside } from '../../../src'
+
+let callback
+let testElementRef
+let childElementRef
+let siblingRef
+let testHook
+let testHookWithSibling
+let testHookWithActiveState
+let active
+
+beforeEach(() => {
+ testElementRef = createRef()
+ childElementRef = createRef()
+ siblingRef = createRef()
+ const TestChildComponent = forwardRef((props, ref) => {
+ return
+ })
+
+ const TestHook = forwardRef(({ callback }, ref) => {
+ useClickOutside(ref, callback)
+ return (
+
+
+
+ )
+ })
+
+ const TestHookWithActiveState = forwardRef(({ callback }, ref) => {
+ useClickOutside(ref, { active }, callback)
+ return (
+
+
+
+ )
+ })
+
+ const TestHookWithSibling = forwardRef(({ callback }, ref) => {
+ useClickOutside([ref, siblingRef], callback)
+ return (
+ <>
+
+
+
+
+ >
+ )
+ })
+
+ testHook = (callback) => {
+ render()
+ }
+
+ testHookWithActiveState = (callback) => {
+ render()
+ }
+
+ testHookWithSibling = (callback) => {
+ render()
+ }
+
+ callback = jest.fn()
+})
+
+afterEach(cleanup)
+
+describe('useClickOutside', () => {
+ it('calls callback with click event on clicking outside the component', () => {
+ testHook(callback)
+ act(() => {
+ fireEvent(
+ document.body,
+ new Event('click', {
+ bubbles: true,
+ cancelable: false
+ })
+ )
+ })
+
+ expect(callback).toBeCalledTimes(1)
+ expect(callback.mock.calls[0].length).toBe(1)
+ expect(callback.mock.calls[0][0] instanceof Event).toBe(true)
+ expect(callback.mock.calls[0][0].type).toBe('click')
+ })
+
+ it('calls callback if calling component is active', () => {
+ active = true
+ testHookWithActiveState(callback)
+ act(() => {
+ fireEvent(
+ document.body,
+ new Event('click', {
+ bubbles: true,
+ cancelable: false
+ })
+ )
+ })
+
+ expect(callback).toBeCalledTimes(1)
+ expect(callback.mock.calls[0].length).toBe(1)
+ expect(callback.mock.calls[0][0] instanceof Event).toBe(true)
+ expect(callback.mock.calls[0][0].type).toBe('click')
+ })
+
+ it('does not call callback if calling component is inactive', () => {
+ active = false
+ testHookWithActiveState(callback)
+ act(() => {
+ fireEvent(
+ document.body,
+ new Event('click', {
+ bubbles: true,
+ cancelable: false
+ })
+ )
+ })
+
+ expect(callback).toBeCalledTimes(0)
+ })
+
+ it('does not call callback when the component itself receives a click', () => {
+ testHook(callback)
+ act(() => {
+ fireEvent(
+ testElementRef.current,
+ new Event('click', {
+ bubbles: true,
+ cancelable: false
+ })
+ )
+ })
+
+ expect(callback).toBeCalledTimes(0)
+ })
+
+ it('does not call callback when a child receives a click', () => {
+ testHook(callback)
+ act(() => {
+ fireEvent(
+ childElementRef.current,
+ new Event('click', {
+ bubbles: true,
+ cancelable: false
+ })
+ )
+ })
+
+ expect(callback).toBeCalledTimes(0)
+ })
+
+ it('supports array of refs, and will call callback if target is not contained by any', () => {
+ testHookWithSibling(callback)
+ act(() => {
+ fireEvent(
+ document.body,
+ new Event('click', {
+ bubbles: true,
+ cancelable: false
+ })
+ )
+ })
+
+ expect(callback).toBeCalledTimes(1)
+ expect(callback.mock.calls[0].length).toBe(1)
+ expect(callback.mock.calls[0][0] instanceof Event).toBe(true)
+ expect(callback.mock.calls[0][0].type).toBe('click')
+ })
+
+ it('handles null ref.current', () => {
+ siblingRef.current = null
+ testHookWithSibling(callback)
+ act(() => {
+ fireEvent(
+ document.body,
+ new Event('click', {
+ bubbles: true,
+ cancelable: false
+ })
+ )
+ })
+
+ expect(callback).toBeCalledTimes(1)
+ expect(callback.mock.calls[0].length).toBe(1)
+ expect(callback.mock.calls[0][0] instanceof Event).toBe(true)
+ expect(callback.mock.calls[0][0].type).toBe('click')
+ })
+
+ it('supports array of refs, and will not call callback if target is contained by any', () => {
+ testHookWithSibling(callback)
+ act(() => {
+ fireEvent(
+ siblingRef.current,
+ new Event('click', {
+ bubbles: true,
+ cancelable: false
+ })
+ )
+ })
+ act(() => {
+ fireEvent(
+ testElementRef.current,
+ new Event('click', {
+ bubbles: true,
+ cancelable: false
+ })
+ )
+ })
+
+ expect(callback).toBeCalledTimes(0)
+ })
+})