diff --git a/src/hooks/useEventListener/index.ts b/src/hooks/useEventListener/index.ts new file mode 100644 index 00000000..3e7d5291 --- /dev/null +++ b/src/hooks/useEventListener/index.ts @@ -0,0 +1 @@ +export { useEventListener } from './useEventListener.ts'; diff --git a/src/hooks/useEventListener/useEventListener.spec.tsx b/src/hooks/useEventListener/useEventListener.spec.tsx new file mode 100644 index 00000000..192a1652 --- /dev/null +++ b/src/hooks/useEventListener/useEventListener.spec.tsx @@ -0,0 +1,117 @@ +import { useRef } from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; + +import { useEventListener } from './useEventListener.ts'; + +describe('useEventListener', () => { + let handlerSpy: Mock; + + beforeEach(() => { + handlerSpy = vi.fn(); + }); + + it('should trigger window resize event', () => { + function TestComponent() { + useEventListener('resize', handlerSpy); + + return
Resize the window
; + } + + render(); + + global.dispatchEvent(new Event('resize')); + + expect(handlerSpy).toHaveBeenCalled(); + }); + + it('should trigger window scroll event', () => { + function TestComponent() { + useEventListener('scroll', handlerSpy); + + return
Scroll the window
; + } + + render(); + + global.dispatchEvent(new Event('scroll')); + + expect(handlerSpy).toHaveBeenCalled(); + }); + + it('should trigger document visibilitychange event', () => { + function TestComponent() { + useEventListener('visibilitychange', handlerSpy, document); + + return
Visibility Change
; + } + + render(); + + document.dispatchEvent(new Event('visibilitychange')); + + expect(handlerSpy).toHaveBeenCalled(); + }); + + it('should trigger document click event', () => { + function TestComponent() { + useEventListener('click', handlerSpy, document); + + return
Click anywhere in the document
; + } + + render(); + + fireEvent.click(document); + + expect(handlerSpy).toHaveBeenCalled(); + }); + + it('should trigger element click event', () => { + function TestComponent() { + const buttonRef = useRef(null); + + useEventListener('click', handlerSpy, buttonRef); + + return ; + } + + render(); + + fireEvent.click(screen.getByText('Click me')); + + expect(handlerSpy).toHaveBeenCalled(); + }); + + it('should trigger element focus event', () => { + function TestComponent() { + const inputRef = useRef(null); + + useEventListener('focus', handlerSpy, inputRef); + + return ; + } + + render(); + + fireEvent.focus(screen.getByRole('textbox')); + + expect(handlerSpy).toHaveBeenCalled(); + }); + + it('should not throw if ref is null and does not register listener', () => { + function TestComponent() { + const nullRef = useRef(null); + + useEventListener('click', handlerSpy, nullRef); + + return
Test
; + } + + render(); + + fireEvent.click(screen.getByText('Test')); + + expect(handlerSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/useEventListener/useEventListener.ts b/src/hooks/useEventListener/useEventListener.ts new file mode 100644 index 00000000..89ffb4a9 --- /dev/null +++ b/src/hooks/useEventListener/useEventListener.ts @@ -0,0 +1,113 @@ +import { RefObject, useEffect } from 'react'; + +import { usePreservedCallback } from '../usePreservedCallback/index.ts'; + +/** + * @description + * `useEventListener` is a React hook that allows you to easily add and clean up event listeners on various targets, + * such as `window`, `document`, HTML elements, or SVG elements. + * The listener is automatically updated with the latest handler on each render without reattaching, + * ensuring stable performance and correct behavior. + * + * @template KW - Event name type for `window` events, determining the corresponding event object type. + * @template KD - Event name type for `document` events, determining the corresponding event object type. + * @template KH - Event name type for HTML or SVG element events, determining the corresponding event object type. + * @template T - Type of the DOM element being referenced (default is `HTMLElement`, but can be an SVG element). + * @param {KW | KD | KH} eventName - The name of the event to listen for. + * @param {(event: WindowEventMap[KW] | DocumentEventMap[KD] | HTMLElementEventMap[KH] | SVGElementEventMap[KH]) => void} handler - The callback function that will be triggered when the event occurs. + * @param {RefObject | Document} [element] - The target to attach the event listener to. Can be a React `ref` object or the `document`. If omitted or `undefined`, the listener is attached to the `window`. + * @param {boolean | AddEventListenerOptions} [options] - Optional parameters for the event listener such as `capture`, `once`, or `passive`. + * + * @example + * function WindowResize() { + * useEventListener('resize', (event) => { + * console.log('Window resized', event); + * }); + * + * return
Resize the window and check the console.
; + * } + * + * @example + * function ClickButton() { + * const buttonRef = useRef(null); + * + * useEventListener('click', (event) => { + * console.log('Button clicked', event); + * }, buttonRef); + * + * return ; + * } + * + * @example + * function ScrollTracker() { + * const scrollRef = useRef(null); + * + * useEventListener('scroll', () => { + * console.log('Scroll event detected!'); + * }, scrollRef, { passive: true }); + * + * return ( + *
+ *
Scroll Me!
+ *
+ * ); + * } + * + * @example + * function Document() { + * useEventListener('click', (event) => { + * console.log('Document clicked at coordinates', event.clientX, event.clientY); + * }, document); + * + * return
Click anywhere on the document and check the console for coordinates.
; + * } + */ +export function useEventListener< + K extends keyof HTMLElementEventMap & keyof SVGElementEventMap, + T extends Element = K extends keyof HTMLElementEventMap ? HTMLElement : SVGElement, +>( + eventName: K, + handler: (event: HTMLElementEventMap[K] | SVGElementEventMap[K]) => void, + element: RefObject, + options?: boolean | AddEventListenerOptions +): void; +export function useEventListener( + eventName: K, + handler: (event: DocumentEventMap[K]) => void, + element: Document, + options?: boolean | AddEventListenerOptions +): void; +export function useEventListener( + eventName: K, + handler: (event: WindowEventMap[K]) => void, + element?: undefined, + options?: boolean | AddEventListenerOptions +): void; +export function useEventListener< + KW extends keyof WindowEventMap, + KD extends keyof DocumentEventMap, + KH extends keyof HTMLElementEventMap & keyof SVGElementEventMap, + T extends HTMLElement | SVGElement = HTMLElement, +>( + eventName: KW | KD | KH, + handler: ( + event: WindowEventMap[KW] | DocumentEventMap[KD] | HTMLElementEventMap[KH] | SVGElementEventMap[KH] | Event + ) => void, + element?: RefObject | Document, + options?: boolean | AddEventListenerOptions +) { + const preservedHandler = usePreservedCallback(handler); + + useEffect(() => { + const targetElement = + element instanceof Document ? document : (element?.current ?? (element === undefined ? window : undefined)); + + if (!targetElement?.addEventListener) return; + + const listener: typeof handler = event => preservedHandler(event); + + targetElement.addEventListener(eventName, listener, options); + + return () => targetElement.removeEventListener(eventName, listener, options); + }, [eventName, element, options, preservedHandler]); +} diff --git a/src/index.ts b/src/index.ts index 5a5911dc..2ba76dea 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ export { useCounter } from './hooks/useCounter/index.ts'; export { useDebounce } from './hooks/useDebounce/index.ts'; export { useDebouncedCallback } from './hooks/useDebouncedCallback/index.ts'; export { useDoubleClick } from './hooks/useDoubleClick/index.ts'; +export { useEventListener } from './hooks/useEventListener/index.ts'; export { useImpressionRef } from './hooks/useImpressionRef/index.ts'; export { useInputState } from './hooks/useInputState/index.ts'; export { useIntersectionObserver } from './hooks/useIntersectionObserver/index.ts';