-
Notifications
You must be signed in to change notification settings - Fork 99
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: new hook
useEventListener
(#140)
* feat: new hook `useEventListener` * Fix some typos Co-authored-by: Joe Duncko <[email protected]>
- Loading branch information
Showing
10 changed files
with
264 additions
and
10 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,41 @@ | ||
import * as React from 'react'; | ||
import { useState } from 'react'; | ||
import { useEventListener, useToggle } from '../..'; | ||
|
||
export const Example: React.FC = () => { | ||
const [state, setState] = useState<Date>(); | ||
const [mounted, toggleMounted] = useToggle(true); | ||
|
||
const ToggledComponent = () => { | ||
useEventListener( | ||
window, | ||
'mousemove', | ||
() => { | ||
setState(new Date()); | ||
}, | ||
{ passive: true } | ||
); | ||
|
||
return <div>child component is mounted</div>; | ||
}; | ||
|
||
return ( | ||
<div> | ||
<div> | ||
Below state is update on window's `mousemove` event. | ||
<br /> | ||
You can unmount child component to ensure that event is unsubscribed when component | ||
unmounted. | ||
</div> | ||
|
||
<br /> | ||
<div>{state ? `mouse last moved: ${state}` : 'mouse not moved yet'}</div> | ||
|
||
<br /> | ||
<div> | ||
{mounted && <ToggledComponent />} | ||
<button onClick={() => toggleMounted()}>toggle component</button> | ||
</div> | ||
</div> | ||
); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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/useEventListener" component={Example} /> | ||
|
||
# useEventListener | ||
|
||
Subscribes an event listener to the target, and automatically unsubscribes it on unmount. | ||
|
||
- Automatically unsubscribes on component unmount. | ||
- Properly handle event listener objects. | ||
- SSR-friendly. | ||
- Full TypeScript support for any target type. | ||
|
||
#### Example | ||
|
||
<Canvas> | ||
<Story story={Example} inline /> | ||
</Canvas> | ||
|
||
## Reference | ||
|
||
```ts | ||
export function useEventListener<T extends EventTarget>( | ||
target: ITargetOrTargetRef<T>, | ||
...params: | ||
| Parameters<T['addEventListener']> | ||
| [string, EventListenerOrEventListenerObject, ...any] | ||
): void; | ||
``` | ||
|
||
#### Arguments | ||
|
||
- **target** _`ITargetOrTargetRef`_ - Element ref object or element itself. | ||
- **params** - Parameters specific for target's `addEventListener`. Commonly, it is | ||
`[eventName, listener, options]`. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,89 @@ | ||
/* eslint-disable func-names */ | ||
import { renderHook } from '@testing-library/react-hooks/dom'; | ||
import { useEventListener } from '../..'; | ||
|
||
describe('useEventListener', () => { | ||
it('should be defined', () => { | ||
expect(useEventListener).toBeDefined(); | ||
}); | ||
|
||
it('should render', () => { | ||
const { result } = renderHook(() => useEventListener(null, '', () => {})); | ||
expect(result.error).toBeUndefined(); | ||
}); | ||
|
||
it('should bind listener on mount and unbind on unmount', () => { | ||
const div = document.createElement('div'); | ||
const addSpy = jest.spyOn(div, 'addEventListener'); | ||
const removeSpy = jest.spyOn(div, 'removeEventListener'); | ||
|
||
const { rerender, unmount } = renderHook(() => | ||
useEventListener(div, 'resize', () => {}, { passive: true }) | ||
); | ||
|
||
expect(addSpy).toHaveBeenCalledTimes(1); | ||
expect(removeSpy).toHaveBeenCalledTimes(0); | ||
|
||
rerender(); | ||
expect(addSpy).toHaveBeenCalledTimes(1); | ||
expect(removeSpy).toHaveBeenCalledTimes(0); | ||
|
||
unmount(); | ||
expect(addSpy).toHaveBeenCalledTimes(1); | ||
expect(removeSpy).toHaveBeenCalledTimes(1); | ||
}); | ||
|
||
it('should work with react refs', () => { | ||
const div = document.createElement('div'); | ||
const addSpy = jest.spyOn(div, 'addEventListener'); | ||
const removeSpy = jest.spyOn(div, 'removeEventListener'); | ||
|
||
const { rerender, unmount } = renderHook(() => | ||
useEventListener({ current: div }, 'resize', () => {}, { passive: true }) | ||
); | ||
|
||
expect(addSpy).toHaveBeenCalledTimes(1); | ||
expect(addSpy.mock.calls[0][2]).toStrictEqual({ passive: true }); | ||
expect(removeSpy).toHaveBeenCalledTimes(0); | ||
|
||
rerender(); | ||
expect(addSpy).toHaveBeenCalledTimes(1); | ||
expect(removeSpy).toHaveBeenCalledTimes(0); | ||
|
||
unmount(); | ||
expect(addSpy).toHaveBeenCalledTimes(1); | ||
expect(removeSpy).toHaveBeenCalledTimes(1); | ||
}); | ||
|
||
it('should invoke provided function on event trigger with proper context', () => { | ||
const div = document.createElement('div'); | ||
let context: any; | ||
const spy = jest.fn(function (this: any) { | ||
context = this; | ||
}); | ||
|
||
renderHook(() => useEventListener(div, 'resize', spy, { passive: true })); | ||
|
||
const evt = new Event('resize'); | ||
div.dispatchEvent(evt); | ||
|
||
expect(spy).toHaveBeenCalledWith(evt); | ||
expect(context).toBe(div); | ||
}); | ||
|
||
it('should properly handle event listener objects', () => { | ||
const div = document.createElement('div'); | ||
let context: any; | ||
const spy = jest.fn(function (this: any) { | ||
context = this; | ||
}); | ||
|
||
renderHook(() => useEventListener(div, 'resize', { handleEvent: spy }, { passive: true })); | ||
|
||
const evt = new Event('resize'); | ||
div.dispatchEvent(evt); | ||
|
||
expect(spy).toHaveBeenCalledWith(evt); | ||
expect(context).toBe(div); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
import { renderHook } from '@testing-library/react-hooks/server'; | ||
import { useEventListener } from '../..'; | ||
|
||
describe('useEventListener', () => { | ||
it('should be defined', () => { | ||
expect(useEventListener).toBeDefined(); | ||
}); | ||
|
||
it('should render', () => { | ||
const { result } = renderHook(() => useEventListener(null, 'random name', () => {})); | ||
expect(result.error).toBeUndefined(); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,65 @@ | ||
/* eslint-disable @typescript-eslint/no-explicit-any */ | ||
import { MutableRefObject, RefObject, useEffect, useMemo } from 'react'; | ||
import { hasOwnProperty } from '../util/misc'; | ||
import { useSyncedRef, useIsMounted } from '..'; | ||
|
||
type ITargetOrTargetRef<T extends EventTarget> = T | null | RefObject<T> | MutableRefObject<T>; | ||
|
||
/** | ||
* Subscribes an event listener to the target, and automatically unsubscribes | ||
* it on unmount. | ||
* | ||
* @param target Element ref object or element itself. | ||
* @param params Parameters specific for target's `addEventListener`. Commonly, | ||
* it is `[eventName, listener, options]`. | ||
*/ | ||
export function useEventListener<T extends EventTarget>( | ||
target: ITargetOrTargetRef<T>, | ||
...params: | ||
| Parameters<T['addEventListener']> | ||
| [string, EventListenerOrEventListenerObject | ((...args: any[]) => any), ...any] | ||
): void { | ||
// extract current target from ref object | ||
const tgt: T = | ||
target && hasOwnProperty(target, 'current') | ||
? (target as MutableRefObject<T>).current | ||
: (target as T); | ||
const isMounted = useIsMounted(); | ||
|
||
// create static event listener | ||
const listenerRef = useSyncedRef(params[1]); | ||
const eventListener = useMemo<EventListener>( | ||
() => | ||
// as some event listeners designed to be used through `this` | ||
// it is better to make listener a conventional function as it | ||
// infers call context | ||
// eslint-disable-next-line func-names | ||
function (this: T, ...args) { | ||
// normally, such situation should not happen, but better to | ||
// have back covered | ||
/* istanbul ignore next */ | ||
if (!isMounted()) return; | ||
|
||
// we dont care if non-listener provided, simply dont do anything | ||
/* istanbul ignore else */ | ||
if (typeof listenerRef.current === 'function') { | ||
listenerRef.current.apply(this, args); | ||
} else if (typeof (listenerRef.current as EventListenerObject).handleEvent === 'function') { | ||
(listenerRef.current as EventListenerObject).handleEvent.apply(this, args); | ||
} | ||
}, | ||
// eslint-disable-next-line react-hooks/exhaustive-deps | ||
[] | ||
); | ||
|
||
useEffect(() => { | ||
if (!tgt) return undefined; | ||
|
||
const restParams = params.slice(2); | ||
|
||
tgt.addEventListener(params[0], eventListener, ...restParams); | ||
|
||
return () => tgt.removeEventListener(params[0], eventListener, ...restParams); | ||
// eslint-disable-next-line react-hooks/exhaustive-deps | ||
}, [tgt, params[0]]); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters