Skip to content

Commit b4e1ce3

Browse files
Merge pull request #8 from commitd/sh-useEventHandler
✨ Adds useEventListener hook
2 parents c8c3d41 + 8d0673e commit b4e1ce3

File tree

5 files changed

+247
-1
lines changed

5 files changed

+247
-1
lines changed

src/hooks/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
export * from './useDebounce'
2+
export * from './useEventListener'
13
export * from './useInterval'
24
export * from './useLocalState'
35
export * from './usePoll'
46
export * from './useTimeout'
57
export * from './useToggle'
6-
export * from './useDebounce'

src/hooks/useEventListener/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './useEventListener'
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { Meta, Story } from '@storybook/react'
2+
import { Button, Typography } from '@committed/components'
3+
import React, { RefObject } from 'react'
4+
import { useEventListener } from '.'
5+
6+
export interface UseEventListenerDocsProps<
7+
T extends HTMLElement = HTMLDivElement
8+
> {
9+
/** the name of the event to listen to */
10+
eventName: string
11+
/** the callback function to call on the event firing */
12+
handler: ((event: Event) => void) | null
13+
/** (optional) reference for the element to add the listener too */
14+
element?: RefObject<T>
15+
}
16+
17+
/**
18+
* useEventListener hook adds an event listener to the given event type and calls the handler when fired.
19+
* The listener is added to the `window` by default or the target element if provided using a ref object.
20+
* It is removed automatically on unmounting.
21+
*
22+
* For event types reference see <https://developer.mozilla.org/en-US/docs/Web/Events>.
23+
*
24+
* (Derived from <https://usehooks-typescript.com/use-event-listener>)
25+
*
26+
* @param eventName the name of the event to listen to
27+
* @param handler the callback function
28+
* @param element (optional) reference for the element
29+
*/
30+
export const UseEventListenerDocs: React.FC<UseEventListenerDocsProps> = () =>
31+
null
32+
33+
export default {
34+
title: 'Hooks/useEventListener',
35+
component: UseEventListenerDocs,
36+
excludeStories: ['UseEventListenerDocs'],
37+
argTypes: {
38+
eventName: {
39+
description:
40+
'the name of the event to listen to (some examples to select from here)',
41+
defaultValue: 'click',
42+
control: {
43+
type: 'select',
44+
options: ['click', 'dblclick', 'auxclick', 'mouseenter', 'mouseout'],
45+
},
46+
},
47+
element: { control: { type: 'none' } },
48+
},
49+
} as Meta
50+
51+
const Template: Story<UseEventListenerDocsProps> = ({
52+
eventName,
53+
}: UseEventListenerDocsProps) => {
54+
const [count, setCount] = React.useState(0)
55+
const divRef = React.useRef<HTMLDivElement>(null)
56+
useEventListener(eventName, () => setCount(count + 1), divRef)
57+
return (
58+
<div>
59+
<Typography>{`Counter: ${count}`}</Typography>
60+
{/* Using div, see https://github.com/commitd/components/issues/68 */}
61+
<div ref={divRef}>
62+
<Button color="primary">{eventName}</Button>
63+
</div>
64+
</div>
65+
)
66+
}
67+
68+
export const Default = Template.bind({})
69+
Default.args = {}
70+
71+
export const WindowAndEvents = () => {
72+
const [x, setX] = React.useState(0)
73+
const [y, setY] = React.useState(0)
74+
useEventListener('mousemove', (event: MouseEvent) => {
75+
setX(event.offsetX)
76+
setY(event.offsetY)
77+
})
78+
return <Typography>{`Mouse Position: ${x}, ${y}`}</Typography>
79+
}
80+
WindowAndEvents.parameters = {
81+
docs: {
82+
description: {
83+
story: `If a ref is not specified then we listen to the \`window\` (or, as here, the \`iframe\`).
84+
85+
The event is passed to the handler and can be the relevant generic extension of \`Event\`.`,
86+
},
87+
},
88+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { renderHook } from '@testing-library/react-hooks'
2+
import { RefObject } from 'react'
3+
import { useEventListener } from '.'
4+
5+
interface Handler {
6+
(e: Event): void
7+
}
8+
9+
test('Should add listener, call handler, and remove listener, all on ref node', () => {
10+
const listeners: Record<string, Handler> = {}
11+
12+
const current = ({
13+
addEventListener: jest.fn((event: string, handler: Handler) => {
14+
listeners[event] = handler
15+
}),
16+
removeEventListener: jest.fn((event: string, _handler: Handler) => {
17+
delete listeners[event]
18+
}),
19+
} as unknown) as HTMLDivElement
20+
21+
const ref = {
22+
current,
23+
} as RefObject<HTMLDivElement>
24+
25+
const handler = jest.fn()
26+
const { unmount } = renderHook(() => useEventListener('click', handler, ref))
27+
28+
expect(listeners.click).toBeTruthy()
29+
30+
const event = ({} as unknown) as Event
31+
expect(handler).toHaveBeenCalledTimes(0)
32+
;(listeners.click as EventListener)(event)
33+
34+
expect(handler).toHaveBeenCalledTimes(1)
35+
expect(handler).toHaveBeenCalledWith(event)
36+
37+
unmount()
38+
39+
expect(listeners.click).toBeFalsy()
40+
})
41+
42+
test('Should add listener, call handler, and remove listener, all on window', () => {
43+
const listeners: Record<string, EventListenerOrEventListenerObject> = {}
44+
45+
jest
46+
.spyOn(window, 'addEventListener')
47+
.mockImplementation(
48+
(
49+
type: string,
50+
listener: EventListenerOrEventListenerObject,
51+
_options?: boolean | AddEventListenerOptions | undefined
52+
) => {
53+
listeners[type] = listener
54+
}
55+
)
56+
jest
57+
.spyOn(window, 'removeEventListener')
58+
.mockImplementation(
59+
(
60+
type: string,
61+
_listener: EventListenerOrEventListenerObject,
62+
_options?: boolean | AddEventListenerOptions | undefined
63+
) => {
64+
delete listeners[type]
65+
}
66+
)
67+
68+
const handler = jest.fn()
69+
const { unmount } = renderHook(() => useEventListener('mouseout', handler))
70+
71+
expect(listeners.mouseout).toBeTruthy()
72+
73+
const event = ({} as unknown) as Event
74+
expect(handler).toHaveBeenCalledTimes(0)
75+
;(listeners.mouseout as EventListener)(event)
76+
77+
expect(handler).toHaveBeenCalledTimes(1)
78+
expect(handler).toHaveBeenCalledWith(event)
79+
80+
unmount()
81+
82+
expect(listeners.mouseout).toBeFalsy()
83+
})
84+
85+
test('Should cope is handler is null', () => {
86+
const listeners: Record<string, Handler> = {}
87+
88+
const current = ({
89+
addEventListener: jest.fn((event: string, handler: Handler) => {
90+
listeners[event] = handler
91+
}),
92+
removeEventListener: jest.fn((event: string, _handler: Handler) => {
93+
delete listeners[event]
94+
}),
95+
} as unknown) as HTMLDivElement
96+
97+
const ref = {
98+
current,
99+
} as RefObject<HTMLDivElement>
100+
const { unmount } = renderHook(() => useEventListener('custom', null, ref))
101+
102+
expect(listeners.custom).toBeTruthy()
103+
104+
const event = ({} as unknown) as Event
105+
106+
;(listeners.custom as EventListener)(event)
107+
108+
unmount()
109+
110+
expect(listeners.custom).toBeFalsy()
111+
})
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { useRef, useEffect, RefObject } from 'react'
2+
3+
/**
4+
* useEventListener hook adds an event listener to the given event type and calls the handler when fired.
5+
* The listener is added to the `window` by default or the target element if provided using a ref object.
6+
* It is removed automatically on unmounting.
7+
*
8+
* For event types reference see <https://developer.mozilla.org/en-US/docs/Web/Events>.
9+
*
10+
* (Derived from <https://usehooks-typescript.com/use-event-listener>)
11+
*
12+
* @param eventName the name of the event to listen to
13+
* @param handler the callback function to call on the event firing
14+
* @param element (optional) reference for the element to add the listener too
15+
*/
16+
export function useEventListener<
17+
T extends HTMLElement = HTMLDivElement,
18+
E extends Event = Event
19+
>(
20+
eventName: string,
21+
handler: ((event: E) => void) | null,
22+
element?: RefObject<T>
23+
): void {
24+
const savedHandler = useRef<((event: E) => void) | null>()
25+
26+
useEffect(() => {
27+
savedHandler.current = handler
28+
}, [handler])
29+
30+
useEffect(() => {
31+
function eventListener(event: Event): void {
32+
const current = savedHandler.current
33+
if (current != null) current(event as E)
34+
}
35+
36+
const targetElement: T | Window = element?.current ?? window
37+
targetElement.addEventListener(eventName, eventListener)
38+
39+
return () => {
40+
targetElement.removeEventListener(eventName, eventListener)
41+
}
42+
// False positive request for E and T
43+
// eslint-disable-next-line react-hooks/exhaustive-deps
44+
}, [eventName, element])
45+
}

0 commit comments

Comments
 (0)