diff --git a/docs/docs/react/components/InView.mdx b/docs/docs/react/components/InView.mdx index 801405f31..55f89f9ee 100644 --- a/docs/docs/react/components/InView.mdx +++ b/docs/docs/react/components/InView.mdx @@ -4,13 +4,11 @@ import { InView } from '@modern-kit/react'; `InView`는 `IntersectionObserver`를 선언적으로 활용 할 수 있는 컴포넌트입니다. -`@modern-kit/react`의 **[useIntersectionObserver](https://modern-agile-team.github.io/modern-kit/docs/react/hooks/useIntersectionObserver)** 훅을 사용하여 구현되었습니다. - -`Viewport`에 노출될 때(`onIntersectStart`) 혹은 나갈 때(`onIntersectEnd`) 특정 action 함수를 호출 할 수 있는 컴포넌트입니다. +관찰 대상이 `Viewport`에 노출될 때(`onIntersectStart`) 혹은 나갈 때(`onIntersectEnd`) 특정 action 함수를 호출 할 수 있는 컴포넌트입니다. -`calledOnce`를 활용하면 `onIntersectStart`와 `onIntersectEnd`를 각각 한번씩 호출 할 수 있습니다. +다형성을 지원하기 때문에 `as` 속성을 통해 특정 요소로 렌더링할 수 있습니다. -Intersection Observer Option을 설정할 수 있습니다.(하단 `Note` 참고) +`@modern-kit/react`의 **[useIntersectionObserver](https://modern-agile-team.github.io/modern-kit/docs/react/hooks/useIntersectionObserver)** 훅을 사용하여 구현되었습니다.
@@ -20,14 +18,17 @@ Intersection Observer Option을 설정할 수 있습니다.(하단 `Note` 참고 ## Interface ```ts title="typescript" interface InViewProps extends UseIntersectionObserverProps { - children: JSX.Element; + children: React.ReactNode; } ``` ```ts title="typescript" -const InView: ({ children, ...props }: InViewProps) => JSX.Element +const InView: PolyForwardComponent<"div", InViewProps, React.ElementType> ``` ## Usage +### Default +- 기본적으로 `div`로 감싸지며, 해당 `div`를 관찰 대상으로 설정합니다. +- 해당 `div`가 viewport에 노출되거나 숨겨질 때 `onIntersectStart/onIntersectEnd` 콜백 함수를 호출합니다. ```tsx title="typescript" import { InView } from '@modern-kit/react'; @@ -42,12 +43,41 @@ const Example = () => { return (
- {/* ... */} + +
Box1
+
+
; + ); +}; +``` + +### asChild +- 자식 요소를 그대로 렌더링하고, 해당 요소를 관찰 대상으로 설정합니다. +- 자식 요소가 viewport에 노출되거나 숨겨질 때 `onIntersectStart/onIntersectEnd` 콜백 함수를 호출합니다. +- 이때 자식 요소는 단일 요소만 허용됩니다. +```tsx title="typescript" +import { InView } from '@modern-kit/react'; + +const Example = () => { + const ref = useRef(null); + + const handleIntersectStart = () => { + /* action */ + } + + const handleIntersectEnd = () => { + /* action */ + } + + return ( +
-
Box1
+ onIntersectEnd={handleIntersectEnd} + > +
  • List Item1
  • +
  • List Item2
  • ; ); @@ -77,32 +107,30 @@ export const Example = () => { console.log('action onIntersectStart(1)')} onIntersectEnd={() => console.log('action onIntersectEnd(1)')} - calledOnce - > -
    -

    Box1

    -

    브라우저 개발자 도구의 콘솔을 확인해주세요.

    -

    onIntersectStart가 최초 1회만 호출됩니다.

    -

    calledOnce: true

    -
    + }} + calledOnce + > +

    Box1

    +

    브라우저 개발자 도구의 콘솔을 확인해주세요.

    +

    calledOnce: true

    +

    as: div

    console.log('action onIntersectStart(2)')} onIntersectEnd={() => console.log('action onIntersectEnd(2)')} - > -
    -

    Box2

    -

    브라우저 개발자 도구의 콘솔을 확인해주세요.

    -

    onIntersectStart, onIntersectEnd 함수가 여러 번 호출됩니다.

    -

    calledOnce: false

    -
    + }} + > +
  • Box2
  • +
  • 브라우저 개발자 도구의 콘솔을 확인해주세요.
  • +
  • calledOnce: false
  • +
  • as: ul
  • diff --git a/packages/react/src/utils/test/mockFile.ts b/packages/react/src/_internal/test/mockFile.ts similarity index 100% rename from packages/react/src/utils/test/mockFile.ts rename to packages/react/src/_internal/test/mockFile.ts diff --git a/packages/react/src/utils/test/mockIntersectionObserver.ts b/packages/react/src/_internal/test/mockIntersectionObserver.ts similarity index 100% rename from packages/react/src/utils/test/mockIntersectionObserver.ts rename to packages/react/src/_internal/test/mockIntersectionObserver.ts diff --git a/packages/react/src/utils/test/renderSetup.ts b/packages/react/src/_internal/test/renderSetup.ts similarity index 100% rename from packages/react/src/utils/test/renderSetup.ts rename to packages/react/src/_internal/test/renderSetup.ts diff --git a/packages/react/src/components/ClientGate/ClientGate.spec.tsx b/packages/react/src/components/ClientGate/ClientGate.spec.tsx index d9518a4ad..6a3a00f4a 100644 --- a/packages/react/src/components/ClientGate/ClientGate.spec.tsx +++ b/packages/react/src/components/ClientGate/ClientGate.spec.tsx @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { screen } from '@testing-library/react'; -import { renderSetup } from '../../utils/test/renderSetup'; +import { renderSetup } from '../../_internal/test/renderSetup'; import { renderToString } from 'react-dom/server'; import { ClientGate } from '.'; diff --git a/packages/react/src/components/DebounceWrapper/DebounceWrapper.spec.tsx b/packages/react/src/components/DebounceWrapper/DebounceWrapper.spec.tsx index a4bc21385..c072b66d5 100644 --- a/packages/react/src/components/DebounceWrapper/DebounceWrapper.spec.tsx +++ b/packages/react/src/components/DebounceWrapper/DebounceWrapper.spec.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { renderSetup } from '../../utils/test/renderSetup'; +import { renderSetup } from '../../_internal/test/renderSetup'; import { DebounceWrapper } from '.'; import { ChangeEvent, useState } from 'react'; import { act, screen } from '@testing-library/react'; diff --git a/packages/react/src/components/FallbackLazyImage/FallbackLazyImage.spec.tsx b/packages/react/src/components/FallbackLazyImage/FallbackLazyImage.spec.tsx index 8778e8f43..8ad2bc0a9 100644 --- a/packages/react/src/components/FallbackLazyImage/FallbackLazyImage.spec.tsx +++ b/packages/react/src/components/FallbackLazyImage/FallbackLazyImage.spec.tsx @@ -5,8 +5,8 @@ import { mockIntersecting, mockIntersectionObserverCleanup, mockIntersectionObserverSetup, -} from '../../utils/test/mockIntersectionObserver'; -import { renderSetup } from '../../utils/test/renderSetup'; +} from '../../_internal/test/mockIntersectionObserver'; +import { renderSetup } from '../../_internal/test/renderSetup'; import { Mock } from 'vitest'; beforeEach(() => { diff --git a/packages/react/src/components/InView/InView.spec.tsx b/packages/react/src/components/InView/InView.spec.tsx index 0a9c4d704..d4dd664fb 100644 --- a/packages/react/src/components/InView/InView.spec.tsx +++ b/packages/react/src/components/InView/InView.spec.tsx @@ -5,8 +5,9 @@ import { mockIntersecting, mockIntersectionObserverCleanup, mockIntersectionObserverSetup, -} from '../../utils/test/mockIntersectionObserver'; -import { renderSetup } from '../../utils/test/renderSetup'; +} from '../../_internal/test/mockIntersectionObserver'; +import { renderSetup } from '../../_internal/test/renderSetup'; +import { ElementType } from 'react'; beforeEach(() => { mockIntersectionObserverSetup(); @@ -19,16 +20,19 @@ afterEach(() => { interface TestComponentProps { onIntersectStart: () => void; onIntersectEnd: () => void; + as?: ElementType; calledOnce?: boolean; } const TestComponent = ({ onIntersectStart, onIntersectEnd, + as, calledOnce, }: TestComponentProps) => { return ( @@ -41,7 +45,7 @@ describe('InView', () => { const intersectStartMock = vi.fn(); const intersectEndMock = vi.fn(); - it('InView 컴포넌트가 viewport에 노출되거나 숨겨질 때 onIntersect 콜백 함수를 호출해야 합니다.', async () => { + it('InView 컴포넌트가 viewport에 노출되거나 숨겨질 때 onIntersect 콜백 함수를 호출해야 합니다. 기본적으로 div 요소로 렌더링되어야 합니다.', async () => { renderSetup( { /> ); - const box = screen.getByText('box'); + const boxWrapper = screen.getByText('box').parentElement as HTMLElement; + expect(boxWrapper.tagName).toBe('DIV'); expect(intersectStartMock).toBeCalledTimes(0); expect(intersectEndMock).toBeCalledTimes(0); - await waitFor(() => mockIntersecting({ type: 'view', element: box })); + await waitFor(() => + mockIntersecting({ type: 'view', element: boxWrapper }) + ); + expect(intersectStartMock).toBeCalledTimes(1); + + await waitFor(() => + mockIntersecting({ type: 'hide', element: boxWrapper }) + ); + expect(intersectEndMock).toBeCalledTimes(1); + }); + + it('as props를 통해 특정 요소로 렌더링할 수 있습니다.', async () => { + renderSetup( + + ); + + const ulWrapper = screen.getByText('box').parentElement as HTMLElement; + expect(ulWrapper.tagName).toBe('UL'); + + await waitFor(() => mockIntersecting({ type: 'view', element: ulWrapper })); expect(intersectStartMock).toBeCalledTimes(1); - await waitFor(() => mockIntersecting({ type: 'hide', element: box })); + await waitFor(() => mockIntersecting({ type: 'hide', element: ulWrapper })); expect(intersectEndMock).toBeCalledTimes(1); }); @@ -70,17 +98,27 @@ describe('InView', () => { /> ); - const box = screen.getByText('box'); + const boxWrapper = screen.getByText('box').parentElement as HTMLElement; - await waitFor(() => mockIntersecting({ type: 'view', element: box })); + await waitFor(() => + mockIntersecting({ type: 'view', element: boxWrapper }) + ); expect(intersectStartMock).toBeCalledTimes(1); - await waitFor(() => mockIntersecting({ type: 'hide', element: box })); + await waitFor(() => + mockIntersecting({ type: 'hide', element: boxWrapper }) + ); expect(intersectEndMock).toBeCalledTimes(1); - await waitFor(() => mockIntersecting({ type: 'view', element: box })); - await waitFor(() => mockIntersecting({ type: 'hide', element: box })); - await waitFor(() => mockIntersecting({ type: 'view', element: box })); + await waitFor(() => + mockIntersecting({ type: 'view', element: boxWrapper }) + ); + await waitFor(() => + mockIntersecting({ type: 'hide', element: boxWrapper }) + ); + await waitFor(() => + mockIntersecting({ type: 'view', element: boxWrapper }) + ); expect(intersectStartMock).toBeCalledTimes(1); expect(intersectEndMock).toBeCalledTimes(1); diff --git a/packages/react/src/components/InView/index.tsx b/packages/react/src/components/InView/index.tsx index 929458620..300c023d3 100644 --- a/packages/react/src/components/InView/index.tsx +++ b/packages/react/src/components/InView/index.tsx @@ -1,22 +1,27 @@ -import { Slot } from '../Slot'; +import React from 'react'; import { useIntersectionObserver, UseIntersectionObserverProps, } from '../../hooks/useIntersectionObserver'; +import { polymorphicForwardRef } from '../../utils/polymorphicForwardRef'; +import { useMergeRefs } from '../../hooks/useMergeRefs'; interface InViewProps extends UseIntersectionObserverProps { - children: JSX.Element; + children: React.ReactNode; } /** * @description `InView`는 `IntersectionObserver`를 선언적으로 활용 할 수 있는 컴포넌트입니다. * + * 관찰 대상이 `viewport`에 노출될 때(`onIntersectStart`) 혹은 나갈 때(`onIntersectEnd`) 특정 action 함수를 호출 할 수 있는 컴포넌트입니다. + * * `@modern-kit/react`의 `useIntersectionObserver` 훅을 사용하여 구현되었습니다. * * @see https://modern-agile-team.github.io/modern-kit/docs/react/hooks/useIntersectionObserver * * @param {InViewProps} props - 컴포넌트에 전달되는 속성들입니다. - * @param {JSX.Element} props.children - 관찰할 자식 요소입니다. + * @param {React.ReactNode} props.children - 관찰할 자식 요소입니다. + * @param {boolean} props.asChild - 자식 요소를 그대로 렌더링할지 여부를 나타냅니다. `true`일 경우 자식 요소가 그대로 렌더링되며, 자식 요소가 관찰 대상이됩니다. * @param {(entry: IntersectionObserverEntry) => void} props.onIntersectStart - 타겟 요소가 viewport 내에 들어갈 때 호출되는 콜백 함수입니다. * @param {(entry: IntersectionObserverEntry) => void} props.onIntersectEnd - 타겟 요소가 viewport에서 나갈 때 호출되는 콜백 함수입니다. * @param {number | number[]} props.threshold - 관찰을 시작할 viewport의 가시성 비율입니다. @@ -24,9 +29,36 @@ interface InViewProps extends UseIntersectionObserverProps { * @param {string} props.rootMargin - 루트 요소에 대한 마진을 지정합니다. 이는 뷰포트 또는 루트 요소의 경계를 확장하거나 축소하는데 사용됩니다. * @param {boolean} props.enabled - Observer를 활성화할지 여부를 나타냅니다. `false`일 경우 Observer가 작동하지 않습니다. * @param {boolean} props.calledOnce - 요소가 교차할 때 콜백을 `한 번`만 호출할지 여부를 나타냅니다. + * + * @returns {JSX.Element} + * + * @example + * ```tsx + * // 기본적으로 div로 감싸지며, 해당 div를 관찰 대상으로 설정합니다. + * // 해당 div가 viewport에 노출되거나 숨겨질 때 onIntersectStart/onIntersectEnd 콜백 함수를 호출합니다. + * + *
    Content1
    + *
    + * ``` + * + * @example + * ```tsx + * // as 속성을 통해 특정 요소로 렌더링할 수 있습니다. + * + *
  • List Item1
  • + *
  • List Item2
  • + *
    + * ``` */ -export const InView = ({ children, ...props }: InViewProps) => { - const { ref: intersectionObserverRef } = useIntersectionObserver(props); +export const InView = polymorphicForwardRef<'div', InViewProps>( + ({ children, as = 'div', ...props }, ref) => { + const Wrapper = as ?? 'div'; + const { ref: intersectionObserverRef } = useIntersectionObserver(props); - return {children}; -}; + return ( + + {children} + + ); + } +); diff --git a/packages/react/src/components/InfiniteScroll/InfinteScroll.spec.tsx b/packages/react/src/components/InfiniteScroll/InfinteScroll.spec.tsx index a0fe0965f..376fa7484 100644 --- a/packages/react/src/components/InfiniteScroll/InfinteScroll.spec.tsx +++ b/packages/react/src/components/InfiniteScroll/InfinteScroll.spec.tsx @@ -5,8 +5,8 @@ import { mockIntersecting, mockIntersectionObserverCleanup, mockIntersectionObserverSetup, -} from '../../utils/test/mockIntersectionObserver'; -import { renderSetup } from '../../utils/test/renderSetup'; +} from '../../_internal/test/mockIntersectionObserver'; +import { renderSetup } from '../../_internal/test/renderSetup'; beforeEach(() => { mockIntersectionObserverSetup(); diff --git a/packages/react/src/components/Iterator/Iterator.spec.tsx b/packages/react/src/components/Iterator/Iterator.spec.tsx index d70d7be84..afe034995 100644 --- a/packages/react/src/components/Iterator/Iterator.spec.tsx +++ b/packages/react/src/components/Iterator/Iterator.spec.tsx @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { renderSetup } from '../../utils/test/renderSetup'; +import { renderSetup } from '../../_internal/test/renderSetup'; import { screen } from '@testing-library/react'; import { Iterator } from '.'; diff --git a/packages/react/src/components/LazyImage/LazyImage.spec.tsx b/packages/react/src/components/LazyImage/LazyImage.spec.tsx index 568db5480..f77987690 100644 --- a/packages/react/src/components/LazyImage/LazyImage.spec.tsx +++ b/packages/react/src/components/LazyImage/LazyImage.spec.tsx @@ -5,8 +5,8 @@ import { mockIntersecting, mockIntersectionObserverCleanup, mockIntersectionObserverSetup, -} from '../../utils/test/mockIntersectionObserver'; -import { renderSetup } from '../../utils/test/renderSetup'; +} from '../../_internal/test/mockIntersectionObserver'; +import { renderSetup } from '../../_internal/test/renderSetup'; beforeEach(() => { mockIntersectionObserverSetup(); diff --git a/packages/react/src/components/Mounted/Mounted.spec.tsx b/packages/react/src/components/Mounted/Mounted.spec.tsx index d44831f94..13755ce4d 100644 --- a/packages/react/src/components/Mounted/Mounted.spec.tsx +++ b/packages/react/src/components/Mounted/Mounted.spec.tsx @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { screen } from '@testing-library/react'; -import { renderSetup } from '../../utils/test/renderSetup'; +import { renderSetup } from '../../_internal/test/renderSetup'; import { renderToString } from 'react-dom/server'; import { Mounted } from '.'; diff --git a/packages/react/src/components/OutsideClick/OutsideClick.spec.tsx b/packages/react/src/components/OutsideClick/OutsideClick.spec.tsx index 76d39eac0..ee3e3fa53 100644 --- a/packages/react/src/components/OutsideClick/OutsideClick.spec.tsx +++ b/packages/react/src/components/OutsideClick/OutsideClick.spec.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; import { waitFor, screen } from '@testing-library/react'; -import { renderSetup } from '../../utils/test/renderSetup'; +import { renderSetup } from '../../_internal/test/renderSetup'; import { OutsideClick } from './index'; describe('OutsideClick', () => { diff --git a/packages/react/src/components/Portal/Portal.spec.tsx b/packages/react/src/components/Portal/Portal.spec.tsx index 34f9a7dde..fb9c61ac8 100644 --- a/packages/react/src/components/Portal/Portal.spec.tsx +++ b/packages/react/src/components/Portal/Portal.spec.tsx @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import { screen, waitFor } from '@testing-library/react'; import { useRef, useState } from 'react'; import { Portal } from '.'; -import { renderSetup } from '../../utils/test/renderSetup'; +import { renderSetup } from '../../_internal/test/renderSetup'; import { Nullable } from '@modern-kit/types'; const DefaultTestComponent = () => { diff --git a/packages/react/src/components/SeparatedIterator/SeparatedIterator.spec.tsx b/packages/react/src/components/SeparatedIterator/SeparatedIterator.spec.tsx index fe63b665f..2674f5026 100644 --- a/packages/react/src/components/SeparatedIterator/SeparatedIterator.spec.tsx +++ b/packages/react/src/components/SeparatedIterator/SeparatedIterator.spec.tsx @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { renderSetup } from '../../utils/test/renderSetup'; +import { renderSetup } from '../../_internal/test/renderSetup'; import { screen } from '@testing-library/react'; import { SeparatedIterator } from '.'; diff --git a/packages/react/src/components/Slot/Slot.spec.tsx b/packages/react/src/components/Slot/Slot.spec.tsx index 35eb754aa..44be1ac13 100644 --- a/packages/react/src/components/Slot/Slot.spec.tsx +++ b/packages/react/src/components/Slot/Slot.spec.tsx @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { Slot, Slottable } from '.'; import { ComponentProps, PropsWithChildren, ReactElement } from 'react'; -import { renderSetup } from '../../utils/test/renderSetup'; +import { renderSetup } from '../../_internal/test/renderSetup'; import { screen } from '@testing-library/dom'; const TestButton = ({ diff --git a/packages/react/src/components/SwitchCase/SwitchCase.test.tsx b/packages/react/src/components/SwitchCase/SwitchCase.test.tsx index 23f91dcac..5cf5b8d98 100644 --- a/packages/react/src/components/SwitchCase/SwitchCase.test.tsx +++ b/packages/react/src/components/SwitchCase/SwitchCase.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { screen } from '@testing-library/react'; -import { renderSetup } from '../../utils/test/renderSetup'; +import { renderSetup } from '../../_internal/test/renderSetup'; import { SwitchCase } from '.'; describe('SwitchCase', () => { diff --git a/packages/react/src/hooks/useBlockPromiseMultipleClick/useBlockPromiseMultipleClick.spec.tsx b/packages/react/src/hooks/useBlockPromiseMultipleClick/useBlockPromiseMultipleClick.spec.tsx index 3251a7b8b..9c36835b8 100644 --- a/packages/react/src/hooks/useBlockPromiseMultipleClick/useBlockPromiseMultipleClick.spec.tsx +++ b/packages/react/src/hooks/useBlockPromiseMultipleClick/useBlockPromiseMultipleClick.spec.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { screen, renderHook, act } from '@testing-library/react'; -import { renderSetup } from '../../utils/test/renderSetup'; +import { renderSetup } from '../../_internal/test/renderSetup'; import { useBlockPromiseMultipleClick } from '.'; import { delay } from '@modern-kit/utils'; diff --git a/packages/react/src/hooks/useDebouncedInputValue/useDebouncedInputValue.spec.tsx b/packages/react/src/hooks/useDebouncedInputValue/useDebouncedInputValue.spec.tsx index ae2cd3afb..9aefc59b5 100644 --- a/packages/react/src/hooks/useDebouncedInputValue/useDebouncedInputValue.spec.tsx +++ b/packages/react/src/hooks/useDebouncedInputValue/useDebouncedInputValue.spec.tsx @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { screen } from '@testing-library/react'; import { useDebouncedInputValue } from '.'; -import { renderSetup } from '../../utils/test/renderSetup'; +import { renderSetup } from '../../_internal/test/renderSetup'; import { delay } from '@modern-kit/utils'; const DELAY = 200; diff --git a/packages/react/src/hooks/useDebouncedState/useDebouncedState.spec.tsx b/packages/react/src/hooks/useDebouncedState/useDebouncedState.spec.tsx index ecb0301da..01c13fb5d 100644 --- a/packages/react/src/hooks/useDebouncedState/useDebouncedState.spec.tsx +++ b/packages/react/src/hooks/useDebouncedState/useDebouncedState.spec.tsx @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { screen } from '@testing-library/react'; -import { renderSetup } from '../../utils/test/renderSetup'; +import { renderSetup } from '../../_internal/test/renderSetup'; import { useDebouncedState } from '.'; import { delay } from '@modern-kit/utils'; diff --git a/packages/react/src/hooks/useFileReader/useFileReader.spec.ts b/packages/react/src/hooks/useFileReader/useFileReader.spec.ts index 0e911067e..cacc02430 100644 --- a/packages/react/src/hooks/useFileReader/useFileReader.spec.ts +++ b/packages/react/src/hooks/useFileReader/useFileReader.spec.ts @@ -5,7 +5,7 @@ import { MockFileReaderForcedCallOnError, MockFileReaderThrowError, mockFileList, -} from '../../utils/test/mockFile'; +} from '../../_internal/test/mockFile'; const getSuccessFileContent = (file: File) => { return { diff --git a/packages/react/src/hooks/useFocus/useFocus.spec.tsx b/packages/react/src/hooks/useFocus/useFocus.spec.tsx index a051e4b7a..e1e1d3beb 100644 --- a/packages/react/src/hooks/useFocus/useFocus.spec.tsx +++ b/packages/react/src/hooks/useFocus/useFocus.spec.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, Mock, vi } from 'vitest'; import { useFocus } from '.'; -import { renderSetup } from '../../utils/test/renderSetup'; +import { renderSetup } from '../../_internal/test/renderSetup'; import { screen } from '@testing-library/react'; const TestComponent = ({ diff --git a/packages/react/src/hooks/useHover/useHover.spec.tsx b/packages/react/src/hooks/useHover/useHover.spec.tsx index 798fdd0ae..a220e6e46 100644 --- a/packages/react/src/hooks/useHover/useHover.spec.tsx +++ b/packages/react/src/hooks/useHover/useHover.spec.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, Mock, vi } from 'vitest'; import { useHover } from '.'; -import { renderSetup } from '../../utils/test/renderSetup'; +import { renderSetup } from '../../_internal/test/renderSetup'; import { screen, waitFor } from '@testing-library/react'; describe('useHover', () => { diff --git a/packages/react/src/hooks/useIntersectionObserver/useIntersectionObserver.spec.tsx b/packages/react/src/hooks/useIntersectionObserver/useIntersectionObserver.spec.tsx index d59c18f1a..767d36c3a 100644 --- a/packages/react/src/hooks/useIntersectionObserver/useIntersectionObserver.spec.tsx +++ b/packages/react/src/hooks/useIntersectionObserver/useIntersectionObserver.spec.tsx @@ -3,10 +3,10 @@ import { mockIntersecting, mockIntersectionObserverCleanup, mockIntersectionObserverSetup, -} from '../../utils/test/mockIntersectionObserver'; +} from '../../_internal/test/mockIntersectionObserver'; import { useIntersectionObserver } from '.'; import { waitFor, screen } from '@testing-library/react'; -import { renderSetup } from '../../utils/test/renderSetup'; +import { renderSetup } from '../../_internal/test/renderSetup'; beforeEach(() => { mockIntersectionObserverSetup(); diff --git a/packages/react/src/hooks/useKeyDown/useKeydown.spec.tsx b/packages/react/src/hooks/useKeyDown/useKeydown.spec.tsx index 0f1ee439a..a00e7239b 100644 --- a/packages/react/src/hooks/useKeyDown/useKeydown.spec.tsx +++ b/packages/react/src/hooks/useKeyDown/useKeydown.spec.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; -import { renderSetup } from '../../utils/test/renderSetup'; +import { renderSetup } from '../../_internal/test/renderSetup'; import { useKeyDown } from '.'; import { screen } from '@testing-library/react'; diff --git a/packages/react/src/hooks/useMergeRefs/useMergeRefs.spec.tsx b/packages/react/src/hooks/useMergeRefs/useMergeRefs.spec.tsx index af7a0fedd..23690a75c 100644 --- a/packages/react/src/hooks/useMergeRefs/useMergeRefs.spec.tsx +++ b/packages/react/src/hooks/useMergeRefs/useMergeRefs.spec.tsx @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import { useRef, useState } from 'react'; import { useMergeRefs } from '.'; import { screen, waitFor } from '@testing-library/react'; -import { renderSetup } from '../../utils/test/renderSetup'; +import { renderSetup } from '../../_internal/test/renderSetup'; import { Nullable } from '@modern-kit/types'; const TestComponent = () => { diff --git a/packages/react/src/hooks/useMouse/useMouse.spec.tsx b/packages/react/src/hooks/useMouse/useMouse.spec.tsx index 828ec4977..64631b325 100644 --- a/packages/react/src/hooks/useMouse/useMouse.spec.tsx +++ b/packages/react/src/hooks/useMouse/useMouse.spec.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; -import { renderSetup } from '../../utils/test/renderSetup'; +import { renderSetup } from '../../_internal/test/renderSetup'; import { fireEvent, screen } from '@testing-library/react'; import { useMouse } from '.'; diff --git a/packages/react/src/hooks/useOutsidePointerDown/useOutsidePointerDown.spec.tsx b/packages/react/src/hooks/useOutsidePointerDown/useOutsidePointerDown.spec.tsx index d9337dc59..1823cbfcd 100644 --- a/packages/react/src/hooks/useOutsidePointerDown/useOutsidePointerDown.spec.tsx +++ b/packages/react/src/hooks/useOutsidePointerDown/useOutsidePointerDown.spec.tsx @@ -1,7 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; import { screen } from '@testing-library/react'; import { useOutsidePointerDown } from '.'; -import { renderSetup } from '../../utils/test/renderSetup'; +import { renderSetup } from '../../_internal/test/renderSetup'; const TestComponent = ({ onAction }: { onAction: () => void }) => { const targetRef = useOutsidePointerDown(onAction); diff --git a/packages/react/src/hooks/useTimeout/useTimeout.spec.tsx b/packages/react/src/hooks/useTimeout/useTimeout.spec.tsx index 19219a564..0f06a9281 100644 --- a/packages/react/src/hooks/useTimeout/useTimeout.spec.tsx +++ b/packages/react/src/hooks/useTimeout/useTimeout.spec.tsx @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { act, renderHook, screen } from '@testing-library/react'; import { useTimeout } from '.'; import { useState } from 'react'; -import { renderSetup } from '../../utils/test/renderSetup'; +import { renderSetup } from '../../_internal/test/renderSetup'; const delayTime = 1000; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index f76fd6f16..c55977d1b 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,2 +1,3 @@ export * from './components'; export * from './hooks'; +export * from './utils'; diff --git a/packages/react/src/utils/index.ts b/packages/react/src/utils/index.ts new file mode 100644 index 000000000..7a6be9ad0 --- /dev/null +++ b/packages/react/src/utils/index.ts @@ -0,0 +1 @@ +export * from './polymorphicForwardRef'; diff --git a/packages/react/src/utils/polymorphicForwardRef.tsx b/packages/react/src/utils/polymorphicForwardRef.tsx new file mode 100644 index 000000000..22269bdb7 --- /dev/null +++ b/packages/react/src/utils/polymorphicForwardRef.tsx @@ -0,0 +1,177 @@ +import { + forwardRef, + Fragment, + type ComponentProps, + type ComponentPropsWithRef, + type ElementType, + type ForwardRefExoticComponent, + type ForwardRefRenderFunction, + type ReactElement, +} from 'react'; + +/** + * @description 유니온 타입에서 각각의 타입에 대해 Omit을 적용하는 타입입니다. + * `조건부 타입`을 사용하여 분배법칙처럼 동작합니다. + * + * @template T - 분배 대상이 되는 유니온 타입 + * @template K - 제거할 프로퍼티 키 + * + * @example + * type Union = { a: string } | { b: number } + * type Result = DistributiveOmit + * + * // 동작 원리와 순서 + * // 1. Result = DistributiveOmit + * // 2. Result = Omit<{ a: string }, 'a'> | Omit<{ b: number }, 'a'> + * // 3. Result = {} | { b: number } + */ +type DistributiveOmit = T extends any + ? Omit + : never; + +/** + * @description 두 타입을 병합하는 타입입니다. + * + * @template A - 첫 번째 타입 + * @template B - 두 번째 타입 + * + * @example + * type A = { a: string, b: number } + * type B = { b: string, c: boolean } + * type Result = Merge + * + * // 동작 원리와 순서 + * // 1. Result = Merge + * // 2. Result = Omit & B + * // 3. Result = { a: string } & B + * // 4. Result = { a: string, b: string, c: boolean } + */ +type Merge = Omit & B; + +/** + * @description 유니온 타입의 각 구성 요소에 대해 B 타입과의 병합을 수행하는 타입입니다. + * 각 유니온 멤버에서 B의 키들을 제거한 후, B 타입과 병합합니다. + * + * @template A - 병합의 대상이 되는 유니온 타입 + * @template B - 각 유니온 멤버와 병합될 타입 + * + * @example + * type A = { a: string, c: boolean } | { b: number } + * type B = { c: boolean } + * type Result = DistributiveMerge + * + * // 동작 원리와 순서 + * // 1. Result = (Omit<{ a: string, c: boolean }, "c"> | Omit<{ b: number }, 'c'>) & B; + * // 2. Result = { a: string } & B | { b: number } & B; + * // 3. Result = { a: string, c: boolean } | { b: number, c: boolean } + */ +type DistributiveMerge = DistributiveOmit & B; + +/** + * @description 다형성 컴포넌트의 props 타입을 정의하는 타입입니다. 이때, `as` 프로퍼티를 포함합니다. + * + * @template Component - 렌더링할 요소의 타입을 지정합니다. 예를 들어, 'button', 'div' 등 HTML 요소가 될 수 있습니다. + * @template PermanentProps - 컴포넌트의 고정 props 타입입니다. + * @template ComponentProps - 지정된 요소 타입에 기본적으로 제공하는 props 타입입니다. 예를 들어 button 요소의 경우 type, disabled 등이 있습니다. + * + * @example + * interface ButtonProps { + * variant: 'primary' | 'secondary'; + * size: 'sm' | 'md' | 'lg'; + * } + * + * // button 요소로 렌더링될 때의 타입 + * type HtmlButtonProps = AsProps<'button', ButtonProps, ComponentProps<'button'>> + * // HtmlButtonProps = { + * // variant: 'primary' | 'secondary'; + * // size: 'sm' | 'md' | 'lg'; + * // as?: 'button'; + * // ... 기타 button의 HTML 속성들 + * // } + */ +type AsProps< + Component extends ElementType, + PermanentProps extends Record, + ComponentProps extends Record +> = DistributiveMerge; + +/** + * @description ref를 포함한 다형성 컴포넌트의 함수 시그니처를 정의하는 타입입니다. + * 하나의 컴포넌트가 여러 HTML 요소로 렌더링될 수 있도록 하며, 각 요소에 맞는 props와 ref를 자동으로 처리합니다. + * + * @template Default - 렌더링할 요소의 타입을 지정합니다. 예를 들어, 'button', 'div' 등 HTML 요소가 될 수 있습니다. + * @template Props - 컴포넌트의 커스텀 props 타입입니다. + * @template OnlyAs - 컴포넌트가 렌더링될 수 있는 요소 타입을 제한합니다. (선택적, 기본값: ElementType) + */ +type PolymorphicWithRef< + Default extends OnlyAs, + Props extends Record, + OnlyAs extends ElementType = ElementType +> = ( + props: AsProps< + T, + Props, + T extends ElementType + ? ComponentPropsWithRef + : ComponentProps + > +) => ReactElement | null; + +/** + * @description React.forwardRef를 사용한 다형성 컴포넌트의 전체 타입을 정의합니다. + * ForwardRefExoticComponent와 PolymorphicWithRef를 결합합니다. + * + * @template Default - 렌더링할 요소의 타입을 지정합니다. 예를 들어, 'button', 'div' 등 HTML 요소가 될 수 있습니다. + * @template Props - 컴포넌트의 커스텀 props 타입입니다. + * @template OnlyAs - 컴포넌트가 렌더링될 수 있는 요소 타입을 제한합니다. (선택적, 기본값: ElementType) + */ +type PolyForwardComponent< + Default extends OnlyAs, + Props extends Record, + OnlyAs extends ElementType = ElementType +> = Merge< + ForwardRefExoticComponent< + Merge< + Default extends ElementType + ? ComponentPropsWithRef + : ComponentProps, + Props & { as?: Default } + > + >, + PolymorphicWithRef +>; + +/** + * @description React.forwardRef를 위한 다형성 타입 래퍼입니다. + * 컴포넌트에 다형성과 ref 전달 기능을 모두 부여합니다. + * + * @template Default - 렌더링할 요소의 타입을 지정합니다. 예를 들어, 'button', 'div' 등 HTML 요소가 될 수 있습니다. + * @template Props - 컴포넌트의 커스텀 props 타입입니다. + * @template OnlyAs - 컴포넌트가 렌더링될 수 있는 요소 타입을 제한합니다. (선택적, 기본값: ElementType) + * + * @returns 다형성과 ref 전달이 가능한 새로운 컴포넌트 타입을 반환합니다. + */ +type PolyRefFunction = < + Default extends OnlyAs, + Props extends Record, + OnlyAs extends ElementType = ElementType +>( + Component: ForwardRefRenderFunction +) => PolyForwardComponent; + +/** + * @description React.forwardRef를 다형성 컴포넌트를 위한 타입으로 캐스팅하는 유틸리티입니다. + * 기존의 forwardRef를 PolyRefFunction 타입으로 변환하여 다형성과 ref 전달을 모두 지원하는 컴포넌트를 생성할 수 있게 합니다. + * + * @example + * interface ButtonProps { + * variant: 'primary' | 'secondary'; + * size: 'sm' | 'md' | 'lg'; + * } + * + * const Button = polymorphicForwardRef<'button', ButtonProps>((props, ref) => { + * const Component = props.as ?? 'button'; + * return ; + * }); + */ +export const polymorphicForwardRef = forwardRef as PolyRefFunction;