Skip to content

Commit

Permalink
fix: InView 다형성 적용
Browse files Browse the repository at this point in the history
  • Loading branch information
ssi02014 committed Nov 26, 2024
1 parent da6afc5 commit 353b0ec
Show file tree
Hide file tree
Showing 6 changed files with 234 additions and 98 deletions.
50 changes: 20 additions & 30 deletions docs/docs/react/components/InView.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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)** 훅을 사용하여 구현되었습니다.

Expand All @@ -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<HTMLElement>
>
const InView: PolyForwardComponent<"div", InViewProps, React.ElementType>
```
## Usage
Expand All @@ -50,7 +45,6 @@ const Example = () => {
<div>
<InView onIntersectStart={handleIntersectStart} onIntersectEnd={handleIntersectEnd}>
<div>Box1</div>
<div>Box2</div>
</InView>
</div>;
);
Expand Down Expand Up @@ -78,14 +72,12 @@ const Example = () => {
return (
<div>
<InView
asChild
as='ul'
onIntersectStart={handleIntersectStart}
onIntersectEnd={handleIntersectEnd}
>
<ul ref={ref} style={{ background: '#c0392B' }}>
<li>List Item1</li>
<li>List Item2</li>
</ul>
<li>List Item1</li>
<li>List Item2</li>
</InView>
</div>;
);
Expand Down Expand Up @@ -115,32 +107,30 @@ export const Example = () => {
<InView
onIntersectStart={() => console.log('action onIntersectStart(1)')}
onIntersectEnd={() => console.log('action onIntersectEnd(1)')}
calledOnce
>
<div style={{
style={{
...inViewStyle,
background: '#c0392B',
}}>
<p>Box1</p>
<p>브라우저 개발자 도구의 콘솔을 확인해주세요.</p>
<p>onIntersectStart가 최초 1회만 호출됩니다.</p>
<p>calledOnce: true</p>
</div>
}}
calledOnce
>
<p>Box1</p>
<p>브라우저 개발자 도구의 콘솔을 확인해주세요.</p>
<p>calledOnce: true</p>
<p>as: div</p>
</InView>
<div style={{ height: '300px' }} />
<InView
onIntersectStart={() => console.log('action onIntersectStart(2)')}
onIntersectEnd={() => console.log('action onIntersectEnd(2)')}
>
<div style={{
style={{
...inViewStyle,
background: '#89a5ea',
}}>
<p>Box2</p>
<p>브라우저 개발자 도구의 콘솔을 확인해주세요.</p>
<p>onIntersectStart, onIntersectEnd 함수가 여러 번 호출됩니다.</p>
<p>calledOnce: false</p>
</div>
}}
>
<li>Box2</li>
<li>브라우저 개발자 도구의 콘솔을 확인해주세요.</li>
<li>calledOnce: false</li>
<li>as: ul</li>
</InView>
<div style={{ width: '100%', height: '900px', textAlign: 'center' }} />
</div>
Expand Down
67 changes: 28 additions & 39 deletions packages/react/src/components/InView/InView.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
mockIntersectionObserverSetup,
} from '../../utils/test/mockIntersectionObserver';
import { renderSetup } from '../../utils/test/renderSetup';
import { ElementType } from 'react';

beforeEach(() => {
mockIntersectionObserverSetup();
Expand All @@ -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 (
<InView
asChild={asChild}
as={as}
onIntersectStart={onIntersectStart}
onIntersectEnd={onIntersectEnd}
calledOnce={calledOnce}>
Expand All @@ -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(
<TestComponent
onIntersectStart={intersectStartMock}
Expand All @@ -53,6 +54,7 @@ describe('InView', () => {
);

const boxWrapper = screen.getByText('box').parentElement as HTMLElement;
expect(boxWrapper.tagName).toBe('DIV');

expect(intersectStartMock).toBeCalledTimes(0);
expect(intersectEndMock).toBeCalledTimes(0);
Expand All @@ -68,32 +70,22 @@ describe('InView', () => {
expect(intersectEndMock).toBeCalledTimes(1);
});

it('asChild 프로퍼티가 true이면 자식 요소가 그대로 렌더링되야 하며, 자식 요소를 관찰 대상으로 설정해야 합니다.', async () => {
it('as props를 통해 특정 요소로 렌더링할 수 있습니다.', async () => {
renderSetup(
<TestComponent
as="ul"
onIntersectStart={intersectStartMock}
onIntersectEnd={intersectEndMock}
asChild={true}
/>
);

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);
});

Expand All @@ -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(
<InView asChild={true}>
<div>box1</div>
<div>box2</div>
</InView>
)
).toThrow(
'InView 컴포넌트는 asChild 프로퍼티가 true일 경우 자식으로 단일 요소만 허용합니다.'
);
});
});
45 changes: 16 additions & 29 deletions packages/react/src/components/InView/index.tsx
Original file line number Diff line number Diff line change
@@ -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;
}

/**
Expand Down Expand Up @@ -38,40 +38,27 @@ interface InViewProps extends UseIntersectionObserverProps {
* // 해당 div가 viewport에 노출되거나 숨겨질 때 onIntersectStart/onIntersectEnd 콜백 함수를 호출합니다.
* <InView onIntersectStart={onIntersectStart} onIntersectEnd={onIntersectEnd}>
* <div>Content1</div>
* <div>Content2</div>
* </InView>
* ```
*
* @example
* ```tsx
* // asChild 프로퍼티를 사용하면 자식 요소를 그대로 렌더링하고, 자식 요소를 관찰 대상으로 설정합니다.
* // 자식 요소가 viewport에 노출되거나 숨겨질 때 onIntersectStart/onIntersectEnd 콜백이 호출됩니다.
* // 이때 자식 요소는 단일 요소만 허용됩니다.
* const ref = useRef<HTMLUListElement>(null);
*
* <InView asChild onIntersectStart={onIntersectStart} onIntersectEnd={onIntersectEnd}>
* <ul ref={ref} style={style}>
* <li>List Item1</li>
* <li>List Item2</li>
* </ul>
* // as 속성을 통해 특정 요소로 렌더링할 수 있습니다.
* <InView as="ul" onIntersectStart={onIntersectStart} onIntersectEnd={onIntersectEnd}>
* <li>List Item1</li>
* <li>List Item2</li>
* </InView>
* ```
*/
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 (
<Wrapper ref={useMergeRefs(ref, intersectionObserverRef)} {...props}>
{children}
</Wrapper>
);
}
return (
<InViewWrapper ref={intersectionObserverRef}>{children}</InViewWrapper>
);
};
);
1 change: 1 addition & 0 deletions packages/react/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './components';
export * from './hooks';
export * from './types';
1 change: 1 addition & 0 deletions packages/react/src/types/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './polymorphicForwardRef';
Loading

0 comments on commit 353b0ec

Please sign in to comment.