Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature: useEventListener EventMap generic override + EventTarget & refactor: multiple overloads to single mapped type #657

Open
wants to merge 30 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
9460e26
refactor: useEventlistener (with EventMap generic overrid)
JimGitFE Nov 18, 2024
845d04f
doc: updated tsDoc, match new generics & config parameter
JimGitFE Nov 18, 2024
48b9a2d
chore: backwards compatibility for DocumentEventMap type declaration …
JimGitFE Nov 18, 2024
2f458d0
typo
JimGitFE Nov 18, 2024
29e8fef
refactor: renamed CustomEventsMap to CustomEventMap + initialized dec…
JimGitFE Nov 18, 2024
419d60c
chore: reformat prettier & updated config argument on listener hook …
JimGitFE Nov 18, 2024
b4d6f8a
refactor: prev listener parameter name
JimGitFE Nov 20, 2024
d6240fd
fix: listener tests
JimGitFE Nov 20, 2024
16149c3
add: support for straight Element or RefObject refactor: renamed conf…
JimGitFE Nov 20, 2024
226f473
doc: updated demo and .md for listener hook
JimGitFE Nov 20, 2024
056d53a
chore: cleanup unnecessary
JimGitFE Nov 20, 2024
f6d5736
chore: unnecessary imports
JimGitFE Nov 20, 2024
eabc706
doc: Window is default hook element
JimGitFE Nov 21, 2024
5026898
chore: prettier reformat
JimGitFE Nov 21, 2024
c77ba5f
fix: options at removeEventListener
JimGitFE Nov 21, 2024
28b29ee
refactor: better format & git blame
JimGitFE Nov 21, 2024
be628b1
chore: unnecessary import
JimGitFE Nov 21, 2024
09b9c7e
chore: prop check & cleaner blame
JimGitFE Nov 21, 2024
62add40
typo
JimGitFE Nov 21, 2024
6f54d09
typo
JimGitFE Nov 21, 2024
bb6974e
chore: cleanup
JimGitFE Nov 21, 2024
fa3c3ee
chore: cleanup
JimGitFE Nov 21, 2024
e00ca6f
chore: improved pr diff
JimGitFE Nov 22, 2024
fb1d703
doc: demo shorter demo
JimGitFE Nov 22, 2024
5c29d2e
typo
JimGitFE Nov 22, 2024
49732cd
doc: unnecessary explanation
JimGitFE Nov 22, 2024
fdfde9b
typo
JimGitFE Nov 22, 2024
19a5671
add: accept any element (only if eventmap generic) + fix: eventmap ge…
JimGitFE Nov 22, 2024
99e36e2
add: tests for EventTarget from PR #585
JimGitFE Nov 22, 2024
5987504
add: more EventTargets & AudioNodes (template for more EventMap addit…
JimGitFE Nov 22, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useEventListener } from './useEventListener'
export default function Component() {
// Define button ref
const buttonRef = useRef<HTMLButtonElement>(null)
const documentRef = useRef<Document>(document)
const speechSynthesisRef = useRef(new SpeechSynthesis())

const onScroll = (event: Event) => {
console.log('window scrolled!', event)
Expand All @@ -25,11 +25,14 @@ export default function Component() {
// example with window based event
useEventListener('scroll', onScroll)

// example with EventTarget element based event
useEventListener('voiceschanged', ev => console.log('speech!', ev), { element: speechSynthesisRef })

// example with document based event
useEventListener('visibilitychange', onVisibilityChange, documentRef)
useEventListener('visibilitychange', onVisibilityChange, {element: document })

// example with element based event
useEventListener('click', onClick, buttonRef)
useEventListener('click', onClick, { element: buttonRef })

return (
<div style={{ minHeight: '200vh' }}>
Expand Down
66 changes: 56 additions & 10 deletions packages/usehooks-ts/src/useEventListener/useEventListener.md
Original file line number Diff line number Diff line change
@@ -1,21 +1,67 @@
Use EventListener with simplicity by React Hook.
## Use EventListener with simplicity by React Hook.

Supports `Window`, `Element` and `Document` and custom events with almost the same parameters as the native [`addEventListener` options](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#syntax). See examples below.
Supports `Window`, `Element`, `Document`, `EventTarget` Based and custom events with almost the same parameters as the native [`addEventListener` options](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#syntax). See examples below.

If you want to use your CustomEvent using Typescript, you have to declare the event type.
Find which kind of Event you want to extends:
If you want to use your CustomEvent using Typescript follow one of three options:

- `MediaQueryListEventMap`
- `WindowEventMap`
- `HTMLElementEventMap`
- `DocumentEventMap`
## 1. Globally Declared CustomEventMap, intersects all DOM Elements EventMaps

```ts
// globals.d.ts
declare global {
/** Extends EventMap declarations for all DOM Elements (intersection)*/
interface CustomEventMap {
'my-custom-event': CustomEvent<{ isCustom: boolean }>
order: { orderId: number; name: string }
delivery: { itemCount: number }
}
}

// page.tsx

useEventListener('delivery', ({ itemCount }) => {
console.log('count', itemCount)
})

useEventListener('my-custom-event', event => {
console.log('boolean:', event.detail.isCustom)
})
```

Then declare your custom event:
## 2. Use EventMap generic override:

```typescript
type OrderEventMap = {
order: { orderId: number; name: string }
delivery: { itemCount: number }
}
useEventListener<OrderEventMap>('order', ({ orderId }) => {
console.log('id', orderId)
})
```

## 3. Declare the event type as an intersection of the specific Element EventMap:

```ts
// global.d.ts
declare global {
interface DocumentEventMap {
/** Intersects EventMap declarations for DOM Window Element (default DOM target for hook listener) */
interface WindowEventMap {
'my-custom-event': CustomEvent<{ exampleArg: string }>
}
}
```

Available EventMap at useEventListener.ts

- `WindowEventMap`
- `HTMLElementEventMap`
- `DocumentEventMap`

Event Targets:

- `MediaQueryListEventMap`
- `RTCDataChannelEventMap`
- `RTCPeerConnectionEventMap`
- `SpeechSynthesisEventMap`
- `SpeechSynthesisUtteranceEventMap`
50 changes: 44 additions & 6 deletions packages/usehooks-ts/src/useEventListener/useEventListener.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ declare global {
interface DocumentEventMap {
'test-event': CustomEvent
}

/** Since TestTarget not at ElementsToEventMap, we need to use an EventMap generic override */
interface TestTargetEventMap {
'boundary': SpeechSynthesisEvent
}
}

const windowAddEventListenerSpy = vitest.spyOn(window, 'addEventListener')
Expand All @@ -38,6 +43,14 @@ const docRemoveEventListenerSpy = vitest.spyOn(
'removeEventListener',
)

class TestTarget extends EventTarget {}
const testTarget = new TestTarget()
const targetAddEventListenerSpy = vitest.spyOn(testTarget, 'addEventListener')
const targetRemoveEventListenerSpy = vitest.spyOn(
testTarget,
'removeEventListener',
)

describe('useEventListener()', () => {
afterEach(() => {
vitest.clearAllMocks()
Expand Down Expand Up @@ -73,7 +86,7 @@ describe('useEventListener()', () => {
const options = undefined

const { unmount } = renderHook(() => {
useEventListener(eventName, handler, ref, options)
useEventListener(eventName, handler, { element: ref, options })
})

expect(refAddEventListenerSpy).toHaveBeenCalledTimes(1)
Expand All @@ -92,13 +105,38 @@ describe('useEventListener()', () => {
)
})

it('should bind/unbind the event listener to the EventTarget when EventTarget is provided', () => {
const eventName = 'boundary'
const handler = vitest.fn()
const options = undefined

const { unmount } = renderHook(() => {
useEventListener<TestTargetEventMap>("boundary", handler, { element: testTarget, options })
})

expect(targetAddEventListenerSpy).toHaveBeenCalledTimes(1)
expect(targetAddEventListenerSpy).toHaveBeenCalledWith(
eventName,
expect.any(Function),
options,
)

unmount()

expect(targetRemoveEventListenerSpy).toHaveBeenCalledWith(
eventName,
expect.any(Function),
options,
)
})

it('should bind/unbind the event listener to the document when document is provided', () => {
const eventName = 'test-event'
const handler = vitest.fn()
const options = undefined

const { unmount } = renderHook(() => {
useEventListener(eventName, handler, docRef, options)
useEventListener(eventName, handler, { element: docRef, options })
})

expect(docAddEventListenerSpy).toHaveBeenCalledTimes(1)
Expand Down Expand Up @@ -127,7 +165,7 @@ describe('useEventListener()', () => {
}

renderHook(() => {
useEventListener(eventName, handler, undefined, options)
useEventListener(eventName, handler, { options })
})

expect(windowAddEventListenerSpy).toHaveBeenCalledWith(
Expand All @@ -142,7 +180,7 @@ describe('useEventListener()', () => {
const handler = vitest.fn()

renderHook(() => {
useEventListener(eventName, handler, ref)
useEventListener(eventName, handler, { element: ref })
})

fireEvent.click(ref.current)
Expand All @@ -155,10 +193,10 @@ describe('useEventListener()', () => {
const keydownHandler = vitest.fn()

renderHook(() => {
useEventListener('click', clickHandler, ref)
useEventListener('click', clickHandler, { element: ref })
})
renderHook(() => {
useEventListener('keydown', keydownHandler, ref)
useEventListener('keydown', keydownHandler, { element: ref })
})

fireEvent.click(ref.current)
Expand Down
145 changes: 76 additions & 69 deletions packages/usehooks-ts/src/useEventListener/useEventListener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,55 +4,64 @@ import type { RefObject } from 'react'

import { useIsomorphicLayoutEffect } from '../useIsomorphicLayoutEffect/useIsomorphicLayoutEffect'

// MediaQueryList Event based useEventListener interface
function useEventListener<K extends keyof MediaQueryListEventMap>(
eventName: K,
handler: (event: MediaQueryListEventMap[K]) => void,
element: RefObject<MediaQueryList>,
options?: boolean | AddEventListenerOptions,
): void
// Recommended usage: move CustomEventMap to global declaration
/** Extends EventMap declarations for all DOM Elements (intersection)*/
interface CustomEventMap {
'your-custom-event': CustomEvent<{ isCustom: boolean }>
}

// Window Event based useEventListener interface
function useEventListener<K extends keyof WindowEventMap>(
eventName: K,
handler: (event: WindowEventMap[K]) => void,
element?: undefined,
options?: boolean | AddEventListenerOptions,
): void
/** Element as string to Matching EventMap */
type ElementToEventMap = {
Window: [Window, WindowEventMap]
HTMLElement: [HTMLElement, HTMLElementEventMap]
Document: [Document, DocumentEventMap]
//
// EventTargets
TextTrack: [TextTrack, TextTrackEventMap]
BaseAudioContext: [BaseAudioContext, BaseAudioContextEventMap]
BroadcastChannel: [BroadcastChannel, BroadcastChannelEventMap]
FileReader: [FileReader, FileReaderEventMap]
HTMLMediaElement: [HTMLMediaElement, HTMLMediaElementEventMap]
MediaQueryList: [MediaQueryList, MediaQueryListEventMap]
Notification: [Notification, NotificationEventMap]
//
// ... add more elements here
//
RTCDataChannel: [RTCDataChannel, RTCDataChannelEventMap]
RTCPeerConnection: [RTCPeerConnection, RTCPeerConnectionEventMap]
SpeechSynthesis: [SpeechSynthesis, SpeechSynthesisEventMap]
SpeechSynthesisUtterance: [SpeechSynthesisUtterance, SpeechSynthesisUtteranceEventMap]
WebSocket: [WebSocket, WebSocketEventMap]
XMLHttpRequest: [XMLHttpRequest, XMLHttpRequestEventMap]
//
// Audio Nodes
AudioScheduledSourceNode: [AudioScheduledSourceNode, AudioScheduledSourceNodeEventMap]
AudioWorkletNode: [AudioWorkletNode, AudioWorkletNodeEventMap]
//
// ... add more elements here
}
/** If A exists Return B else C */
type ifGen<A, B, C> = [A] extends [undefined | never] ? C : B;

// Element Event based useEventListener interface
function useEventListener<
K extends keyof HTMLElementEventMap & keyof SVGElementEventMap,
T extends Element = K extends keyof HTMLElementEventMap
? HTMLDivElement
: SVGElement,
>(
eventName: K,
handler:
| ((event: HTMLElementEventMap[K]) => void)
| ((event: SVGElementEventMap[K]) => void),
element: RefObject<T>,
options?: boolean | AddEventListenerOptions,
): void
/** Return `T` if `M` undefined or never */
type Fallback<M, T> = ifGen<M, M, T>

// Document Event based useEventListener interface
function useEventListener<K extends keyof DocumentEventMap>(
eventName: K,
handler: (event: DocumentEventMap[K]) => void,
element: RefObject<Document>,
options?: boolean | AddEventListenerOptions,
): void
/** Return `EventMap` type of matching element ref (from config argument)
* Intersected with `CustomEventMap` (from global declaration)
* If Element not in map default Fallback to HTMLElement */
type EventMapOf<E> = Fallback<{
[K in keyof ElementToEventMap]: E extends ElementToEventMap[K][0] ? ElementToEventMap[K][1] & CustomEventMap : never
}[keyof ElementToEventMap], HTMLElement>

/**
* Custom hook that attaches event listeners to DOM elements, the window, or media query lists.
* @template KW - The type of event for window events.
* @template KH - The type of event for HTML or SVG element events.
* @template KM - The type of event for media query list events.
* @template T - The type of the DOM element (default is `HTMLElement`).
* @param {KW | KH | KM} eventName - The name of the event to listen for.
* @param {(event: WindowEventMap[KW] | HTMLElementEventMap[KH] | SVGElementEventMap[KH] | MediaQueryListEventMap[KM] | Event) => void} handler - The event handler function.
* @param {RefObject<T>} [element] - The DOM element or media query list to attach the event listener to (optional).
* @param {boolean | AddEventListenerOptions} [options] - An options object that specifies characteristics about the event listener (optional).
* @template M - The type of custom Event Map (optional generic), overrides any other element to events mapping.
* @template E - The type of the DOM element (default is `Window`).
* @template K - The type of event name, Key of an EventMap (match for DOM element).
* @param {K} eventName - The name of the event to listen for.
* @param {(event: Fallback<M, EventMapOf<E>>[K]) => void} handler - The event handler function.
* @param {RefObject<T>} config.element - A reference that specifies the DOM element to attach the event listener to.
* @param {boolean | AddEventListenerOptions} config.options - Event listener Options.
* @public
* @see [Documentation](https://usehooks-ts.com/react-hook/use-event-listener)
* @example
Expand All @@ -63,33 +72,33 @@ function useEventListener<K extends keyof DocumentEventMap>(
* @example
* ```tsx
* // Example 2: Attach a document event listener with options
* const elementRef = useRef(document);
* useEventListener('click', handleClick, elementRef, { capture: true });
* const element = useRef(document);
* useEventListener('click', handleClick, { element, options: { capture: true } });
* ```
* @example
* ```tsx
* // Example 3: Attach an element event listener
* const buttonRef = useRef<HTMLButtonElement>(null);
* useEventListener('click', handleButtonClick, buttonRef);
* const element = useRef<HTMLButtonElement>(null);
* useEventListener('click', handleButtonClick, { element });
* ```
*/
function useEventListener<
KW extends keyof WindowEventMap,
KH extends keyof HTMLElementEventMap & keyof SVGElementEventMap,
KM extends keyof MediaQueryListEventMap,
T extends HTMLElement | SVGAElement | MediaQueryList = HTMLElement,
/** Custom Event Map (optional generic)*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
M extends Record<string, any> | undefined = undefined,
/** Element Type of Optional refObject (defaults to Window) */
E extends ifGen<M, any, ElementToEventMap[keyof ElementToEventMap][0]> = ifGen<M, any, Window>,
/** eventName Key of type custom EventMap if present */
K extends keyof Fallback<M, EventMapOf<E>> = keyof Fallback<M, EventMapOf<E>>,
>(
eventName: KW | KH | KM,
handler: (
event:
| WindowEventMap[KW]
| HTMLElementEventMap[KH]
| SVGElementEventMap[KH]
| MediaQueryListEventMap[KM]
| Event,
) => void,
element?: RefObject<T>,
options?: boolean | AddEventListenerOptions,
eventName: K & string,
handler: (event: Fallback<M, EventMapOf<E>>[K]) => void,
config: {
/** Litening Target (defaults to window) (supports, ref or plain Element) */
element?: RefObject<E> | E
/** eventListener Options */
options?: boolean | AddEventListenerOptions
} = {},
) {
// Create a ref that stores handler
const savedHandler = useRef(handler)
Expand All @@ -100,22 +109,20 @@ function useEventListener<

useEffect(() => {
// Define the listening target
const targetElement: T | Window = element?.current ?? window
const targetElement: E | Window = config.element ? 'current' in config.element ? config.element.current ?? window : config.element : window

if (!(targetElement && targetElement.addEventListener)) return

// Create event listener that calls handler function stored in ref
const listener: typeof handler = event => {
savedHandler.current(event)
}
const listener: EventListener = event => savedHandler.current(event as Parameters<typeof handler>[0])

targetElement.addEventListener(eventName, listener, options)
targetElement.addEventListener(eventName, listener, config.options)

// Remove event listener on cleanup
return () => {
targetElement.removeEventListener(eventName, listener, options)
targetElement.removeEventListener(eventName, listener, config.options)
}
}, [eventName, element, options])
}, [eventName, config])
}

export { useEventListener }
Loading