From 9f1ed15905a6d7e84aa8eb2d3ad1fecfbe91ad90 Mon Sep 17 00:00:00 2001 From: Wonsuk Choi Date: Mon, 12 May 2025 01:29:59 +0900 Subject: [PATCH 1/7] feat(hooks): add 'useEventListener' --- src/hooks/useEventListener/index.ts | 1 + .../useEventListener.spec.tsx | 117 ++++++++++++++++++ .../useEventListener/useEventListener.ts | 98 +++++++++++++++ src/index.ts | 1 + 4 files changed, 217 insertions(+) create mode 100644 src/hooks/useEventListener/index.ts create mode 100644 src/hooks/useEventListener/useEventListener.spec.tsx create mode 100644 src/hooks/useEventListener/useEventListener.ts diff --git a/src/hooks/useEventListener/index.ts b/src/hooks/useEventListener/index.ts new file mode 100644 index 0000000..3e7d529 --- /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 0000000..02a55f8 --- /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 Hook', () => { + 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 0000000..543763a --- /dev/null +++ b/src/hooks/useEventListener/useEventListener.ts @@ -0,0 +1,98 @@ +import { RefObject, useEffect } from 'react'; + +import { usePreservedCallback } from '../usePreservedCallback/index.ts'; + +/** + * @description + * `useEventListener` is a React hook that simplifies adding and cleaning up event listeners on various targets + * such as `window`, `document`, HTML elements, or SVG elements. + * It automatically updates the handler without needing to reattach the event listener on every render, + * ensuring stable performance and correct behavior. + * + * @template KW - The type of event for window events. + * @template KD - The type of event for document events. + * @template KH - The type of event for HTML or SVG element events. + * @template T - The type of the DOM element (default is `HTMLElement`). + * @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 to execute when the event is triggered. + * @param {RefObject | Document} [element] - A React ref object targeting the element to attach the event listener to, or a `Document` object to attach directly to the document. Defaults to `window` if not provided. + * @param {boolean | AddEventListenerOptions} [options] - Optional parameters for the event listener, such as `capture`, `once`, or `passive`. + * + * @returns {void} This hook does not return anything. + * + * @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 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]) => void) | ((event: 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 | SVGAElement = HTMLElement, +>( + eventName: KW | KD | KH, + handler: (event: WindowEventMap[KW] | HTMLElementEventMap[KH] | DocumentEventMap[KD] | 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 5a5911d..2ba76de 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'; From 0ec54b98d0cd2f9636692f96f5f2d90e196b0739 Mon Sep 17 00:00:00 2001 From: Wonsuk Choi Date: Mon, 12 May 2025 02:08:41 +0900 Subject: [PATCH 2/7] docs(hooks/useEventListener.ts): improve JSDoc --- .../useEventListener/useEventListener.ts | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/src/hooks/useEventListener/useEventListener.ts b/src/hooks/useEventListener/useEventListener.ts index 543763a..71b0d8e 100644 --- a/src/hooks/useEventListener/useEventListener.ts +++ b/src/hooks/useEventListener/useEventListener.ts @@ -4,21 +4,19 @@ import { usePreservedCallback } from '../usePreservedCallback/index.ts'; /** * @description - * `useEventListener` is a React hook that simplifies adding and cleaning up event listeners on various targets + * `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. - * It automatically updates the handler without needing to reattach the event listener on every render, + * The listener is automatically updated with the latest handler on each render without reattaching, * ensuring stable performance and correct behavior. * - * @template KW - The type of event for window events. - * @template KD - The type of event for document events. - * @template KH - The type of event for HTML or SVG element events. - * @template T - The type of the DOM element (default is `HTMLElement`). + * @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 to execute when the event is triggered. - * @param {RefObject | Document} [element] - A React ref object targeting the element to attach the event listener to, or a `Document` object to attach directly to the document. Defaults to `window` if not provided. - * @param {boolean | AddEventListenerOptions} [options] - Optional parameters for the event listener, such as `capture`, `once`, or `passive`. - * - * @returns {void} This hook does not return anything. + * @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() { From 2c56a00103a2a1b894921764207197d0d216a009 Mon Sep 17 00:00:00 2001 From: Wonsuk Choi Date: Mon, 12 May 2025 02:49:51 +0900 Subject: [PATCH 3/7] feat(hooks/useEventListener): improve handler param type --- src/hooks/useEventListener/useEventListener.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/hooks/useEventListener/useEventListener.ts b/src/hooks/useEventListener/useEventListener.ts index 71b0d8e..5b212d2 100644 --- a/src/hooks/useEventListener/useEventListener.ts +++ b/src/hooks/useEventListener/useEventListener.ts @@ -75,7 +75,9 @@ export function useEventListener< T extends HTMLElement | SVGAElement = HTMLElement, >( eventName: KW | KD | KH, - handler: (event: WindowEventMap[KW] | HTMLElementEventMap[KH] | DocumentEventMap[KD] | Event) => void, + handler: ( + event: WindowEventMap[KW] | DocumentEventMap[KD] | HTMLElementEventMap[KH] | SVGElementEventMap[KH] | Event + ) => void, element?: RefObject | Document, options?: boolean | AddEventListenerOptions ) { From 985c6b4633824b4784451104d22ed7abdf950467 Mon Sep 17 00:00:00 2001 From: Wonsuk Choi Date: Mon, 12 May 2025 09:56:31 +0900 Subject: [PATCH 4/7] refactor(hooks/useEventListener): improve handler param type in RefObject element case --- src/hooks/useEventListener/useEventListener.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useEventListener/useEventListener.ts b/src/hooks/useEventListener/useEventListener.ts index 5b212d2..6ef1073 100644 --- a/src/hooks/useEventListener/useEventListener.ts +++ b/src/hooks/useEventListener/useEventListener.ts @@ -52,7 +52,7 @@ export function useEventListener< T extends Element = K extends keyof HTMLElementEventMap ? HTMLElement : SVGElement, >( eventName: K, - handler: ((event: HTMLElementEventMap[K]) => void) | ((event: SVGElementEventMap[K]) => void), + handler: (event: HTMLElementEventMap[K] | SVGElementEventMap[K]) => void, element: RefObject, options?: boolean | AddEventListenerOptions ): void; From 5b5e0da34399091b6c322ce9b3f2f829d2678e5e Mon Sep 17 00:00:00 2001 From: Wonsuk Choi Date: Mon, 12 May 2025 10:05:59 +0900 Subject: [PATCH 5/7] refactor(hooks/useEventListener): change SVGAElement to SVGElement --- src/hooks/useEventListener/useEventListener.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useEventListener/useEventListener.ts b/src/hooks/useEventListener/useEventListener.ts index 6ef1073..61dded9 100644 --- a/src/hooks/useEventListener/useEventListener.ts +++ b/src/hooks/useEventListener/useEventListener.ts @@ -72,7 +72,7 @@ export function useEventListener< KW extends keyof WindowEventMap, KD extends keyof DocumentEventMap, KH extends keyof HTMLElementEventMap & keyof SVGElementEventMap, - T extends HTMLElement | SVGAElement = HTMLElement, + T extends HTMLElement | SVGElement = HTMLElement, >( eventName: KW | KD | KH, handler: ( From 4fbfe9d36150608411b009c5797b61a6a98a99e0 Mon Sep 17 00:00:00 2001 From: Wonsuk Choi Date: Mon, 12 May 2025 10:36:16 +0900 Subject: [PATCH 6/7] test(hooks/useEventListener): rename describe to 'useEventListener' --- src/hooks/useEventListener/useEventListener.spec.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useEventListener/useEventListener.spec.tsx b/src/hooks/useEventListener/useEventListener.spec.tsx index 02a55f8..192a165 100644 --- a/src/hooks/useEventListener/useEventListener.spec.tsx +++ b/src/hooks/useEventListener/useEventListener.spec.tsx @@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; import { useEventListener } from './useEventListener.ts'; -describe('useEventListener Hook', () => { +describe('useEventListener', () => { let handlerSpy: Mock; beforeEach(() => { From c4472a37712eabda9df73611d0eaee36378ec45f Mon Sep 17 00:00:00 2001 From: Wonsuk Choi Date: Wed, 14 May 2025 00:46:09 +0900 Subject: [PATCH 7/7] docs(hooks/useEventListener.ts): add 'ScrollTracker' example in JSDoc --- src/hooks/useEventListener/useEventListener.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/hooks/useEventListener/useEventListener.ts b/src/hooks/useEventListener/useEventListener.ts index 61dded9..89ffb4a 100644 --- a/src/hooks/useEventListener/useEventListener.ts +++ b/src/hooks/useEventListener/useEventListener.ts @@ -39,6 +39,21 @@ import { usePreservedCallback } from '../usePreservedCallback/index.ts'; * } * * @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);