Skip to content

Commit f32f16b

Browse files
committed
Add useClickAway hook
1 parent e6ab9d7 commit f32f16b

File tree

2 files changed

+111
-0
lines changed

2 files changed

+111
-0
lines changed
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { mount } from 'enzyme';
2+
import { useRef } from 'preact/hooks';
3+
import { act } from 'preact/test-utils';
4+
5+
import { useClickAway } from '../use-click-away';
6+
7+
describe('useClickAway', () => {
8+
let handler;
9+
10+
const events = [new Event('mousedown'), new Event('click')];
11+
12+
// Create a fake component to mount in tests that uses the hook
13+
function FakeComponent({ enabled = true }) {
14+
const myRef = useRef();
15+
useClickAway(myRef, handler, { enabled });
16+
return (
17+
<div ref={myRef}>
18+
<button>Hi</button>
19+
</div>
20+
);
21+
}
22+
23+
function createComponent(props) {
24+
return mount(<FakeComponent {...props} />);
25+
}
26+
27+
beforeEach(() => {
28+
handler = sinon.stub();
29+
});
30+
31+
events.forEach(event => {
32+
it(`should invoke callback once for events outside of element (${event.type})`, () => {
33+
const wrapper = createComponent();
34+
35+
act(() => {
36+
document.body.dispatchEvent(event);
37+
});
38+
wrapper.update();
39+
40+
assert.calledOnce(handler);
41+
42+
wrapper.setProps({ enabled: false });
43+
44+
act(() => {
45+
document.body.dispatchEvent(event);
46+
});
47+
48+
// Cleanup of hook should have removed eventListeners, so the callback
49+
// is not called again
50+
assert.calledOnce(handler);
51+
});
52+
});
53+
54+
events.forEach(event => {
55+
it(`should not invoke callback on events inside of container (${event.type})`, () => {
56+
const wrapper = createComponent();
57+
const button = wrapper.find('button');
58+
59+
act(() => {
60+
button.getDOMNode().dispatchEvent(event);
61+
});
62+
wrapper.update();
63+
64+
assert.equal(handler.callCount, 0);
65+
});
66+
});
67+
});

src/hooks/use-click-away.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type { RefObject } from 'preact';
2+
import { useEffect } from 'preact/hooks';
3+
4+
import { ListenerCollection } from '../util/listener-collection';
5+
6+
type UseClickAwayOptions = {
7+
/** Enable listening for away-click events? Can be set to false to disable */
8+
enabled?: boolean;
9+
};
10+
11+
/**
12+
* Listen on document.body for click events. If a click event's target is
13+
* outside of the `container` element, invoke the `callback`. Do not listen if
14+
* not `enabled`.
15+
*/
16+
export function useClickAway(
17+
container: RefObject<HTMLElement | undefined>,
18+
callback: (e: Event) => void,
19+
{ enabled = true }: UseClickAwayOptions = {}
20+
) {
21+
useEffect(() => {
22+
if (!enabled) {
23+
return () => {};
24+
}
25+
const target = document.body;
26+
const listeners = new ListenerCollection();
27+
28+
const handleAwayClick = (event: Event) => {
29+
if (
30+
container.current &&
31+
!container.current.contains(event.target as Node)
32+
) {
33+
callback(event);
34+
}
35+
};
36+
37+
listeners.add(target, 'mousedown', handleAwayClick);
38+
listeners.add(target, 'click', handleAwayClick);
39+
40+
return () => {
41+
listeners.removeAll();
42+
};
43+
}, [container, enabled, callback]);
44+
}

0 commit comments

Comments
 (0)