Skip to content

Commit e6ab9d7

Browse files
committed
Add useFocusAway hook
1 parent 6b472fa commit e6ab9d7

File tree

2 files changed

+120
-0
lines changed

2 files changed

+120
-0
lines changed
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { mount } from 'enzyme';
2+
import { useRef } from 'preact/hooks';
3+
import { act } from 'preact/test-utils';
4+
5+
import { useFocusAway } from '../use-focus-away';
6+
7+
describe('useFocusAway', () => {
8+
let handler;
9+
10+
const events = [new Event('focus')];
11+
12+
// Create a fake component to mount in tests that uses the hook
13+
function FakeComponent({ enabled = true }) {
14+
const myRef = useRef();
15+
useFocusAway(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+
// Update the component to change it and re-execute the hook
43+
wrapper.setProps({ enabled: false });
44+
45+
act(() => {
46+
document.body.dispatchEvent(new Event('focus'));
47+
});
48+
49+
// Cleanup of hook should have removed eventListeners, so the callback
50+
// is not called again
51+
assert.calledOnce(handler);
52+
});
53+
});
54+
55+
events.forEach(event => {
56+
it(`should not invoke callback on events inside of container (${event.type})`, () => {
57+
const wrapper = createComponent();
58+
const button = wrapper.find('button');
59+
60+
act(() => {
61+
button.getDOMNode().dispatchEvent(event);
62+
});
63+
wrapper.update();
64+
65+
assert.equal(handler.callCount, 0);
66+
});
67+
});
68+
});

src/hooks/use-focus-away.ts

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

0 commit comments

Comments
 (0)