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

fix(react): useEventListener ref 이슈 대응 작업 #479

Merged
merged 1 commit into from
Sep 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
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;
}
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