From da6afc5db9185c5a0bb060979af4aff3ecaad580 Mon Sep 17 00:00:00 2001 From: ssi02014 Date: Tue, 26 Nov 2024 14:54:41 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20InView=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/docs/react/components/InView.mdx | 60 +++++++++++++++---- .../src/components/InView/InView.spec.tsx | 51 +++++++++++++++- .../react/src/components/InView/index.tsx | 53 ++++++++++++++-- 3 files changed, 148 insertions(+), 16 deletions(-) diff --git a/docs/docs/react/components/InView.mdx b/docs/docs/react/components/InView.mdx index 801405f31..a430c7a24 100644 --- a/docs/docs/react/components/InView.mdx +++ b/docs/docs/react/components/InView.mdx @@ -4,13 +4,13 @@ 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`를 각각 한번씩 호출 할 수 있습니다. +`asChild`를 활용하면 자식 요소를 그대로 렌더링하고, 자식 요소를 관찰 대상으로 설정할 수 있습니다. 이때 자식 요소는 단일 요소만 허용됩니다. +기본 값은 `false`이며, `false`일 경우 `div`로 감싸지며, 해당 `div`를 관찰 대상으로 설정합니다. +- `ref` 활용 및 div가 아닌 특정 요소를 직접 관찰 대상으로 설정할 때 유용합니다. -Intersection Observer Option을 설정할 수 있습니다.(하단 `Note` 참고) +`@modern-kit/react`의 **[useIntersectionObserver](https://modern-agile-team.github.io/modern-kit/docs/react/hooks/useIntersectionObserver)** 훅을 사용하여 구현되었습니다.
@@ -20,14 +20,20 @@ Intersection Observer Option을 설정할 수 있습니다.(하단 `Note` 참고 ## Interface ```ts title="typescript" interface InViewProps extends UseIntersectionObserverProps { - children: JSX.Element; + children: React.ReactNode; + asChild?: boolean; } ``` ```ts title="typescript" -const InView: ({ children, ...props }: InViewProps) => JSX.Element +const InView: React.ForwardRefExoticComponent< + InViewProps & React.RefAttributes +> ``` ## Usage +### Default +- 기본적으로 `div`로 감싸지며, 해당 `div`를 관찰 대상으로 설정합니다. +- 해당 `div`가 viewport에 노출되거나 숨겨질 때 `onIntersectStart/onIntersectEnd` 콜백 함수를 호출합니다. ```tsx title="typescript" import { InView } from '@modern-kit/react'; @@ -42,12 +48,44 @@ const Example = () => { return (
- {/* ... */} + +
Box1
+
Box2
+
+
; + ); +}; +``` + +### 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
  • +
; ); diff --git a/packages/react/src/components/InView/InView.spec.tsx b/packages/react/src/components/InView/InView.spec.tsx index 0a9c4d704..5de0cb903 100644 --- a/packages/react/src/components/InView/InView.spec.tsx +++ b/packages/react/src/components/InView/InView.spec.tsx @@ -20,15 +20,18 @@ interface TestComponentProps { onIntersectStart: () => void; onIntersectEnd: () => void; calledOnce?: boolean; + asChild?: boolean; } const TestComponent = ({ onIntersectStart, onIntersectEnd, calledOnce, + asChild, }: TestComponentProps) => { return ( @@ -49,14 +52,47 @@ describe('InView', () => { /> ); - const box = screen.getByText('box'); + const boxWrapper = screen.getByText('box').parentElement as HTMLElement; expect(intersectStartMock).toBeCalledTimes(0); expect(intersectEndMock).toBeCalledTimes(0); + await waitFor(() => + mockIntersecting({ type: 'view', element: boxWrapper }) + ); + expect(intersectStartMock).toBeCalledTimes(1); + + await waitFor(() => + mockIntersecting({ type: 'hide', element: boxWrapper }) + ); + expect(intersectEndMock).toBeCalledTimes(1); + }); + + it('asChild 프로퍼티가 true이면 자식 요소가 그대로 렌더링되야 하며, 자식 요소를 관찰 대상으로 설정해야 합니다.', async () => { + renderSetup( + + ); + + const boxWrapper = screen.getByText('box').parentElement as HTMLElement; + const box = screen.getByText('box'); + + await waitFor(() => + mockIntersecting({ type: 'view', element: boxWrapper }) + ); + expect(intersectStartMock).toBeCalledTimes(0); + await waitFor(() => mockIntersecting({ type: 'view', element: box })); expect(intersectStartMock).toBeCalledTimes(1); + await waitFor(() => + mockIntersecting({ type: 'hide', element: boxWrapper }) + ); + expect(intersectEndMock).toBeCalledTimes(0); + await waitFor(() => mockIntersecting({ type: 'hide', element: box })); expect(intersectEndMock).toBeCalledTimes(1); }); @@ -85,4 +121,17 @@ describe('InView', () => { expect(intersectStartMock).toBeCalledTimes(1); expect(intersectEndMock).toBeCalledTimes(1); }); + + it('asChild 프로퍼티가 true일 경우 자식 요소로 단일 요소가 아닐 경우 에러가 발생합니다.', () => { + expect(() => + renderSetup( + +
box1
+
box2
+
+ ) + ).toThrow( + 'InView 컴포넌트는 asChild 프로퍼티가 true일 경우 자식으로 단일 요소만 허용합니다.' + ); + }); }); diff --git a/packages/react/src/components/InView/index.tsx b/packages/react/src/components/InView/index.tsx index 929458620..6a84bc855 100644 --- a/packages/react/src/components/InView/index.tsx +++ b/packages/react/src/components/InView/index.tsx @@ -1,3 +1,4 @@ +import React, { Children } from 'react'; import { Slot } from '../Slot'; import { useIntersectionObserver, @@ -5,18 +6,22 @@ import { } from '../../hooks/useIntersectionObserver'; interface InViewProps extends UseIntersectionObserverProps { - children: JSX.Element; + children: React.ReactNode; + asChild?: boolean; } /** * @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,49 @@ 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
+ *
Content2
+ *
+ * ``` + * + * @example + * ```tsx + * // asChild 프로퍼티를 사용하면 자식 요소를 그대로 렌더링하고, 자식 요소를 관찰 대상으로 설정합니다. + * // 자식 요소가 viewport에 노출되거나 숨겨질 때 onIntersectStart/onIntersectEnd 콜백이 호출됩니다. + * // 이때 자식 요소는 단일 요소만 허용됩니다. + * const ref = useRef(null); + * + * + *
    + *
  • List Item1
  • + *
  • List Item2
  • + *
+ *
+ * ``` */ -export const InView = ({ children, ...props }: InViewProps) => { +export const InView = ({ + children, + asChild = false, + ...props +}: InViewProps): JSX.Element => { + const InViewWrapper = asChild ? Slot : 'div'; const { ref: intersectionObserverRef } = useIntersectionObserver(props); + const childrenCount = Children.count(children); - return {children}; + if (asChild && childrenCount > 1) { + throw new Error( + 'InView 컴포넌트는 asChild 프로퍼티가 true일 경우 자식으로 단일 요소만 허용합니다.' + ); + } + return ( + {children} + ); }; From 353b0ec16073d103a1919601f77b493a483bdd2d Mon Sep 17 00:00:00 2001 From: ssi02014 Date: Tue, 26 Nov 2024 19:02:44 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20InView=20=EB=8B=A4=ED=98=95=EC=84=B1?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/docs/react/components/InView.mdx | 50 +++--- .../src/components/InView/InView.spec.tsx | 67 +++---- .../react/src/components/InView/index.tsx | 45 ++--- packages/react/src/index.ts | 1 + packages/react/src/types/index.ts | 1 + .../react/src/types/polymorphicForwardRef.tsx | 168 ++++++++++++++++++ 6 files changed, 234 insertions(+), 98 deletions(-) create mode 100644 packages/react/src/types/index.ts create mode 100644 packages/react/src/types/polymorphicForwardRef.tsx diff --git a/docs/docs/react/components/InView.mdx b/docs/docs/react/components/InView.mdx index a430c7a24..55f89f9ee 100644 --- a/docs/docs/react/components/InView.mdx +++ b/docs/docs/react/components/InView.mdx @@ -6,9 +6,7 @@ import { InView } from '@modern-kit/react'; 관찰 대상이 `Viewport`에 노출될 때(`onIntersectStart`) 혹은 나갈 때(`onIntersectEnd`) 특정 action 함수를 호출 할 수 있는 컴포넌트입니다. -`asChild`를 활용하면 자식 요소를 그대로 렌더링하고, 자식 요소를 관찰 대상으로 설정할 수 있습니다. 이때 자식 요소는 단일 요소만 허용됩니다. -기본 값은 `false`이며, `false`일 경우 `div`로 감싸지며, 해당 `div`를 관찰 대상으로 설정합니다. -- `ref` 활용 및 div가 아닌 특정 요소를 직접 관찰 대상으로 설정할 때 유용합니다. +다형성을 지원하기 때문에 `as` 속성을 통해 특정 요소로 렌더링할 수 있습니다. `@modern-kit/react`의 **[useIntersectionObserver](https://modern-agile-team.github.io/modern-kit/docs/react/hooks/useIntersectionObserver)** 훅을 사용하여 구현되었습니다. @@ -21,13 +19,10 @@ import { InView } from '@modern-kit/react'; ```ts title="typescript" interface InViewProps extends UseIntersectionObserverProps { children: React.ReactNode; - asChild?: boolean; } ``` ```ts title="typescript" -const InView: React.ForwardRefExoticComponent< - InViewProps & React.RefAttributes -> +const InView: PolyForwardComponent<"div", InViewProps, React.ElementType> ``` ## Usage @@ -50,7 +45,6 @@ const Example = () => {
Box1
-
Box2
; ); @@ -78,14 +72,12 @@ const Example = () => { return (
-
    -
  • List Item1
  • -
  • List Item2
  • -
+
  • List Item1
  • +
  • List Item2
  • ; ); @@ -115,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/components/InView/InView.spec.tsx b/packages/react/src/components/InView/InView.spec.tsx index 5de0cb903..bb082d819 100644 --- a/packages/react/src/components/InView/InView.spec.tsx +++ b/packages/react/src/components/InView/InView.spec.tsx @@ -7,6 +7,7 @@ import { mockIntersectionObserverSetup, } from '../../utils/test/mockIntersectionObserver'; import { renderSetup } from '../../utils/test/renderSetup'; +import { ElementType } from 'react'; beforeEach(() => { mockIntersectionObserverSetup(); @@ -19,19 +20,19 @@ afterEach(() => { interface TestComponentProps { onIntersectStart: () => void; onIntersectEnd: () => void; + as?: ElementType; calledOnce?: boolean; - asChild?: boolean; } const TestComponent = ({ onIntersectStart, onIntersectEnd, + as, calledOnce, - asChild, }: TestComponentProps) => { return ( @@ -44,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 boxWrapper = screen.getByText('box').parentElement as HTMLElement; + expect(boxWrapper.tagName).toBe('DIV'); expect(intersectStartMock).toBeCalledTimes(0); expect(intersectEndMock).toBeCalledTimes(0); @@ -68,32 +70,22 @@ describe('InView', () => { expect(intersectEndMock).toBeCalledTimes(1); }); - it('asChild 프로퍼티가 true이면 자식 요소가 그대로 렌더링되야 하며, 자식 요소를 관찰 대상으로 설정해야 합니다.', async () => { + it('as props를 통해 특정 요소로 렌더링할 수 있습니다.', async () => { renderSetup( ); - const boxWrapper = screen.getByText('box').parentElement as HTMLElement; - const box = screen.getByText('box'); - - await waitFor(() => - mockIntersecting({ type: 'view', element: boxWrapper }) - ); - expect(intersectStartMock).toBeCalledTimes(0); + const ulWrapper = screen.getByText('box').parentElement as HTMLElement; + expect(ulWrapper.tagName).toBe('UL'); - await waitFor(() => mockIntersecting({ type: 'view', element: box })); + await waitFor(() => mockIntersecting({ type: 'view', element: ulWrapper })); expect(intersectStartMock).toBeCalledTimes(1); - await waitFor(() => - mockIntersecting({ type: 'hide', element: boxWrapper }) - ); - expect(intersectEndMock).toBeCalledTimes(0); - - await waitFor(() => mockIntersecting({ type: 'hide', element: box })); + await waitFor(() => mockIntersecting({ type: 'hide', element: ulWrapper })); expect(intersectEndMock).toBeCalledTimes(1); }); @@ -106,32 +98,29 @@ 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); }); - - it('asChild 프로퍼티가 true일 경우 자식 요소로 단일 요소가 아닐 경우 에러가 발생합니다.', () => { - expect(() => - renderSetup( - -
    box1
    -
    box2
    -
    - ) - ).toThrow( - 'InView 컴포넌트는 asChild 프로퍼티가 true일 경우 자식으로 단일 요소만 허용합니다.' - ); - }); }); diff --git a/packages/react/src/components/InView/index.tsx b/packages/react/src/components/InView/index.tsx index 6a84bc855..c7dea8c61 100644 --- a/packages/react/src/components/InView/index.tsx +++ b/packages/react/src/components/InView/index.tsx @@ -1,13 +1,13 @@ -import React, { Children } from 'react'; -import { Slot } from '../Slot'; +import React from 'react'; import { useIntersectionObserver, UseIntersectionObserverProps, } from '../../hooks/useIntersectionObserver'; +import { polymorphicForwardRef } from '../../types/polymorphicForwardRef'; +import { useMergeRefs } from '../../hooks/useMergeRefs'; interface InViewProps extends UseIntersectionObserverProps { children: React.ReactNode; - asChild?: boolean; } /** @@ -38,40 +38,27 @@ interface InViewProps extends UseIntersectionObserverProps { * // 해당 div가 viewport에 노출되거나 숨겨질 때 onIntersectStart/onIntersectEnd 콜백 함수를 호출합니다. * *
    Content1
    - *
    Content2
    *
    * ``` * * @example * ```tsx - * // asChild 프로퍼티를 사용하면 자식 요소를 그대로 렌더링하고, 자식 요소를 관찰 대상으로 설정합니다. - * // 자식 요소가 viewport에 노출되거나 숨겨질 때 onIntersectStart/onIntersectEnd 콜백이 호출됩니다. - * // 이때 자식 요소는 단일 요소만 허용됩니다. - * const ref = useRef(null); - * - * - *
      - *
    • List Item1
    • - *
    • List Item2
    • - *
    + * // as 속성을 통해 특정 요소로 렌더링할 수 있습니다. + * + *
  • List Item1
  • + *
  • List Item2
  • *
    * ``` */ -export const InView = ({ - children, - asChild = false, - ...props -}: InViewProps): JSX.Element => { - const InViewWrapper = asChild ? Slot : 'div'; - const { ref: intersectionObserverRef } = useIntersectionObserver(props); - const childrenCount = Children.count(children); +export const InView = polymorphicForwardRef<'div', InViewProps>( + ({ children, as = 'div', ...props }, ref) => { + const Wrapper = as ?? 'div'; + const { ref: intersectionObserverRef } = useIntersectionObserver(props); - if (asChild && childrenCount > 1) { - throw new Error( - 'InView 컴포넌트는 asChild 프로퍼티가 true일 경우 자식으로 단일 요소만 허용합니다.' + return ( + + {children} + ); } - return ( - {children} - ); -}; +); diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index f76fd6f16..2103701b1 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 './types'; diff --git a/packages/react/src/types/index.ts b/packages/react/src/types/index.ts new file mode 100644 index 000000000..7a6be9ad0 --- /dev/null +++ b/packages/react/src/types/index.ts @@ -0,0 +1 @@ +export * from './polymorphicForwardRef'; diff --git a/packages/react/src/types/polymorphicForwardRef.tsx b/packages/react/src/types/polymorphicForwardRef.tsx new file mode 100644 index 000000000..3e7e2740b --- /dev/null +++ b/packages/react/src/types/polymorphicForwardRef.tsx @@ -0,0 +1,168 @@ +import { + forwardRef, + Fragment, + type ComponentProps, + type ComponentPropsWithRef, + type ElementType, + type ForwardRefExoticComponent, + type ForwardRefRenderFunction, + type ReactElement, +} from 'react'; + +/** + * @description 유니온 타입에서 각각의 타입에 대해 Omit을 적용하는 타입입니다. + * 조건부 타입을 사용하여 분배법칙처럼 동작합니다. + * + * @example + * type Union = { a: string } | { b: number } + * type Result = DistributiveOmit + * // Result = Omit<{a: string}, 'a'> | Omit<{b: number}, 'a'> + * // Result = {} | {b: number} + */ +type DistributiveOmit = T extends any + ? Omit + : never; + +/** + * @description 두 타입을 병합하는 타입입니다. + * B 타입의 속성이 A 타입의 속성을 덮어씁니다. + * + * @example + * type A = { a: string, b: number } + * type B = { b: string, c: boolean } + * type Result = Merge + * // Result = { a: string, b: string, c: boolean } + */ +type Merge = Omit & B; + +/** + * @description DistributiveMerge 타입은 유니온 타입의 각 구성 요소에 대해 B제네릭 key를 Omit한 후, B 타입과 병합합니다. + * + * @example + * type A = { a: string, c: boolean } | { b: number } + * type B = { c: boolean } + * type Result = DistributiveMerge + * // Result = (Omit<{ a: string, c: boolean }, "c"> | Omit<{ b: number }, 'c'>) & B; + * // Result = { a: string } & B | { b: number } & B; + * // Result = { a: string, c: boolean } | { b: number, c: boolean } + */ +type DistributiveMerge = DistributiveOmit & B; + +/** + * @description 다형성 컴포넌트의 props 타입을 정의하는 타입입니다. as 프로퍼티를 포함합니다. + * + * - Component: 렌더링할 요소의 타입을 지정합니다. 예를 들어, 'button', 'div' 등 HTML 요소가 될 수 있습니다. + * - PermanentProps: 항상 존재해야 하는 props를 정의합니다. + * - ComponentProps: 지정된 요소 타입에 기본적으로 제공하는 props를 정의합니다. 예를 들어 button 요소의 경우 type, disabled 등이 있습니다. + * + * @example + * interface ButtonProps { + * variant: 'primary' | 'secondary'; + * size: 'sm' | 'md' | 'lg'; + * } + * + * // button 요소로 렌더링될 때의 타입 + * type HtmlButtonProps = AsProps<'button', ButtonProps, ComponentProps<'button'>> + * // Result: { + * // variant: 'primary' | 'secondary'; + * // size: 'sm' | 'md' | 'lg'; + * // as?: 'button'; + * // type?: 'button' | 'submit' | 'reset'; + * // disabled?: boolean; + * // ... 기타 button의 HTML 속성들 + * // } + */ +type AsProps< + Component extends ElementType, + PermanentProps extends object, + ComponentProps extends object +> = DistributiveMerge; + +/** + * @description ref를 포함한 다형성 컴포넌트의 함수 시그니처를 정의하는 타입입니다. + * 이 타입은 다양한 요소 타입으로 렌더링될 수 있는 컴포넌트를 정의할 때 사용됩니다. + * + * - Default: 기본 요소 타입을 지정합니다. + * - Props: 컴포넌트에 추가할 사용자 정의 props를 정의합니다. + * - OnlyAs: 컴포넌트가 렌더링될 수 있는 요소 타입을 제한합니다. 기본적으로 모든 React 요소 타입이 허용됩니다. + * + * @example + * // Button 컴포넌트의 사용자 정의 props를 정의합니다. + * interface ButtonProps { + * variant: 'primary' | 'secondary'; + * size: 'sm' | 'md' | 'lg'; + * } + * + * type ButtonComponent = PolymorphicWithRef<'button', ButtonProps>; + */ +type PolymorphicWithRef< + Default extends OnlyAs, + Props extends object = Record, + OnlyAs extends ElementType = ElementType +> = ( + props: AsProps< + T, + Props, + T extends ElementType + ? ComponentPropsWithRef + : ComponentProps + > +) => ReactElement | null; + +/** + * @description forwardRef를 사용한 다형성 컴포넌트의 전체 타입을 정의합니다. + * ForwardRefExoticComponent와 PolymorphicWithRef를 결합합니다. + * ref 전달과 다형성을 모두 지원하는 컴포넌트를 생성합니다. + * + * - Default: 기본 요소 타입을 지정합니다. + * - Props: 컴포넌트에 추가할 사용자 정의 props를 정의합니다. + * - OnlyAs: 컴포넌트가 렌더링될 수 있는 요소 타입을 제한합니다. 기본적으로 모든 React 요소 타입이 허용됩니다. + */ +type PolyForwardComponent< + Default extends OnlyAs, + Props extends object = Record, + OnlyAs extends ElementType = ElementType +> = Merge< + ForwardRefExoticComponent< + Merge< + Default extends ElementType + ? ComponentPropsWithRef + : ComponentProps, + Props & { as?: Default } + > + >, + PolymorphicWithRef +>; + +/** + * @description React.forwardRef를 위한 다형성 타입 래퍼입니다. + * 컴포넌트에 다형성과 ref 전달 기능을 모두 부여합니다. 다양한 요소 타입으로 렌더링할 수 있는 컴포넌트를 생성합니다. + * + * - Default: 기본 요소 타입을 지정합니다. + * - Props: 컴포넌트에 추가할 사용자 정의 props를 정의합니다. + * - OnlyAs: 컴포넌트가 렌더링될 수 있는 요소 타입을 제한합니다. 기본적으로 모든 React 요소 타입이 허용됩니다. + */ +type PolyRefFunction = < + Default extends OnlyAs, + Props extends object = 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; From bc49a198e9b31936b2f37aa4d2e8d7b4377b7542 Mon Sep 17 00:00:00 2001 From: ssi02014 Date: Wed, 27 Nov 2024 01:31:59 +0900 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20polymorphicForwardRef=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/{utils => _internal}/test/mockFile.ts | 0 .../test/mockIntersectionObserver.ts | 0 .../{utils => _internal}/test/renderSetup.ts | 0 .../components/ClientGate/ClientGate.spec.tsx | 2 +- .../DebounceWrapper/DebounceWrapper.spec.tsx | 2 +- .../FallbackLazyImage.spec.tsx | 4 +- .../src/components/InView/InView.spec.tsx | 4 +- .../react/src/components/InView/index.tsx | 2 +- .../InfiniteScroll/InfinteScroll.spec.tsx | 4 +- .../src/components/Iterator/Iterator.spec.tsx | 2 +- .../components/LazyImage/LazyImage.spec.tsx | 4 +- .../src/components/Mounted/Mounted.spec.tsx | 2 +- .../OutsideClick/OutsideClick.spec.tsx | 2 +- .../src/components/Portal/Portal.spec.tsx | 2 +- .../SeparatedIterator.spec.tsx | 2 +- .../react/src/components/Slot/Slot.spec.tsx | 2 +- .../components/SwitchCase/SwitchCase.test.tsx | 2 +- .../useBlockPromiseMultipleClick.spec.tsx | 2 +- .../useDebouncedInputValue.spec.tsx | 2 +- .../useDebouncedState.spec.tsx | 2 +- .../hooks/useFileReader/useFileReader.spec.ts | 2 +- .../src/hooks/useFocus/useFocus.spec.tsx | 2 +- .../src/hooks/useHover/useHover.spec.tsx | 2 +- .../useIntersectionObserver.spec.tsx | 4 +- .../src/hooks/useKeyDown/useKeydown.spec.tsx | 2 +- .../hooks/useMergeRefs/useMergeRefs.spec.tsx | 2 +- .../src/hooks/useMouse/useMouse.spec.tsx | 2 +- .../useOutsidePointerDown.spec.tsx | 2 +- .../src/hooks/useTimeout/useTimeout.spec.tsx | 2 +- packages/react/src/index.ts | 2 +- packages/react/src/{types => utils}/index.ts | 0 .../polymorphicForwardRef.tsx | 95 ++++++++++--------- 32 files changed, 84 insertions(+), 75 deletions(-) rename packages/react/src/{utils => _internal}/test/mockFile.ts (100%) rename packages/react/src/{utils => _internal}/test/mockIntersectionObserver.ts (100%) rename packages/react/src/{utils => _internal}/test/renderSetup.ts (100%) rename packages/react/src/{types => utils}/index.ts (100%) rename packages/react/src/{types => utils}/polymorphicForwardRef.tsx (51%) 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 bb082d819..d4dd664fb 100644 --- a/packages/react/src/components/InView/InView.spec.tsx +++ b/packages/react/src/components/InView/InView.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 { ElementType } from 'react'; beforeEach(() => { diff --git a/packages/react/src/components/InView/index.tsx b/packages/react/src/components/InView/index.tsx index c7dea8c61..300c023d3 100644 --- a/packages/react/src/components/InView/index.tsx +++ b/packages/react/src/components/InView/index.tsx @@ -3,7 +3,7 @@ import { useIntersectionObserver, UseIntersectionObserverProps, } from '../../hooks/useIntersectionObserver'; -import { polymorphicForwardRef } from '../../types/polymorphicForwardRef'; +import { polymorphicForwardRef } from '../../utils/polymorphicForwardRef'; import { useMergeRefs } from '../../hooks/useMergeRefs'; interface InViewProps extends UseIntersectionObserverProps { 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 2103701b1..c55977d1b 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,3 +1,3 @@ export * from './components'; export * from './hooks'; -export * from './types'; +export * from './utils'; diff --git a/packages/react/src/types/index.ts b/packages/react/src/utils/index.ts similarity index 100% rename from packages/react/src/types/index.ts rename to packages/react/src/utils/index.ts diff --git a/packages/react/src/types/polymorphicForwardRef.tsx b/packages/react/src/utils/polymorphicForwardRef.tsx similarity index 51% rename from packages/react/src/types/polymorphicForwardRef.tsx rename to packages/react/src/utils/polymorphicForwardRef.tsx index 3e7e2740b..22269bdb7 100644 --- a/packages/react/src/types/polymorphicForwardRef.tsx +++ b/packages/react/src/utils/polymorphicForwardRef.tsx @@ -11,13 +11,19 @@ import { /** * @description 유니온 타입에서 각각의 타입에 대해 Omit을 적용하는 타입입니다. - * 조건부 타입을 사용하여 분배법칙처럼 동작합니다. + * `조건부 타입`을 사용하여 분배법칙처럼 동작합니다. + * + * @template T - 분배 대상이 되는 유니온 타입 + * @template K - 제거할 프로퍼티 키 * * @example * type Union = { a: string } | { b: number } * type Result = DistributiveOmit - * // Result = Omit<{a: string}, 'a'> | Omit<{b: number}, 'a'> - * // Result = {} | {b: number} + * + * // 동작 원리와 순서 + * // 1. Result = DistributiveOmit + * // 2. Result = Omit<{ a: string }, 'a'> | Omit<{ b: number }, 'a'> + * // 3. Result = {} | { b: number } */ type DistributiveOmit = T extends any ? Omit @@ -25,35 +31,48 @@ type DistributiveOmit = T extends any /** * @description 두 타입을 병합하는 타입입니다. - * B 타입의 속성이 A 타입의 속성을 덮어씁니다. + * + * @template A - 첫 번째 타입 + * @template B - 두 번째 타입 * * @example * type A = { a: string, b: number } * type B = { b: string, c: boolean } * type Result = Merge - * // Result = { a: string, b: string, c: boolean } + * + * // 동작 원리와 순서 + * // 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 DistributiveMerge 타입은 유니온 타입의 각 구성 요소에 대해 B제네릭 key를 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 - * // Result = (Omit<{ a: string, c: boolean }, "c"> | Omit<{ b: number }, 'c'>) & B; - * // Result = { a: string } & B | { b: number } & B; - * // Result = { a: string, c: boolean } | { b: number, c: boolean } + * + * // 동작 원리와 순서 + * // 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 프로퍼티를 포함합니다. + * @description 다형성 컴포넌트의 props 타입을 정의하는 타입입니다. 이때, `as` 프로퍼티를 포함합니다. * - * - Component: 렌더링할 요소의 타입을 지정합니다. 예를 들어, 'button', 'div' 등 HTML 요소가 될 수 있습니다. - * - PermanentProps: 항상 존재해야 하는 props를 정의합니다. - * - ComponentProps: 지정된 요소 타입에 기본적으로 제공하는 props를 정의합니다. 예를 들어 button 요소의 경우 type, disabled 등이 있습니다. + * @template Component - 렌더링할 요소의 타입을 지정합니다. 예를 들어, 'button', 'div' 등 HTML 요소가 될 수 있습니다. + * @template PermanentProps - 컴포넌트의 고정 props 타입입니다. + * @template ComponentProps - 지정된 요소 타입에 기본적으로 제공하는 props 타입입니다. 예를 들어 button 요소의 경우 type, disabled 등이 있습니다. * * @example * interface ButtonProps { @@ -63,41 +82,30 @@ type DistributiveMerge = DistributiveOmit & B; * * // button 요소로 렌더링될 때의 타입 * type HtmlButtonProps = AsProps<'button', ButtonProps, ComponentProps<'button'>> - * // Result: { + * // HtmlButtonProps = { * // variant: 'primary' | 'secondary'; * // size: 'sm' | 'md' | 'lg'; * // as?: 'button'; - * // type?: 'button' | 'submit' | 'reset'; - * // disabled?: boolean; * // ... 기타 button의 HTML 속성들 * // } */ type AsProps< Component extends ElementType, - PermanentProps extends object, - ComponentProps extends object + PermanentProps extends Record, + ComponentProps extends Record > = DistributiveMerge; /** * @description ref를 포함한 다형성 컴포넌트의 함수 시그니처를 정의하는 타입입니다. - * 이 타입은 다양한 요소 타입으로 렌더링될 수 있는 컴포넌트를 정의할 때 사용됩니다. - * - * - Default: 기본 요소 타입을 지정합니다. - * - Props: 컴포넌트에 추가할 사용자 정의 props를 정의합니다. - * - OnlyAs: 컴포넌트가 렌더링될 수 있는 요소 타입을 제한합니다. 기본적으로 모든 React 요소 타입이 허용됩니다. + * 하나의 컴포넌트가 여러 HTML 요소로 렌더링될 수 있도록 하며, 각 요소에 맞는 props와 ref를 자동으로 처리합니다. * - * @example - * // Button 컴포넌트의 사용자 정의 props를 정의합니다. - * interface ButtonProps { - * variant: 'primary' | 'secondary'; - * size: 'sm' | 'md' | 'lg'; - * } - * - * type ButtonComponent = PolymorphicWithRef<'button', ButtonProps>; + * @template Default - 렌더링할 요소의 타입을 지정합니다. 예를 들어, 'button', 'div' 등 HTML 요소가 될 수 있습니다. + * @template Props - 컴포넌트의 커스텀 props 타입입니다. + * @template OnlyAs - 컴포넌트가 렌더링될 수 있는 요소 타입을 제한합니다. (선택적, 기본값: ElementType) */ type PolymorphicWithRef< Default extends OnlyAs, - Props extends object = Record, + Props extends Record, OnlyAs extends ElementType = ElementType > = ( props: AsProps< @@ -110,17 +118,16 @@ type PolymorphicWithRef< ) => ReactElement | null; /** - * @description forwardRef를 사용한 다형성 컴포넌트의 전체 타입을 정의합니다. + * @description React.forwardRef를 사용한 다형성 컴포넌트의 전체 타입을 정의합니다. * ForwardRefExoticComponent와 PolymorphicWithRef를 결합합니다. - * ref 전달과 다형성을 모두 지원하는 컴포넌트를 생성합니다. * - * - Default: 기본 요소 타입을 지정합니다. - * - Props: 컴포넌트에 추가할 사용자 정의 props를 정의합니다. - * - OnlyAs: 컴포넌트가 렌더링될 수 있는 요소 타입을 제한합니다. 기본적으로 모든 React 요소 타입이 허용됩니다. + * @template Default - 렌더링할 요소의 타입을 지정합니다. 예를 들어, 'button', 'div' 등 HTML 요소가 될 수 있습니다. + * @template Props - 컴포넌트의 커스텀 props 타입입니다. + * @template OnlyAs - 컴포넌트가 렌더링될 수 있는 요소 타입을 제한합니다. (선택적, 기본값: ElementType) */ type PolyForwardComponent< Default extends OnlyAs, - Props extends object = Record, + Props extends Record, OnlyAs extends ElementType = ElementType > = Merge< ForwardRefExoticComponent< @@ -136,15 +143,17 @@ type PolyForwardComponent< /** * @description React.forwardRef를 위한 다형성 타입 래퍼입니다. - * 컴포넌트에 다형성과 ref 전달 기능을 모두 부여합니다. 다양한 요소 타입으로 렌더링할 수 있는 컴포넌트를 생성합니다. + * 컴포넌트에 다형성과 ref 전달 기능을 모두 부여합니다. + * + * @template Default - 렌더링할 요소의 타입을 지정합니다. 예를 들어, 'button', 'div' 등 HTML 요소가 될 수 있습니다. + * @template Props - 컴포넌트의 커스텀 props 타입입니다. + * @template OnlyAs - 컴포넌트가 렌더링될 수 있는 요소 타입을 제한합니다. (선택적, 기본값: ElementType) * - * - Default: 기본 요소 타입을 지정합니다. - * - Props: 컴포넌트에 추가할 사용자 정의 props를 정의합니다. - * - OnlyAs: 컴포넌트가 렌더링될 수 있는 요소 타입을 제한합니다. 기본적으로 모든 React 요소 타입이 허용됩니다. + * @returns 다형성과 ref 전달이 가능한 새로운 컴포넌트 타입을 반환합니다. */ type PolyRefFunction = < Default extends OnlyAs, - Props extends object = Record, + Props extends Record, OnlyAs extends ElementType = ElementType >( Component: ForwardRefRenderFunction