diff --git a/docs/docs/react/components/InView.mdx b/docs/docs/react/components/InView.mdx index 801405f31..997ab4951 100644 --- a/docs/docs/react/components/InView.mdx +++ b/docs/docs/react/components/InView.mdx @@ -4,13 +4,12 @@ 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`를 관찰 대상으로 설정합니다. -Intersection Observer Option을 설정할 수 있습니다.(하단 `Note` 참고) +`@modern-kit/react`의 **[useIntersectionObserver](https://modern-agile-team.github.io/modern-kit/docs/react/hooks/useIntersectionObserver)** 훅을 사용하여 구현되었습니다.
@@ -20,14 +19,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'; @@ -45,9 +50,48 @@ const Example = () => { {/* ... */} + onIntersectEnd={handleIntersectEnd} + calledOnce + >
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 ( +
+ {/* ... */} + +
+

Content1

+

Content2

+
; ); 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..dbc5cdd80 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,50 @@ 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); + * + * + *
+ * Content1 + * Content2 + *
+ *
+ * ``` */ -export const InView = ({ children, ...props }: InViewProps) => { - const { ref: intersectionObserverRef } = useIntersectionObserver(props); +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} + ); };