Skip to content

Commit

Permalink
fix: useEventListener ref 대응 추가 (#479)
Browse files Browse the repository at this point in the history
  • Loading branch information
ssi02014 authored Sep 22, 2024
1 parent b8b07d2 commit aa6f860
Show file tree
Hide file tree
Showing 8 changed files with 153 additions and 56 deletions.
5 changes: 5 additions & 0 deletions .changeset/ten-garlics-smoke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modern-kit/react': patch
---

fix: useEventListener ref 대응 추가 - @ssi02014
45 changes: 34 additions & 11 deletions docs/docs/react/hooks/useEventListener.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,47 +13,61 @@ import BrowserOnly from '@docusaurus/BrowserOnly';

## Interface
```ts title="typescript"
type EventListenerAvailableElement =
| Window
| Document
| HTMLElement
| SVGElement
| MediaQueryList;

type TargetElement<T extends EventListenerAvailableElement> =
| T
| null
| undefined
| RefObject<T | null | undefined>;
```
```ts title="typescript"
// Window Event based useEventListener interface
export function useEventListener<K extends keyof WindowEventMap>(
function useEventListener<K extends keyof WindowEventMap>(
element: Window,
type: K,
listener: (event: WindowEventMap[K]) => void,
options?: AddEventListenerOptions
): void;

// Document Event based useEventListener interface
export function useEventListener<K extends keyof DocumentEventMap>(
function useEventListener<K extends keyof DocumentEventMap>(
element: Document,
type: K,
listener: (event: DocumentEventMap[K]) => void,
options?: AddEventListenerOptions
): void;

// MediaQueryList Event based useEventListener interface
export function useEventListener<K extends keyof MediaQueryListEventMap>(
function useEventListener<K extends keyof MediaQueryListEventMap>(
element: MediaQueryList,
type: K,
listener: (event: MediaQueryListEventMap[K]) => void,
options?: AddEventListenerOptions
): void;

// Element Event based useEventListener interface
export function useEventListener<
function useEventListener<
K extends keyof HTMLElementEventMap,
T extends HTMLElement
>(
element: T | null,
element: TargetElement<T>,
type: K,
listener: (event: HTMLElementEventMap[K]) => void,
options?: AddEventListenerOptions
): void;

// SVGElement Event based useEventListener interface
export function useEventListener<
function useEventListener<
K extends keyof HTMLElementEventMap,
T extends SVGElement
>(
element: T | null,
element: TargetElement<T>,
type: K,
listener: (event: SVGElementEventMap[K]) => void,
options?: AddEventListenerOptions
Expand All @@ -67,16 +81,20 @@ import { useEventListener } from '@modern-kit/react';

const Example = () => {
const [number, setNumber] = useState(0)

// document에 click 이벤트 리스터 추가
const buttonRef = useRef<HTMLButtonElement | null>(null);

useEventListener(document, 'click', () => {
console.log(number);
})

useEventListener(buttonRef, 'click', () => {
setNumber(number + 1);
})

return (
<div>
<h3>화면을 클릭해보세요!</h3>
<p>number: {number}</p>
<button ref={buttonRef}>숫자 더하기</button>
</div>
);
};
Expand All @@ -86,15 +104,20 @@ const Example = () => {

export const Example = () => {
const [number, setNumber] = useState(0)
const buttonRef = useRef(null);

useEventListener(document, 'click', () => {
console.log(number);
})

useEventListener(buttonRef, 'click', () => {
setNumber(number + 1);
})

return (
<div>
<h3>화면을 클릭해보세요!</h3>
<p>number: {number}</p>
<button ref={buttonRef}>숫자 더하기</button>
</div>
);
};
Expand Down
53 changes: 35 additions & 18 deletions packages/react/src/hooks/useEventListener/index.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,24 @@
import { usePreservedCallback } from '../../hooks/usePreservedCallback';
import { usePreservedState } from '../../hooks/usePreservedState';
import { useIsomorphicLayoutEffect } from '../../hooks/useIsomorphicLayoutEffect';
import {
EventListenerAvailableElement,
TargetElement,
isRefObject,
} from './useEventListener.utils';

/**
* @description 지정된 요소에 이벤트 리스너를 추가하고, 컴포넌트가 언마운트될 때 자동으로 제거합니다.
*
* @template W - Window에서 사용할 수 있는 이벤트 타입
* @template D - Document에서 사용할 수 있는 이벤트 타입
* @template E - HTMLElement에서 사용할 수 있는 이벤트 타입
* @template M - MediaQueryList에서 사용할 수 있는 이벤트 타입
* @template E - HTMLElement에서 사용할 수 있는 이벤트 타입
* @template S - SVGElement에서 사용할 수 있는 이벤트 타입
* @template T - 이벤트 리스너가 등록될 요소 타입
*
* @param {T | null} element - 이벤트 리스너를 등록할 대상 요소입니다.
* @param {W | D | M | E} type - 등록할 이벤트 타입입니다. `click`, `resize`, `keydown` 등의 값이 올 수 있습니다.
* @param {TargetElement<T>} element - 이벤트 리스너를 등록할 대상 요소입니다.
* @param {W | D | M | E | S} type - 등록할 이벤트 타입입니다. `click`, `resize`, `keydown` 등의 값이 올 수 있습니다.
* @param {(
* event:
* | WindowEventMap[W]
Expand All @@ -30,17 +36,21 @@ import { useIsomorphicLayoutEffect } from '../../hooks/useIsomorphicLayoutEffect
*
* @example
* // window
* useEventListener(window, 'click', callback);
* useEventListener(window, 'resize', callback);
*
* @example
* // document
* useEventListener(document, 'click', callback);
*
* @example
* // element
* const buttonRef = useRef<HTMLButtonElement | null>(null);
* useEventListener(buttonRef.current, 'click', callback);
* useEventListener(buttonRef, 'click', callback);
*
* @example
* // onBeforeAddListener
* const buttonRef = useRef<HTMLButtonElement | null>(null);
* useEventListener(buttonRef.current, 'click', callback, { onBeforeAddListener });
* // media query
* const mediaQueryList = window.matchMedia(mediaQueryString);
* useEventListener(mediaQueryList, 'change', handleChange);
*/
// Window Event based useEventListener interface
export function useEventListener<K extends keyof WindowEventMap>(
Expand Down Expand Up @@ -71,18 +81,18 @@ export function useEventListener<
K extends keyof HTMLElementEventMap,
T extends HTMLElement
>(
element: T | null,
element: TargetElement<T>,
type: K,
listener: (event: HTMLElementEventMap[K]) => void,
options?: AddEventListenerOptions
): void;

// SVGElement Event based useEventListener interface
export function useEventListener<
K extends keyof HTMLElementEventMap,
K extends keyof SVGElementEventMap,
T extends SVGElement
>(
element: T | null,
element: TargetElement<T>,
type: K,
listener: (event: SVGElementEventMap[K]) => void,
options?: AddEventListenerOptions
Expand All @@ -93,16 +103,17 @@ export function useEventListener<
D extends keyof DocumentEventMap,
M extends keyof MediaQueryListEventMap,
E extends keyof HTMLElementEventMap,
T extends Window | Document | HTMLElement | SVGElement | MediaQueryList
S extends keyof SVGElementEventMap,
T extends EventListenerAvailableElement
>(
element: T | null,
type: W | D | M | E,
element: TargetElement<T>,
type: W | D | M | E | S,
listener: (
event:
| WindowEventMap[W]
| DocumentEventMap[D]
| HTMLElementEventMap[E]
| SVGElementEventMap[E]
| SVGElementEventMap[S]
| MediaQueryListEventMap[M]
| Event
) => void,
Expand All @@ -112,14 +123,20 @@ export function useEventListener<
const preservedListener = usePreservedCallback(listener);

useIsomorphicLayoutEffect(() => {
if (!element) return;
const targetElement = isRefObject(element) ? element.current : element;

if (!targetElement) return;

// event registration
element.addEventListener(type, preservedListener, preservedOptions);
targetElement.addEventListener(type, preservedListener, preservedOptions);

// clean up
return () => {
element.removeEventListener(type, preservedListener, preservedOptions);
targetElement.removeEventListener(
type,
preservedListener,
preservedOptions
);
};
}, [type, element, preservedOptions, preservedListener]);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { RefObject } from 'react';

/**
* @description 이벤트 리스너를 연결할 수 있는 요소를 나타냅니다.
*/
export type EventListenerAvailableElement =
| Window
| Document
| HTMLElement
| SVGElement
| MediaQueryList;

/**
* @description `특정 유형`, `null`, `undefined`, `RefObject`가 될 수 있는 대상 요소입니다.
*/
export type TargetElement<T extends EventListenerAvailableElement> =
| T
| null
| undefined
| RefObject<T | null | undefined>;

/**
* @description 주어진 요소가 `RefObject`인지 확인합니다.
*/
export const isRefObject = <T extends EventListenerAvailableElement>(
element: TargetElement<T>
): element is RefObject<T | null | undefined> => {
return !!(element as RefObject<T | null | undefined>)?.current;
};
71 changes: 47 additions & 24 deletions packages/react/src/hooks/useHover/index.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,66 @@
import { useEffect, useRef, useState } from 'react';

import { usePreservedCallback } from '../usePreservedCallback';
import { useCallback, useRef, useState } from 'react';
import { noop } from '@modern-kit/utils';
import { useEventListener } from '../../hooks/useEventListener';

interface UseHoverProps {
onEnter?: (event: MouseEvent) => void;
onLeave?: (event: MouseEvent) => void;
}

interface UseHoverReturnType<T extends HTMLElement> {
ref: React.RefObject<T>;
isHovered: boolean;
}

/**
* @description 대상 컴포넌트를 기준으로 마우스가 올라가거나 내려갔을 때의 상태를 반환하고, 마우스가 올라가거나 내려갔을 때의 액션을 정의할 수 있는 커스텀 훅입니다.
*
* @template T - HTML 엘리먼트 타입을 지정합니다.
* @param {{
* onEnter?: (event: MouseEvent) => void;
* onLeave?: (event: MouseEvent) => void;
* }} props - 콜백 함수를 포함한 선택적 속성입니다.
* - `onEnter`: 요소에 마우스가 진입할 때 호출되는 함수입니다. 기본값은 `noop` 함수입니다.
* - `onLeave`: 요소에서 마우스가 떠날 때 호출되는 함수입니다. 기본값은 `noop` 함수입니다.
*
* @returns {UseHoverReturnType<T>} `ref`와 `isHovered`를 포함한 객체를 반환합니다.
* - `ref`: 추적할 대상 요소의 참조입니다.
* - `isHovered`: 요소가 호버 상태인지 여부를 나타내는 불리언 값입니다.
*
* @example
* const { ref, isHovered } = useHover<HTMLDivElement>({
* onEnter: () => console.log('마우스 진입'),
* onLeave: () => console.log('마우스 퇴장'),
* });
*
* return <div ref={ref}> {isHovered ? 'Hovered' : 'Not Hovered'} </div>;
*/
export function useHover<T extends HTMLElement>({
onEnter = noop,
onLeave = noop,
}: UseHoverProps = {}) {
}: UseHoverProps = {}): UseHoverReturnType<T> {
const [isHovered, setIsHovered] = useState(false);

const targetRef = useRef<T>(null);

const onMouseEnter = usePreservedCallback((event: MouseEvent) => {
setIsHovered(true);
onEnter(event);
});

const onMouseLeave = usePreservedCallback((event: MouseEvent) => {
setIsHovered(false);
onLeave(event);
});

useEffect(() => {
const targetElement = targetRef.current;
if (!targetElement) return;
const onMouseEnter = useCallback(
(event: MouseEvent) => {
setIsHovered(true);
onEnter(event);
},
[onEnter]
);

targetElement.addEventListener('mouseenter', onMouseEnter);
targetElement.addEventListener('mouseleave', onMouseLeave);
const onMouseLeave = useCallback(
(event: MouseEvent) => {
setIsHovered(false);
onLeave(event);
},
[onLeave]
);

return () => {
targetElement.removeEventListener('mouseenter', onMouseEnter);
targetElement.removeEventListener('mouseleave', onMouseLeave);
};
}, [onMouseEnter, onMouseLeave]);
useEventListener(targetRef, 'mouseenter', onMouseEnter);
useEventListener(targetRef, 'mouseleave', onMouseLeave);

return { ref: targetRef, isHovered };
}
2 changes: 1 addition & 1 deletion packages/react/src/hooks/useMouse/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export function useMouse<T extends HTMLElement>() {
});
}, []);

useEventListener(window.document, 'mousemove', handleMouseMove);
useEventListener(document, 'mousemove', handleMouseMove);

return { ref: targetRef, position: cursorPosition };
}
2 changes: 1 addition & 1 deletion packages/react/src/hooks/useOutsidePointerDown/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export function useOutsidePointerDown<T extends HTMLElement>(
[callback]
);

useEventListener(window.document, eventType, handleOutsideClick);
useEventListener(document, eventType, handleOutsideClick);

return targetRef;
}
2 changes: 1 addition & 1 deletion packages/react/src/hooks/usePreferredColorScheme/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { useMediaQuery } from '../useMediaQuery';
* const colorScheme = usePreferredColorScheme();
* // colorScheme; // 'dark' 또는 'light' 반환
*/
export function usePreferredColorScheme() {
export function usePreferredColorScheme(): 'dark' | 'light' {
const isDark = useMediaQuery('(prefers-color-scheme: dark)');

return isDark ? 'dark' : 'light';
Expand Down

0 comments on commit aa6f860

Please sign in to comment.