Skip to content

Commit 2f582fe

Browse files
committed
Refactor useFocusAway to internally handle focusout event
1 parent 8999f12 commit 2f582fe

File tree

2 files changed

+64
-55
lines changed

2 files changed

+64
-55
lines changed

src/hooks/test/use-focus-away-test.js

Lines changed: 48 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,17 @@ import { useFocusAway } from '../use-focus-away';
77
describe('useFocusAway', () => {
88
let handler;
99

10-
const events = [new Event('focus')];
11-
1210
// Create a fake component to mount in tests that uses the hook
1311
function FakeComponent({ enabled = true }) {
1412
const myRef = useRef();
1513
const hookOpts = enabled === true ? undefined : { enabled };
1614
useFocusAway(myRef, handler, hookOpts);
1715
return (
18-
<div ref={myRef}>
19-
<button>Hi</button>
16+
<div>
17+
<button data-testid="outer-button">Hi</button>
18+
<div ref={myRef} data-testid="container">
19+
<button data-testid="inner-button">Hi</button>
20+
</div>
2021
</div>
2122
);
2223
}
@@ -25,45 +26,62 @@ describe('useFocusAway', () => {
2526
return mount(<FakeComponent {...props} />);
2627
}
2728

29+
function getContainer(wrapper) {
30+
return wrapper.find('[data-testid="container"]').getDOMNode();
31+
}
32+
33+
function getOuterButton(wrapper) {
34+
return wrapper.find('[data-testid="outer-button"]').getDOMNode();
35+
}
36+
37+
function getInnerButton(wrapper) {
38+
return wrapper.find('[data-testid="inner-button"]').getDOMNode();
39+
}
40+
2841
beforeEach(() => {
2942
handler = sinon.stub();
3043
});
3144

32-
events.forEach(event => {
33-
it(`should invoke callback once for events outside of element (${event.type})`, () => {
34-
const wrapper = createComponent();
35-
45+
it('should invoke callback when focus moves outside of container', () => {
46+
const wrapper = createComponent();
47+
const focusOut = () =>
3648
act(() => {
37-
document.body.dispatchEvent(event);
49+
getInnerButton(wrapper).dispatchEvent(
50+
new FocusEvent('focusout', {
51+
bubbles: true,
52+
relatedTarget: getOuterButton(wrapper),
53+
}),
54+
);
3855
});
39-
wrapper.update();
4056

41-
assert.calledOnce(handler);
57+
focusOut();
58+
wrapper.update();
4259

43-
// Update the component to change it and re-execute the hook
44-
wrapper.setProps({ enabled: false });
60+
assert.calledOnce(handler);
4561

46-
act(() => {
47-
document.body.dispatchEvent(new Event('focus'));
48-
});
62+
// Update the component to change it and re-execute the hook
63+
wrapper.setProps({ enabled: false });
4964

50-
// Cleanup of hook should have removed eventListeners, so the callback
51-
// is not called again
52-
assert.calledOnce(handler);
53-
});
54-
});
65+
focusOut();
5566

56-
events.forEach(event => {
57-
it(`should not invoke callback on events inside of container (${event.type})`, () => {
58-
const wrapper = createComponent();
59-
const button = wrapper.find('button');
67+
// Cleanup of hook should have removed eventListeners, so the callback
68+
// is not called again
69+
assert.calledOnce(handler);
70+
});
6071

61-
act(() => {
62-
button.getDOMNode().dispatchEvent(event);
63-
});
64-
wrapper.update();
72+
it('should not invoke callback when focus moves inside of container', () => {
73+
const wrapper = createComponent();
6574

66-
assert.equal(handler.callCount, 0);
75+
act(() => {
76+
getContainer(wrapper).dispatchEvent(
77+
new FocusEvent('focusout', {
78+
bubbles: true,
79+
relatedTarget: getInnerButton(wrapper),
80+
}),
81+
);
6782
});
83+
wrapper.update();
84+
85+
assert.notCalled(handler);
6886
});
6987
});

src/hooks/use-focus-away.ts

Lines changed: 16 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,46 +4,37 @@ import { useEffect } from 'preact/hooks';
44
import { ListenerCollection } from '../util/listener-collection';
55

66
type UseFocusAwayOptions = {
7-
/**
8-
* Enable listening for focus events outside of `container`? Can be set to
9-
* false to disable
10-
*/
7+
/** Enable listening for focusout events in `container`? */
118
enabled?: boolean;
129
};
1310

1411
/**
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`.
12+
* Listen on container for focusout events. If a focusout event's relatedTarget
13+
* is outside of the `container` element, invoke the `callback`.
14+
* Do not listen if not `enabled`.
1815
*/
1916
export function useFocusAway(
2017
container: RefObject<HTMLElement | undefined>,
2118
callback: (e: FocusEvent) => void,
2219
{ enabled = true }: UseFocusAwayOptions = {},
2320
) {
2421
useEffect(() => {
25-
if (!enabled) {
22+
if (!enabled || !container.current) {
2623
return () => {};
2724
}
28-
const target = document.body;
2925
const listeners = new ListenerCollection();
3026

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-
);
27+
listeners.add(container.current, 'focusout', e => {
28+
// Event type is not being properly inferred as FocusEvent
29+
const event = e as FocusEvent;
30+
31+
if (
32+
container.current &&
33+
!container.current.contains(event.relatedTarget as Node)
34+
) {
35+
callback(event);
36+
}
37+
});
4738

4839
return () => {
4940
listeners.removeAll();

0 commit comments

Comments
 (0)