diff --git a/.changeset/stale-seals-pull.md b/.changeset/stale-seals-pull.md new file mode 100644 index 000000000..9e5557dab --- /dev/null +++ b/.changeset/stale-seals-pull.md @@ -0,0 +1,5 @@ +--- +'@modern-kit/react': minor +--- + +feat(react): useOutsidePointerDown 훅 excludeRefs 신규 props 추가 - @ssi02014 diff --git a/docs/docs/react/components/OutsidePointerDownHandler.mdx b/docs/docs/react/components/OutsidePointerDownHandler.mdx index 46a713bba..f11c59ff4 100644 --- a/docs/docs/react/components/OutsidePointerDownHandler.mdx +++ b/docs/docs/react/components/OutsidePointerDownHandler.mdx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useRef } from 'react'; import { OutsidePointerDownHandler } from '@modern-kit/react'; import BrowserOnly from '@docusaurus/BrowserOnly'; @@ -33,9 +33,10 @@ import BrowserOnly from '@docusaurus/BrowserOnly'; ## Interface ```ts title="typescript" interface OutsidePointerDownHandlerProps { - asChild?: boolean; - onPointerDown: () => void; children: ReactNode; + onPointerDown: (targetElement: HTMLElement) => void; + excludeRefs?: React.RefObject[]; + asChild?: boolean; } ``` ```ts title="typescript" @@ -52,33 +53,52 @@ const OutsidePointerDown: PolyForwardComponent< import { OutsidePointerDownHandler } from '@modern-kit/react'; const DefaultExample = () => { + const excludeRef = useRef(null); const style = { - width: '500px', - height: '100px', - backgroundColor: 'skyBlue', + // ... + }; + const excludeStyle = { + // ... }; return ( - console.log('DefaultExample Outside Clicked!')}> -
외부 영역 클릭 후 콘솔을 확인해주세요.
-
+
+ console.log('DefaultExample Outside Clicked!')} + excludeRefs={[excludeRef]} + > +
외부 영역 클릭 후 콘솔을 확인해주세요.
+
+
외부 클릭 및 터치 감지를 제외할 요소의 ref 배열입니다.
+
); }; ``` export const DefaultExample = () => { + const excludeRef = useRef(null); const style = { width: '500px', - height: '100px', backgroundColor: 'skyBlue', + padding: '20px', + }; + const excludeStyle = { + width: '300px', + backgroundColor: '#439966', + color: 'white', + padding: '20px', }; return ( - console.log('DefaultExample Outside Clicked!')}> - 외부 영역 클릭 후 콘솔을 확인해주세요. - +
+ console.log('DefaultExample Outside Clicked!')} + excludeRefs={[excludeRef]} + > +
외부 영역 클릭 후 콘솔을 확인해주세요.
+
+
외부 클릭 및 터치 감지를 제외할 요소의 ref 배열입니다.
+
); }; diff --git a/docs/docs/react/hooks/useBlockMultipleAsyncCalls.mdx b/docs/docs/react/hooks/useBlockMultipleAsyncCalls.mdx index 643239631..f1b5f4ffd 100644 --- a/docs/docs/react/hooks/useBlockMultipleAsyncCalls.mdx +++ b/docs/docs/react/hooks/useBlockMultipleAsyncCalls.mdx @@ -23,10 +23,16 @@ import { useBlockMultipleAsyncCalls } from '@modern-kit/react'; ## Interface ```ts title="typescript" -function useBlockMultipleAsyncCalls(): { +interface UseBlockMultipleAsyncCallsReturnType { + isError: boolean; isLoading: boolean; - blockMultipleAsyncCalls: (callback: () => Promise) => Promise; -}; + blockMultipleAsyncCalls: ( + callback: () => Promise + ) => Promise; +} +``` +```ts title="typescript" +function useBlockMultipleAsyncCalls(): UseBlockMultipleAsyncCallsReturnType ``` ## Usage @@ -47,7 +53,7 @@ const Example = () => { const [nonBlockingCount, setNonBlockingCount] = useState(1); const [value, setValue] = useState(null); - const { isLoading, blockMultipleAsyncCalls } = useBlockMultipleAsyncCalls(); + const { isError, isLoading, blockMultipleAsyncCalls } = useBlockMultipleAsyncCalls(); const fetchApi = async () => { const res = await fetch( @@ -71,6 +77,7 @@ const Example = () => {

NonBlockingCount: {nonBlockingCount}

{isLoading ?

로딩중

:

{value?.title}

} + {isError &&

에러 발생

} ); }; diff --git a/docs/docs/react/hooks/useEventListener.mdx b/docs/docs/react/hooks/useEventListener.mdx index 6d94bf05b..b950b20ee 100644 --- a/docs/docs/react/hooks/useEventListener.mdx +++ b/docs/docs/react/hooks/useEventListener.mdx @@ -30,7 +30,7 @@ function useEventListener( element: Window, type: K, listener: (event: WindowEventMap[K]) => void, - options?: AddEventListenerOptions + options?: boolean | AddEventListenerOptions ): void; // Document Event based useEventListener interface @@ -38,7 +38,7 @@ function useEventListener( element: Document, type: K, listener: (event: DocumentEventMap[K]) => void, - options?: AddEventListenerOptions + options?: boolean | AddEventListenerOptions ): void; // MediaQueryList Event based useEventListener interface @@ -46,7 +46,7 @@ function useEventListener( element: MediaQueryList, type: K, listener: (event: MediaQueryListEventMap[K]) => void, - options?: AddEventListenerOptions + options?: boolean | AddEventListenerOptions ): void; // Element Event based useEventListener interface @@ -57,7 +57,7 @@ function useEventListener< element: TargetElement, type: K, listener: (event: HTMLElementEventMap[K]) => void, - options?: AddEventListenerOptions + options?: boolean | AddEventListenerOptions ): void; // SVGElement Event based useEventListener interface @@ -68,7 +68,7 @@ function useEventListener< element: TargetElement, type: K, listener: (event: SVGElementEventMap[K]) => void, - options?: AddEventListenerOptions + options?: boolean | AddEventListenerOptions ): void; ``` diff --git a/docs/docs/react/hooks/useFileReader.mdx b/docs/docs/react/hooks/useFileReader.mdx index 7e2975e46..0b3985e79 100644 --- a/docs/docs/react/hooks/useFileReader.mdx +++ b/docs/docs/react/hooks/useFileReader.mdx @@ -22,8 +22,9 @@ interface ReadFileOptions { readType: ReadType; accepts?: string[]; } - -const useFileReader: () => { +``` +```ts title="typescript" +function useFileReader(): { readFile: ({ file, readType, @@ -40,12 +41,14 @@ import React, { useState } from 'react'; import { useFileReader } from '@modern-kit/react'; const Example = () => { - const { readFile, fileContents, loading } = useFileReader() + const { readFile, fileContents, isLoading } = useFileReader() const handleChange = (e: React.ChangeEvent) => { if(!e.target.files) return; - readFile({ file: e.target.files, readType: 'readAsText' }); + const data = await readFile({ file: e.target.files, readType: 'readAsText' }); + // data 처리 + /* * 1. readFile은 Promise 반환합니다. 해당 값은 fileContents와 동일합니다. * ex) const data = await readFile(e.target.files, 'readAsDataURL'); diff --git a/docs/docs/react/hooks/useKeyDown.mdx b/docs/docs/react/hooks/useKeyDown.mdx index dd70b30d3..0cc384b84 100644 --- a/docs/docs/react/hooks/useKeyDown.mdx +++ b/docs/docs/react/hooks/useKeyDown.mdx @@ -1,3 +1,5 @@ +import { useKeyDown } from '@modern-kit/react'; + # useKeyDown `ref`를 전달한 요소가 포커싱된 상태에서 `keydown` 이벤트 발생 시 `keyDownCallbackMap`로 지정한 `key`에 트리거되어 콜백 함수를 호출합니다. @@ -78,10 +80,39 @@ const Example = () => { } }); - return ; + return ( + + ); }; ``` +## Example +export const Example = () => { + const targetRef = useKeyDown({ + enabled: true, // default: true + keyDownCallbackMap: { + Enter: (event) => console.log('Enter', event.key), + Shift: (event) => console.log('Shift', event.key), + ' ': (event) => console.log('Space', event.key), + } + }); + + return ( + + ); +}; + + + + ## Note - [Key values for keyboard events(en) - MDN](https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values) - [KeyboardEvent.key(en) - MDN](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key) diff --git a/docs/docs/react/hooks/useOutsidePointerDown.mdx b/docs/docs/react/hooks/useOutsidePointerDown.mdx index 089d8b78d..0b80719df 100644 --- a/docs/docs/react/hooks/useOutsidePointerDown.mdx +++ b/docs/docs/react/hooks/useOutsidePointerDown.mdx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useMemo, useState, useRef } from 'react'; import { useOutsidePointerDown } from '@modern-kit/react'; import BrowserOnly from '@docusaurus/BrowserOnly'; @@ -18,7 +18,10 @@ import BrowserOnly from '@docusaurus/BrowserOnly'; ## Interface ```ts title="typescript" function useOutsidePointerDown( - callback: (targetElement: T) => void + callback: (targetElement: T) => void, + options?: { + excludeRefs: React.RefObject[]; + } ): React.RefObject; ``` @@ -29,35 +32,37 @@ import { useOutsidePointerDown } from '@modern-kit/react'; const Example = () => { const [number, setNumber] = useState(0); + + const excludeRef = useRef(null); const targetRef = useOutsidePointerDown(() => { setNumber(number + 1); + }, { + excludeRefs: [excludeRef], // 외부 클릭 및 터치 감지를 제외할 요소의 ref 배열입니다. }); const outerBoxStyle = useMemo(() => { - return { - width: '400px', - height: '400px', - background: '#439966', - color: '#fff', - }; + return { /* ... */ }; }, []); const InnerBoxStyle = useMemo(() => { - return { - width: '400px', - height: '400px', - background: '#439966', - color: '#fff', - }; + return { /* ... */ }; + }, []); + + const ExcludeBoxStyle = useMemo(() => { + return { /* ... */ }; }, []); return (
-

Inner Box 외부를 클릭해보세요!

+

Target Box 외부를 클릭해보세요!
(Exclude Box는 클릭 감지에 제외됩니다.)

number: {number}

-

Inner Box

+

Target Box

+
+ +
+

Exclude Box

); @@ -66,15 +71,17 @@ const Example = () => { export const Example = () => { const [number, setNumber] = useState(0); + const excludeRef = useRef(null); const targetRef = useOutsidePointerDown(() => { setNumber(number + 1); + }, { + excludeRefs: [excludeRef], }); const outerBoxStyle = useMemo(() => { return { width: '400px', - height: '400px', - background: '#439966', + background: 'blue', color: '#fff', }; }, []); @@ -82,19 +89,32 @@ export const Example = () => { const InnerBoxStyle = useMemo(() => { return { width: '400px', - height: '400px', background: '#439966', color: '#fff', + padding: '20px', + }; + }, []); + + const ExcludeBoxStyle = useMemo(() => { + return { + width: '200px', + background: 'red', + color: '#fff', + padding: '20px', }; }, []); return (
-

Inner Box 외부를 클릭해보세요!

+

Target Box 외부를 클릭해보세요!
(Exclude Box는 클릭 감지에 제외됩니다.)

number: {number}

-

Inner Box

+

Target Box

+
+ +
+

Exclude Box

); diff --git a/packages/react/src/components/OutsidePointerDownHandler/index.tsx b/packages/react/src/components/OutsidePointerDownHandler/index.tsx index 31f18e6ae..7ea1144c8 100644 --- a/packages/react/src/components/OutsidePointerDownHandler/index.tsx +++ b/packages/react/src/components/OutsidePointerDownHandler/index.tsx @@ -6,8 +6,9 @@ import { Slot } from '../Slot'; import React from 'react'; interface OutsidePointerDownHandlerProps { - onPointerDown: (targetElement: HTMLElement) => void; children: ReactNode; + onPointerDown: (targetElement: HTMLElement) => void; + excludeRefs?: React.RefObject[]; asChild?: boolean; } @@ -36,6 +37,7 @@ const OUTSIDE_POINTER_DOWN_HANDLER_ERROR_MESSAGE = * @param {string} [props.as='div'] - 자식 요소를 감싸는 요소를 지정합니다. 기본 값은 `div`입니다. 해당 요소 외부를 클릭 혹은 터치 시 onPointerDown 함수가 호출됩니다. * @param {boolean} [props.asChild=false] - `true`일 경우 `Slot`을 통해 자식 요소를 그대로 렌더링하고, 해당 자식 요소 외부를 클릭 혹은 터치 시 onPointerDown 함수가 호출됩니다. * @param {() => void} props.onPointerDown - 외부 영역 클릭 혹은 터치 시 실행될 함수 + * @param {React.RefObject[]} [props.excludeRefs] - 외부 클릭 및 터치 감지를 제외할 요소의 ref 배열입니다. * @param {ReactNode} props.children - 자식 컴포넌트 * * @returns {JSX.Element} 외부 영역 클릭 혹은 터치 시 onPointerDown 함수가 호출되는 컴포넌트를 반환합니다. @@ -46,6 +48,14 @@ const OUTSIDE_POINTER_DOWN_HANDLER_ERROR_MESSAGE = * *
Contents
*
+ * + * // excludeRefs 속성을 통해 외부 클릭 및 터치 감지를 제외할 요소를 지정할 수 있습니다. + *
+ * + *
Contents
+ *
+ *
Exclude Box
+ *
* ``` * * @example @@ -64,18 +74,32 @@ const OUTSIDE_POINTER_DOWN_HANDLER_ERROR_MESSAGE = export const OutsidePointerDownHandler = polymorphicForwardRef< 'div', OutsidePointerDownHandlerProps ->(({ children, as = 'div', asChild = false, onPointerDown, ...props }, ref) => { - const targetRef = useOutsidePointerDown(onPointerDown); +>( + ( + { + children, + as = 'div', + asChild = false, + onPointerDown, + excludeRefs = [], + ...props + }, + ref + ) => { + const targetRef = useOutsidePointerDown(onPointerDown, { + excludeRefs, + }); - const Wrapper = asChild ? Slot : as; + const Wrapper = asChild ? Slot : as; - if (asChild && !React.isValidElement(children)) { - throw new Error(OUTSIDE_POINTER_DOWN_HANDLER_ERROR_MESSAGE); - } + if (asChild && !React.isValidElement(children)) { + throw new Error(OUTSIDE_POINTER_DOWN_HANDLER_ERROR_MESSAGE); + } - return ( - - {children} - - ); -}); + return ( + + {children} + + ); + } +); diff --git a/packages/react/src/hooks/useBlockMultipleAsyncCalls/index.ts b/packages/react/src/hooks/useBlockMultipleAsyncCalls/index.ts index 4c19526fc..8e459e51c 100644 --- a/packages/react/src/hooks/useBlockMultipleAsyncCalls/index.ts +++ b/packages/react/src/hooks/useBlockMultipleAsyncCalls/index.ts @@ -1,16 +1,18 @@ import { useCallback, useRef, useState } from 'react'; interface UseBlockMultipleAsyncCallsReturnType { + isError: boolean; isLoading: boolean; blockMultipleAsyncCalls: ( callback: () => Promise ) => Promise; } + /** * @description `useBlockMultipleAsyncCalls` 훅은 진행 중인 비동기 호출이 있을 때 중복 호출을 방지하기 위한 커스텀 훅입니다. * * `debounce`는 함수의 중복 호출을 방지하는 데 대부분의 경우에 효과적입니다. - * 하지만, `debounce`는 비동기 작업의 완료를 보장하지 않기 때문에 다음과 같은 한계가 있습니다: + * 하지만, debounce는 비동기 작업의 완료를 보장하지 않기 때문에 다음과 같은 한계가 있습니다: * * 1. `debounce` 시간이 API 응답 시간보다 짧을 경우: 비동기 작업이 완료되지 않은 상태에서 `다시 호출`될 수 있습니다. * 2. `debounce` 시간이 API 응답 시간보다 길 경우: 비동기 작업이 완료되었지만 `버튼`과 같은 요소가 여전히 `비활성화`되어 있을 수 있습니다. @@ -19,13 +21,14 @@ interface UseBlockMultipleAsyncCallsReturnType { * 대부분의 경우에 `debounce`만으로 충분하지만, 위와 같은 한계점을 대응하고자 한다면 `useBlockMultipleAsyncCalls`를 사용할 수 있습니다. * * @returns {UseBlockMultipleAsyncCallsReturnType} 다음을 포함하는 객체: + * - `isError`: 비동기 작업 중 에러가 발생했는지 나타내는 불리언 값 * - `isLoading`: 현재 비동기 작업이 진행 중인지 나타내는 불리언 값 * - `blockMultipleAsyncCalls`: 비동기 작업을 래핑하여 중복 호출을 방지하는 함수 * * @example * ```tsx - * function MyComponent() { - * const { isLoading, blockMultipleAsyncCalls } = useBlockMultipleAsyncCalls(); + * const Example = () => { + * const { isError, isLoading, blockMultipleAsyncCalls } = useBlockMultipleAsyncCalls(); * * const fetchApi = async () => { * const data = await fetchData(); @@ -36,12 +39,18 @@ interface UseBlockMultipleAsyncCallsReturnType { * blockMultipleAsyncCalls(fetchApi); * }; * - * return + * return ( + *
+ * + * {isError &&

에러 발생

} + *
+ * ); * } * ``` */ export function useBlockMultipleAsyncCalls(): UseBlockMultipleAsyncCallsReturnType { const [isLoading, setIsLoading] = useState(false); + const [isError, setIsError] = useState(false); const isCalled = useRef(false); const blockMultipleAsyncCalls = useCallback( @@ -52,10 +61,14 @@ export function useBlockMultipleAsyncCalls(): UseBlockMultipleAsyncCallsReturnTy isCalled.current = true; setIsLoading(true); + setIsError(false); try { const result = await callback(); return result; + } catch (error) { + setIsError(true); + throw error; } finally { isCalled.current = false; setIsLoading(false); @@ -65,6 +78,7 @@ export function useBlockMultipleAsyncCalls(): UseBlockMultipleAsyncCallsReturnTy ); return { + isError, isLoading, blockMultipleAsyncCalls, }; diff --git a/packages/react/src/hooks/useBlockMultipleAsyncCalls/useBlockMultipleAsyncCalls.spec.tsx b/packages/react/src/hooks/useBlockMultipleAsyncCalls/useBlockMultipleAsyncCalls.spec.tsx index 4991b6fbe..1ae7a5e01 100644 --- a/packages/react/src/hooks/useBlockMultipleAsyncCalls/useBlockMultipleAsyncCalls.spec.tsx +++ b/packages/react/src/hooks/useBlockMultipleAsyncCalls/useBlockMultipleAsyncCalls.spec.tsx @@ -20,26 +20,21 @@ describe('useBlockMultipleAsyncCalls', () => { const { result } = renderHook(useBlockMultipleAsyncCalls); const { blockMultipleAsyncCalls } = result.current; - expect(result.current.isLoading).toBe(false); + expect(result.current.isLoading).toBeFalsy(); blockMultipleAsyncCalls(mockFn); blockMultipleAsyncCalls(mockFn); blockMultipleAsyncCalls(mockFn); await waitFor(async () => { - expect(result.current.isLoading).toBe(true); + expect(result.current.isLoading).toBeTruthy(); expect(mockFn).toHaveBeenCalledTimes(1); }); vi.advanceTimersByTime(DELAY_TIME); await waitFor(async () => { - expect(result.current.isLoading).toBe(false); - }); - - vi.advanceTimersByTime(DELAY_TIME); - - await waitFor(async () => { + expect(result.current.isLoading).toBeFalsy(); expect(mockFn).toHaveBeenCalledTimes(1); }); }); @@ -55,21 +50,60 @@ describe('useBlockMultipleAsyncCalls', () => { const { user } = renderSetup(); const button = screen.getByRole('button'); - expect(result.current.isLoading).toBe(false); + expect(result.current.isLoading).toBeFalsy(); await user.click(button); await user.click(button); await user.click(button); await waitFor(async () => { - expect(result.current.isLoading).toBe(true); + expect(result.current.isLoading).toBeTruthy(); expect(mockFn).toHaveBeenCalledTimes(1); }); vi.advanceTimersByTime(DELAY_TIME); await waitFor(async () => { - expect(result.current.isLoading).toBe(false); + expect(result.current.isLoading).toBeFalsy(); + expect(mockFn).toHaveBeenCalledTimes(1); + }); + }); + + it('비동기 함수 호출 중 에러가 발생하면 isError가 true가 되고, 이후 정상적인 비동기 함수 호출 시 isError가 false로 초기화되어야 합니다.', async () => { + const errorMockFn = vi.fn(async () => { + throw new Error('비동기 작업 중 에러 발생'); + }); + const defaultMockFn = vi.fn(async () => await delay(DELAY_TIME)); + + const { result } = renderHook(useBlockMultipleAsyncCalls); + + const { blockMultipleAsyncCalls } = result.current; + + expect(result.current.isLoading).toBeFalsy(); + expect(result.current.isError).toBeFalsy(); + + await waitFor(async () => { + expect(() => blockMultipleAsyncCalls(errorMockFn)).rejects.toThrowError( + '비동기 작업 중 에러 발생' + ); + expect(result.current.isLoading).toBeFalsy(); + expect(result.current.isError).toBeTruthy(); + }); + + blockMultipleAsyncCalls(defaultMockFn); // 정상 비동기 함수 호출 + + await waitFor(async () => { + expect(result.current.isLoading).toBeTruthy(); + expect(result.current.isError).toBeFalsy(); + expect(defaultMockFn).toHaveBeenCalledTimes(1); + }); + + vi.advanceTimersByTime(DELAY_TIME); + + await waitFor(async () => { + expect(result.current.isLoading).toBeFalsy(); + expect(result.current.isError).toBeFalsy(); + expect(defaultMockFn).toHaveBeenCalledTimes(1); }); }); }); diff --git a/packages/react/src/hooks/useEventListener/index.ts b/packages/react/src/hooks/useEventListener/index.ts index cf6cf7e14..f471fbf25 100644 --- a/packages/react/src/hooks/useEventListener/index.ts +++ b/packages/react/src/hooks/useEventListener/index.ts @@ -27,16 +27,20 @@ import { * | SVGElementEventMap[E] * | Event * ) => void} listener - 이벤트가 발생할 때 호출될 콜백 함수입니다. - * @param {AddEventListenerOptions} [options] 이벤트 리스너에 대한 옵션 객체입니다. - * 옵션에는 `once`, `capture`, `passive`와 같은 기본 이벤트 리스너 옵션과 `onBeforeAddListener`과 같은 커스텀 옵션이 포함될 수 있습니다. - * - `onBeforeAddListener`: 이벤트 리스너를 등록하기 전에 특정 작업을 수행하고자 할 때 사용됩니다. + * @param {boolean | AddEventListenerOptions} options - 이벤트 리스너에 대한 옵션 객체 또는 `useCapture`를 의미하는 `boolean` 값이 올 수 있습니다. + * - 옵션 객체에는 `once`, `capture`, `passive`와 같은 기본 이벤트 리스너 옵션들을 포함합니다. + * - useCapture는 이벤트 전파 단계를 결정하는 값으로, `true`일 경우 `캡처링` 단계에서, `false`일 경우 `버블링` 단계에서 이벤트가 처리됩니다. 기본값은 `false`입니다. * * @returns {void} * * @example - * // window + * // window 타겟 및 options/useCapture 사용 예제 * useEventListener(window, 'resize', callback); * + * useEventListener(window, 'resize', callback, options); + * + * useEventListener(window, 'resize', callback, true); // 캡처링 설정 + * * @example * // document * useEventListener(document, 'click', callback); @@ -56,7 +60,7 @@ export function useEventListener( element: Window, type: K, listener: (event: WindowEventMap[K]) => void, - options?: AddEventListenerOptions + options?: boolean | AddEventListenerOptions ): void; // Document Event based useEventListener interface @@ -64,7 +68,7 @@ export function useEventListener( element: Document, type: K, listener: (event: DocumentEventMap[K]) => void, - options?: AddEventListenerOptions + options?: boolean | AddEventListenerOptions ): void; // MediaQueryList Event based useEventListener interface @@ -72,7 +76,7 @@ export function useEventListener( element: MediaQueryList, type: K, listener: (event: MediaQueryListEventMap[K]) => void, - options?: AddEventListenerOptions + options?: boolean | AddEventListenerOptions ): void; // Element Event based useEventListener interface @@ -83,7 +87,7 @@ export function useEventListener< element: TargetElement, type: K, listener: (event: HTMLElementEventMap[K]) => void, - options?: AddEventListenerOptions + options?: boolean | AddEventListenerOptions ): void; // SVGElement Event based useEventListener interface @@ -94,7 +98,7 @@ export function useEventListener< element: TargetElement, type: K, listener: (event: SVGElementEventMap[K]) => void, - options?: AddEventListenerOptions + options?: boolean | AddEventListenerOptions ): void; export function useEventListener< @@ -116,7 +120,7 @@ export function useEventListener< | MediaQueryListEventMap[M] | Event ) => void, - options?: AddEventListenerOptions + options?: boolean | AddEventListenerOptions ): void { const preservedListener = usePreservedCallback(listener); diff --git a/packages/react/src/hooks/useFileReader/index.ts b/packages/react/src/hooks/useFileReader/index.ts index c46d09003..c69b3c3e5 100644 --- a/packages/react/src/hooks/useFileReader/index.ts +++ b/packages/react/src/hooks/useFileReader/index.ts @@ -4,7 +4,7 @@ import { getFiles, getReaderPromise, inValidFileType, -} from './internal'; +} from './useFileReader.utils'; type ReadType = 'readAsText' | 'readAsDataURL' | 'readAsArrayBuffer'; @@ -14,7 +14,38 @@ interface ReadFileOptions { accepts?: string[]; } -export function useFileReader() { +interface UseFileReaderReturnType { + readFile: ({ + file, + readType, + accepts, + }: ReadFileOptions) => Promise; + fileContents: FileContent[]; + isLoading: boolean; +} + +/** + * @description `File` 객체를 원하는 읽기 메서드(`readAsText`,`readAsDataURL`,`readAsArrayBuffer`)로 읽고, 읽은 파일 컨텐츠를 반환하는 커스텀 훅입니다. + * + * @returns {UseFileReaderReturnType} - 파일 읽기 함수, 파일 내용, 로딩 상태를 포함하는 객체를 반환합니다. + * @property {{file, readType, accepts}: ReadFileOptions} readFile - 파일을 읽는 비동기 함수입니다. + * - `file`: 읽을 파일 또는 파일 목록입니다. + * - `readType`: 파일을 읽는 방법을 지정합니다. ('readAsText', 'readAsDataURL', 'readAsArrayBuffer' 중 하나) + * - `accepts`: 허용되는 파일 유형의 배열입니다. + * @property {FileContent[]} fileContents - 읽은 파일의 내용을 저장하는 상태입니다. + * @property {boolean} isLoading - 파일을 읽는 동안 로딩 상태를 나타내는 상태입니다. + * + * @example + * ```tsx + * const { readFile, fileContents, isLoading } = useFileReader(); + * + * const handleChange = (e: React.ChangeEvent) => { + * if(!e.target.files) return; + * readFile({ file: e.target.files, readType: 'readAsText' }); + * } + * ``` + */ +export function useFileReader(): UseFileReaderReturnType { const [fileContents, setFileContents] = useState([]); const [isLoading, setIsLoading] = useState(false); diff --git a/packages/react/src/hooks/useFileReader/useFileReader.spec.ts b/packages/react/src/hooks/useFileReader/useFileReader.spec.ts index cacc02430..227be3970 100644 --- a/packages/react/src/hooks/useFileReader/useFileReader.spec.ts +++ b/packages/react/src/hooks/useFileReader/useFileReader.spec.ts @@ -32,8 +32,8 @@ const errorFileContent = { }; describe('useFileReader', () => { - describe('Success Case', () => { - it('should return the normal file contents in "fileContents" when a value of type "File" is passed as an argument to "readFile"', async () => { + describe('성공 케이스', () => { + it('"readFile"의 인자로 "File" 타입의 값이 전달되면 "fileContents"에 정상적인 파일 내용을 반환해야 합니다.', async () => { const { result } = renderHook(() => useFileReader()); const expectedSuccessFileContents = [getSuccessFileContent(testFile1)]; @@ -55,7 +55,7 @@ describe('useFileReader', () => { }); }); - it('should return the normal file contents in "fileContents" when a value of type "FileList" is passed as an argument to "readFile"', async () => { + it('"readFile"의 인자로 "FileList" 타입의 값이 전달되면 "fileContents"에 정상적인 파일 내용을 반환해야 합니다.', async () => { const { result } = renderHook(() => useFileReader()); const expectedSuccessFileContents = [ getSuccessFileContent(testFile1), @@ -80,7 +80,7 @@ describe('useFileReader', () => { }); }); - it('should only read files of types specified in the "accepts" attribute', async () => { + it('"accepts" 속성에 지정된 타입의 파일만 읽어야 합니다.', async () => { const { result } = renderHook(() => useFileReader()); const expectedSuccessFileContents = [getSuccessFileContent(testFile2)]; @@ -103,9 +103,9 @@ describe('useFileReader', () => { }); }); - describe('Error Case', () => { + describe('에러 케이스', () => { // Line: getReaderPromise - reader.onerror - it('should return the error contents in "fileContents" when "reader.onerror" is called', async () => { + it('"reader.onerror"가 호출되면 "fileContents"에 에러 내용을 반환해야 합니다.', async () => { const { result } = renderHook(() => useFileReader()); const failedExpectedFileContents = [errorFileContent]; @@ -125,7 +125,7 @@ describe('useFileReader', () => { }); // Line: readerPromises - catch - it('should return the error contents in "fileContents" if an error occurs during the call to "reader[readType]"', async () => { + it('"reader[readType]" 호출 중 에러가 발생하면 "fileContents"에 에러 내용을 반환해야 합니다.', async () => { const { result } = renderHook(() => useFileReader()); const failedExpectedFileContents = [errorFileContent]; @@ -146,7 +146,7 @@ describe('useFileReader', () => { }); // Line: inValidFileType - it('should return an empty array for "fileContents" if the argument to "readFile" is neither of type "File" nor "FileList"', async () => { + it('"readFile"의 인자가 "File" 또는 "FileList" 타입이 아닌 경우 "fileContents"에 빈 배열을 반환해야 합니다.', async () => { const { result } = renderHook(() => useFileReader()); await waitFor(async () => { diff --git a/packages/react/src/hooks/useFileReader/internal.ts b/packages/react/src/hooks/useFileReader/useFileReader.utils.ts similarity index 100% rename from packages/react/src/hooks/useFileReader/internal.ts rename to packages/react/src/hooks/useFileReader/useFileReader.utils.ts diff --git a/packages/react/src/hooks/useInterval/index.ts b/packages/react/src/hooks/useInterval/index.ts index b77a631ba..9f26d66bf 100644 --- a/packages/react/src/hooks/useInterval/index.ts +++ b/packages/react/src/hooks/useInterval/index.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef } from 'react'; -import { usePreservedCallback, usePreservedState } from '../../hooks'; +import { usePreservedCallback } from '../usePreservedCallback'; import { getIntervalOptions } from './useInterval.utils'; import { type UseIntervalReturnType, @@ -48,14 +48,13 @@ export function useInterval( ): UseIntervalReturnType { const intervalRef = useRef(); - const callbackAction = usePreservedCallback(callback); - const preservedOptions = usePreservedState(options); + const preservedCallback = usePreservedCallback(callback); - const { delay, enabled } = getIntervalOptions(preservedOptions); + const { delay, enabled } = getIntervalOptions(options); const set = useCallback(() => { - intervalRef.current = window.setInterval(callbackAction, delay); - }, [callbackAction, delay]); + intervalRef.current = window.setInterval(preservedCallback, delay); + }, [preservedCallback, delay]); const clear = useCallback(() => { if (intervalRef.current) { diff --git a/packages/react/src/hooks/useKeyDown/index.ts b/packages/react/src/hooks/useKeyDown/index.ts index 8956be902..b72e4620a 100644 --- a/packages/react/src/hooks/useKeyDown/index.ts +++ b/packages/react/src/hooks/useKeyDown/index.ts @@ -2,7 +2,6 @@ import { usePreservedCallback } from '../usePreservedCallback'; import { RefObject, useEffect, useRef } from 'react'; import { KeyDownCallbackMap } from './useKeyDown.utils'; import { isFunction } from '@modern-kit/utils'; -import { usePreservedState } from '../../hooks/usePreservedState'; interface UseKeyDownProps { enabled?: boolean; @@ -65,7 +64,6 @@ export function useKeyDown({ keyDownCallbackMap = {}, allKeyDownCallback, }: UseKeyDownProps): RefObject { - const preservedKeyDownCallbackMap = usePreservedState(keyDownCallbackMap); const targetRef = useRef(null); const onKeyDown = usePreservedCallback((event: KeyboardEvent) => { @@ -78,7 +76,7 @@ export function useKeyDown({ const key = event.key as keyof KeyDownCallbackMap; - const keyDownCallback = preservedKeyDownCallbackMap[key]; + const keyDownCallback = keyDownCallbackMap[key]; if (isFunction(keyDownCallback)) { keyDownCallback(event); diff --git a/packages/react/src/hooks/useLocalStorage/index.ts b/packages/react/src/hooks/useLocalStorage/index.ts index a719b0776..7825e879d 100644 --- a/packages/react/src/hooks/useLocalStorage/index.ts +++ b/packages/react/src/hooks/useLocalStorage/index.ts @@ -6,7 +6,6 @@ import { useMemo, useSyncExternalStore, } from 'react'; -import { usePreservedState } from '../usePreservedState'; import { getServerSnapshot, getSnapshot, @@ -49,9 +48,9 @@ export function useLocalStorage(props: UseLocalStorageProps) { const { key } = props; const initialValue = 'initialValue' in props ? props.initialValue : null; - const initialValueToUse = usePreservedState( - isFunction(initialValue) ? initialValue() : initialValue - ); + const initialValueToUse = isFunction(initialValue) + ? initialValue() + : initialValue; const externalStoreState = useSyncExternalStore( subscribe, @@ -78,7 +77,7 @@ export function useLocalStorage(props: UseLocalStorageProps) { localStorageEventHandler.dispatchEvent(); } catch (err) { throw new Error( - `Failed to store data for key "${key}" in localStorage: ${err}` + `로컬 스토리지 "${key}" key에 데이터를 저장하는데 실패했습니다: ${err}` ); } }, @@ -91,7 +90,7 @@ export function useLocalStorage(props: UseLocalStorageProps) { localStorageEventHandler.dispatchEvent(); } catch (err) { throw new Error( - `Failed to remove key "${key}" from localStorage: ${err}` + `로컬 스토리지 "${key}" key의 데이터를 삭제하는데 실패했습니다: ${err}` ); } }, [key]); diff --git a/packages/react/src/hooks/useOutsidePointerDown/index.ts b/packages/react/src/hooks/useOutsidePointerDown/index.ts index f9ecd77db..94081fa97 100644 --- a/packages/react/src/hooks/useOutsidePointerDown/index.ts +++ b/packages/react/src/hooks/useOutsidePointerDown/index.ts @@ -1,4 +1,4 @@ -import { useCallback, useRef, useMemo } from 'react'; +import { useRef, useMemo } from 'react'; import { isMobile } from '@modern-kit/utils'; import { useEventListener } from '../useEventListener'; @@ -6,47 +6,80 @@ import { useEventListener } from '../useEventListener'; * @description 특정 요소 외부에서 마우스 또는 터치 이벤트가 발생할 때 호출되는 콜백을 등록하는 커스텀 훅입니다. * * `useOutsidePointerDown` 훅은 지정된 요소(ref로 지정된 요소) 외부에서 사용자가 마우스를 클릭하거나 - * 터치 이벤트가 발생할 때마다 제공된 `callback` 함수를 호출합니다. 모바일 환경에서는 `touchstart` 이벤트를, - * 데스크탑 환경에서는 `mousedown` 이벤트를 감지합니다. + * 터치 이벤트가 발생할 때마다 제공된 `callback` 함수를 호출합니다. + * + * 모바일 환경에서는 `touchstart` 이벤트를, 데스크탑 환경에서는 `mousedown` 이벤트를 감지합니다. * * @template T - HTML 요소의 타입. 기본적으로 `HTMLElement`를 상속합니다. * @param {(targetElement: T) => void} callback - 요소 외부에서 포인터 다운 이벤트가 발생할 때 호출되는 콜백 함수입니다. * 해당 요소의 레퍼런스를 매개변수로 받습니다. + * @param {UseOutsidePointerDownOptions} options - `useOutsidePointerDown` 훅의 옵션 객체입니다. + * @param {React.RefObject[]} options.excludeRefs - 외부 클릭 및 터치 감지를 제외할 요소의 ref 배열입니다. * * @returns {React.RefObject} - 외부 클릭 감지를 원하는 DOM 요소에 연결할 ref 객체를 반환합니다. * * @example * ```tsx - * const targetRef = useOutsidePointerDown((targetElement) => { + * const Example = () => { + * const targetRef = useOutsidePointerDown((targetElement) => { * console.log('외부 클릭 감지:', targetElement); * }); * - *
- *
- *
+ * return ( + *
+ * target box + *
+ * ); + * }; + * ``` + * + * @example + * ```tsx + * const Example = () => { + * // 외부 요소에서 특정 요소를 제외하고 클릭/터치 감지 + * const excludeRef = useRef(null); + * const targetRef = useOutsidePointerDown(callback, { excludeRefs: [excludeRef] }); + * + * return ( + *
+ *
+ * target box + *
+ *
+ * exclude box + *
+ *
+ * ); + * }; * ``` */ export function useOutsidePointerDown( - callback: (targetElement: T) => void + callback: (targetElement: T) => void, + options?: { excludeRefs: React.RefObject[] } ): React.RefObject { + const { excludeRefs } = options ?? {}; const targetRef = useRef(null); const eventType = useMemo( () => (isMobile() ? 'touchstart' : 'mousedown'), [] ); - const handleOutsideClick = useCallback( - ({ target }: MouseEvent | TouchEvent) => { - const targetElement = targetRef.current; + const handleOutsidePointerDown = ({ target }: MouseEvent | TouchEvent) => { + if (!targetRef.current) return; + const targetElement = targetRef.current; - if (targetElement && !targetElement.contains(target as Node)) { - callback(targetElement); - } - }, - [callback] - ); + const isInExcluded = excludeRefs?.some((excludeRef) => + excludeRef.current?.contains(target as Node) + ); + const isOutside = !targetElement.contains(target as Node); + const shouldTriggerCallback = isOutside && !isInExcluded; + + if (shouldTriggerCallback) { + callback(targetElement); + } + }; - useEventListener(document, eventType, handleOutsideClick); + useEventListener(document, eventType, handleOutsidePointerDown); return targetRef; } diff --git a/packages/react/src/hooks/useOutsidePointerDown/useOutsidePointerDown.spec.tsx b/packages/react/src/hooks/useOutsidePointerDown/useOutsidePointerDown.spec.tsx index 1823cbfcd..d423798cd 100644 --- a/packages/react/src/hooks/useOutsidePointerDown/useOutsidePointerDown.spec.tsx +++ b/packages/react/src/hooks/useOutsidePointerDown/useOutsidePointerDown.spec.tsx @@ -2,9 +2,13 @@ import { describe, it, expect, vi } from 'vitest'; import { screen } from '@testing-library/react'; import { useOutsidePointerDown } from '.'; import { renderSetup } from '../../_internal/test/renderSetup'; +import { useRef } from 'react'; const TestComponent = ({ onAction }: { onAction: () => void }) => { - const targetRef = useOutsidePointerDown(onAction); + const excludeRef = useRef(null); + const targetRef = useOutsidePointerDown(onAction, { + excludeRefs: [excludeRef], + }); return (
@@ -12,12 +16,15 @@ const TestComponent = ({ onAction }: { onAction: () => void }) => {
inner
+
+ exclude +
); }; describe('useOutsidePointerDown', () => { - it('should call the callback function when clicking on an element outside of the target element', async () => { + it('타겟 요소 외부의 요소를 클릭할 때 콜백 함수가 호출되어야 합니다.', async () => { const mockFn = vi.fn(); const { user } = renderSetup(); @@ -33,4 +40,18 @@ describe('useOutsidePointerDown', () => { await user.click(innerBox); expect(mockFn).toBeCalledTimes(1); }); + + it('외부 요소 클릭 탐지 제외 요소를 클릭할 때 콜백 함수가 호출되지 않아야 합니다.', async () => { + const mockFn = vi.fn(); + const { user } = renderSetup(); + + const excludeBox = screen.getByRole('exclude-box'); + const outsideBox = screen.getByRole('outside-box'); + + await user.click(excludeBox); + expect(mockFn).toBeCalledTimes(0); + + await user.click(outsideBox); + expect(mockFn).toBeCalledTimes(1); + }); }); diff --git a/packages/react/src/hooks/useSessionStorage/index.ts b/packages/react/src/hooks/useSessionStorage/index.ts index 54e8048cc..7888abe9a 100644 --- a/packages/react/src/hooks/useSessionStorage/index.ts +++ b/packages/react/src/hooks/useSessionStorage/index.ts @@ -6,7 +6,6 @@ import { useMemo, useSyncExternalStore, } from 'react'; -import { usePreservedState } from '../usePreservedState'; import { getServerSnapshot, getSnapshot, @@ -48,9 +47,9 @@ export function useSessionStorage(props: UseSessionStorageProps) { const { key } = props; const initialValue = 'initialValue' in props ? props.initialValue : null; - const initialValueToUse = usePreservedState( - isFunction(initialValue) ? initialValue() : initialValue - ); + const initialValueToUse = isFunction(initialValue) + ? initialValue() + : initialValue; const externalStoreState = useSyncExternalStore( subscribe, @@ -77,7 +76,7 @@ export function useSessionStorage(props: UseSessionStorageProps) { sessionStorageEventHandler.dispatchEvent(); } catch (err) { throw new Error( - `Failed to store data for key "${key}" in sessionStorage: ${err}` + `세션 스토리지 "${key}" key에 데이터를 저장하는데 실패했습니다: ${err}` ); } }, @@ -90,7 +89,7 @@ export function useSessionStorage(props: UseSessionStorageProps) { sessionStorageEventHandler.dispatchEvent(); } catch (err) { throw new Error( - `Failed to remove key "${key}" from sessionStorage: ${err}` + `세션 스토리지 "${key}" key의 데이터를 삭제하는데 실패했습니다: ${err}` ); } }, [key]); diff --git a/packages/react/src/hooks/useStepState/index.ts b/packages/react/src/hooks/useStepState/index.ts index 654d2fd47..961fcc74f 100644 --- a/packages/react/src/hooks/useStepState/index.ts +++ b/packages/react/src/hooks/useStepState/index.ts @@ -5,7 +5,6 @@ import { } from '@modern-kit/utils'; import { UseStepProps, useStep } from '../useStep'; import { SetStateAction, useCallback, useState } from 'react'; -import { usePreservedState } from '../usePreservedState'; interface StorageOptions { key: string; @@ -29,48 +28,45 @@ export function useStepState({ initialState, ...props }: UseStepWithInitialState): ReturnType & { - readonly state: T; - readonly setState: (newState: SetStateAction) => void; - readonly clearState: () => void; + state: T; + setState: (newState: SetStateAction) => void; + clearState: () => void; }; export function useStepState(props: UseStepWithoutInitialState): ReturnType< typeof useStep > & { - readonly state: T | null; - readonly setState: (newState: SetStateAction) => void; - readonly clearState: () => void; + state: T | null; + setState: (newState: SetStateAction) => void; + clearState: () => void; }; export function useStepState(props: UseStepStateProps) { const initialState = 'initialState' in props ? props.initialState : null; - const preservedStorageOptions = usePreservedState(props.storageOptions); - const preservedInitialState = usePreservedState(initialState); + const { type, key } = props?.storageOptions ?? {}; - const [_state, _setState] = useState(preservedInitialState); + const [_state, _setState] = useState(initialState); const setState = useCallback( (newState: SetStateAction) => { _setState((prev) => { const newStateToUse = isFunction(newState) ? newState(prev) : newState; - if (preservedStorageOptions) { - const { type, key } = preservedStorageOptions; + if (type && key) { setStorageItem(type, key, newStateToUse); } return newStateToUse; }); }, - [preservedStorageOptions] + [type, key] ); const clearState = useCallback(() => { - if (preservedStorageOptions) { - const { type, key } = preservedStorageOptions; + if (type && key) { removeStorageItem(type, key); } _setState(null); - }, [preservedStorageOptions]); + }, [type, key]); - return { state: _state, setState, clearState, ...useStep(props) } as const; + return { state: _state, setState, clearState, ...useStep(props) }; } diff --git a/packages/react/src/hooks/useTimeout/index.ts b/packages/react/src/hooks/useTimeout/index.ts index c58c24900..7684e899c 100644 --- a/packages/react/src/hooks/useTimeout/index.ts +++ b/packages/react/src/hooks/useTimeout/index.ts @@ -4,7 +4,7 @@ import { type UseTimeoutReturnType, type TimeoutOptions, } from './useTimeout.types'; -import { usePreservedCallback, usePreservedState } from '../../hooks'; +import { usePreservedCallback } from '../usePreservedCallback'; /** * @description `useTimeout`훅은 지정된 지연 시간 후에 콜백 함수를 호출하는 커스텀 훅입니다. @@ -49,9 +49,8 @@ export function useTimeout( const timeoutRef = useRef(); const callbackAction = usePreservedCallback(callback); - const preservedOptions = usePreservedState(options); - const { delay, enabled } = getTimeoutOptions(preservedOptions); + const { delay, enabled } = getTimeoutOptions(options); const set = useCallback(() => { timeoutRef.current = setTimeout(callbackAction, delay);