Skip to content

Commit

Permalink
feat: new hook useClickOutside (#147)
Browse files Browse the repository at this point in the history
* feat: new hook `useClickOutside`

* Fix a typo

Co-authored-by: Joe Duncko <[email protected]>
  • Loading branch information
xobotyi and JoeDuncko authored Jun 24, 2021
1 parent c201f10 commit 3dece07
Show file tree
Hide file tree
Showing 7 changed files with 242 additions and 0 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,8 @@ import { useMountEffect } from "@react-hookz/web/esnext";

- #### Dom

- [**`useClickOutside`**](https://react-hookz.github.io/web/?path=/docs/dom-useclickoutside)
— Triggers callback when user clicks outside the target element.
- [**`useDocumentTitle`**](https://react-hookz.github.io/web/?path=/docs/dom-usedocumenttitle)
— Sets title of the page.
- [**`useEventListener`**](https://react-hookz.github.io/web/?path=/docs/dom-useeventlistener)
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,5 +75,6 @@ export { useMeasure } from './useMeasure/useMeasure';
export { useMediaQuery } from './useMediaQuery/useMediaQuery';

// Dom
export { useClickOutside } from './useClickOutside/useClickOutside';
export { useDocumentTitle, IUseDocumentTitleOptions } from './useDocumentTitle/useDocumentTitle';
export { useEventListener } from './useEventListener/useEventListener';
49 changes: 49 additions & 0 deletions src/useClickOutside/__docs__/example.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import * as React from 'react';
import { useRef } from 'react';
import { useClickOutside } from '../useClickOutside';
import { useToggle } from '../../useToggle/useToggle';

export const Example: React.FC = () => {
const [toggled, toggle] = useToggle();

const ToggledComponent = () => {
const ref = useRef(null);

useClickOutside(ref, () => {
// eslint-disable-next-line no-alert
window.alert('told ya!');
toggle();
});

return (
<div
ref={ref}
style={{
background: 'maroon',
width: 200,
height: 200,
cursor: 'pointer',
padding: 16,
fontWeight: 'bold',
color: 'rgba(255, 255, 255, .95)',
fontFamily: 'sans-serif',
}}>
DO NOT
<br />
CLICK OUTSIDE
<br />
THE RED SQUARE!
</div>
);
};

return (
<div>
<div>Let&apos;s try some reverse psychology =)</div>
<br />

{!toggled && <button onClick={() => toggle()}>Let&apos;s try!</button>}
{toggled && <ToggledComponent />}
</div>
);
};
36 changes: 36 additions & 0 deletions src/useClickOutside/__docs__/story.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Canvas, Meta, Story } from '@storybook/addon-docs/blocks';
import { Example } from './example.stories';

<Meta title="DOM/useClickOutside" component={Example} />

# useClickOutside

Triggers callback when user clicks outside the target element.

> Hook listens for `document` events, therefore if any listener between event target and `document`
> stopped event propagation - hook won't work.
> By default, `mousedown` and `touchstart` events used, but it can be any bubbling event.
#### Example

<Canvas>
<Story story={Example} inline />
</Canvas>

## Reference

```ts
export function useClickOutside<T extends HTMLElement>(
ref: RefObject<T> | MutableRefObject<T>,
callback: EventListener,
events: string[] = DEFAULT_EVENTS
): void;
```

#### Arguments

- **ref** _`RefObject<T> | MutableRefObject<T>`_ - React ref object with target HTML element.
- **callback** _`EventListener`_ - Callback that will be triggered during the click.
- **events**: _`string[]`_ _(default: `['mousedown', 'touchstart']`)_ - Events list that will be
used as triggers for outside click.
99 changes: 99 additions & 0 deletions src/useClickOutside/__tests__/dom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { renderHook } from '@testing-library/react-hooks/dom';
import { MutableRefObject } from 'react';
import { useClickOutside } from '../..';

describe('useClickOutside', () => {
it('should be defined', () => {
expect(useClickOutside).toBeDefined();
});

it('should render', () => {
const { result } = renderHook(() => useClickOutside({ current: null }, () => {}));
expect(result.error).toBeUndefined();
});

it('should bind document listener on mount and unbind on unmount', () => {
const div = document.createElement('div');
const addSpy = jest.spyOn(document, 'addEventListener');
const removeSpy = jest.spyOn(document, 'removeEventListener');

const { rerender, unmount } = renderHook(() => useClickOutside({ current: div }, () => {}));

expect(addSpy).toHaveBeenCalledTimes(2);
expect(removeSpy).toHaveBeenCalledTimes(0);

rerender();
expect(addSpy).toHaveBeenCalledTimes(2);
expect(removeSpy).toHaveBeenCalledTimes(0);

unmount();
expect(addSpy).toHaveBeenCalledTimes(2);
expect(removeSpy).toHaveBeenCalledTimes(2);

addSpy.mockRestore();
removeSpy.mockRestore();
});

it('should bind any events passed as 3rd parameter', () => {
const div = document.createElement('div');
const addSpy = jest.spyOn(document, 'addEventListener');
const removeSpy = jest.spyOn(document, 'removeEventListener');

const { unmount } = renderHook(() => useClickOutside({ current: div }, () => {}, ['click']));

expect(addSpy).toHaveBeenCalledTimes(1);
expect(removeSpy).toHaveBeenCalledTimes(0);

unmount();
expect(addSpy).toHaveBeenCalledTimes(1);
expect(removeSpy).toHaveBeenCalledTimes(1);

addSpy.mockRestore();
removeSpy.mockRestore();
});

it('should invoke callback if event target is not a child of target', () => {
const div = document.createElement('div');
const div2 = document.createElement('div2');
const spy = jest.fn();

renderHook(() => useClickOutside({ current: div }, spy));

document.body.append(div, div2);

div2.dispatchEvent(new Event('mousedown', { bubbles: true }));
expect(spy).toHaveBeenCalledTimes(1);
});

it('should not execute callback if event target is a child of target', () => {
const div = document.createElement('div');
const div2 = document.createElement('div2');
const spy = jest.fn();

renderHook(() => useClickOutside({ current: div }, spy));

document.body.append(div);
div.append(div2);

div2.dispatchEvent(new Event('mousedown', { bubbles: true }));
expect(spy).not.toHaveBeenCalled();
});

it('should not execute callback if target is unmounted', () => {
const div = document.createElement('div');
const div2 = document.createElement('div2');
const spy = jest.fn();
const ref: MutableRefObject<HTMLDivElement | null> = { current: div };

const { rerender } = renderHook(() => useClickOutside(ref, spy));

document.body.append(div);
div.append(div2);

ref.current = null;
rerender();

div2.dispatchEvent(new Event('mousedown', { bubbles: true }));
expect(spy).not.toHaveBeenCalled();
});
});
13 changes: 13 additions & 0 deletions src/useClickOutside/__tests__/ssr.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { renderHook } from '@testing-library/react-hooks/server';
import { useClickOutside } from '../..';

describe('useClickOutside', () => {
it('should be defined', () => {
expect(useClickOutside).toBeDefined();
});

it('should render', () => {
const { result } = renderHook(() => useClickOutside({ current: null }, () => {}));
expect(result.error).toBeUndefined();
});
});
42 changes: 42 additions & 0 deletions src/useClickOutside/useClickOutside.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { MutableRefObject, RefObject, useEffect } from 'react';
import { off, on } from '../util/misc';
import { useSyncedRef } from '..';

const DEFAULT_EVENTS = ['mousedown', 'touchstart'];

/**
* Triggers callback when user clicks outside the target element.
*
* @param ref React ref object with target HTML element.
* @param callback Callback that will be triggered during the click.
* @param events Events list that will be used as triggers for outside click.
* Default: 'mousedown', 'touchstart'
*/
export function useClickOutside<T extends HTMLElement>(
ref: RefObject<T> | MutableRefObject<T>,
callback: EventListener,
events: string[] = DEFAULT_EVENTS
): void {
const cbRef = useSyncedRef(callback);
const refRef = useSyncedRef(ref);

useEffect(() => {
function handler(this: HTMLElement, event: Event) {
if (!refRef.current.current) return;

const { target: evtTarget } = event;
const cb = cbRef.current;

if (!evtTarget || (!!evtTarget && !refRef.current.current.contains(evtTarget as Node))) {
cb.call(this, event);
}
}

events.forEach((name) => on(document, name, handler, { passive: true }));

return () => {
events.forEach((name) => off(document, name, handler, { passive: true }));
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...events]);
}

0 comments on commit 3dece07

Please sign in to comment.