Skip to content

Commit

Permalink
fix: InView 컴포넌트 polymorphicForwardRef 적용 (#607)
Browse files Browse the repository at this point in the history
* fix: InView 컴포넌트 개선

* fix: InView 다형성 적용

* docs: polymorphicForwardRef 문서 개선
  • Loading branch information
ssi02014 authored Nov 26, 2024
1 parent 93d7e8b commit 35de803
Show file tree
Hide file tree
Showing 33 changed files with 352 additions and 75 deletions.
84 changes: 56 additions & 28 deletions docs/docs/react/components/InView.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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)** 훅을 사용하여 구현되었습니다.

<br />

Expand All @@ -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';

Expand All @@ -42,12 +43,41 @@ const Example = () => {

return (
<div>
{/* ... */}
<InView onIntersectStart={handleIntersectStart} onIntersectEnd={handleIntersectEnd}>
<div>Box1</div>
</InView>
</div>;
);
};
```

### asChild
- 자식 요소를 그대로 렌더링하고, 해당 요소를 관찰 대상으로 설정합니다.
- 자식 요소가 viewport에 노출되거나 숨겨질 때 `onIntersectStart/onIntersectEnd` 콜백 함수를 호출합니다.
- 이때 자식 요소는 단일 요소만 허용됩니다.
```tsx title="typescript"
import { InView } from '@modern-kit/react';

const Example = () => {
const ref = useRef<HTMLUListElement>(null);

const handleIntersectStart = () => {
/* action */
}

const handleIntersectEnd = () => {
/* action */
}

return (
<div>
<InView
as='ul'
onIntersectStart={handleIntersectStart}
onIntersectStart={handleIntersectEnd}
calledOnce>
<div>Box1</div>
onIntersectEnd={handleIntersectEnd}
>
<li>List Item1</li>
<li>List Item2</li>
</InView>
</div>;
);
Expand Down Expand Up @@ -77,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
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -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 '.';
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
62 changes: 50 additions & 12 deletions packages/react/src/components/InView/InView.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -19,16 +20,19 @@ afterEach(() => {
interface TestComponentProps {
onIntersectStart: () => void;
onIntersectEnd: () => void;
as?: ElementType;
calledOnce?: boolean;
}

const TestComponent = ({
onIntersectStart,
onIntersectEnd,
as,
calledOnce,
}: TestComponentProps) => {
return (
<InView
as={as}
onIntersectStart={onIntersectStart}
onIntersectEnd={onIntersectEnd}
calledOnce={calledOnce}>
Expand All @@ -41,23 +45,47 @@ 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}
onIntersectEnd={intersectEndMock}
/>
);

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(
<TestComponent
as="ul"
onIntersectStart={intersectStartMock}
onIntersectEnd={intersectEndMock}
/>
);

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

Expand All @@ -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);
Expand Down
46 changes: 39 additions & 7 deletions packages/react/src/components/InView/index.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,64 @@
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의 가시성 비율입니다.
* @param {Element | Document | null} props.root - 교차할 때 기준이 되는 root 요소입니다. 기본값은 `null`이며 이는 viewport를 의미합니다.
* @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 콜백 함수를 호출합니다.
* <InView onIntersectStart={onIntersectStart} onIntersectEnd={onIntersectEnd}>
* <div>Content1</div>
* </InView>
* ```
*
* @example
* ```tsx
* // as 속성을 통해 특정 요소로 렌더링할 수 있습니다.
* <InView as="ul" onIntersectStart={onIntersectStart} onIntersectEnd={onIntersectEnd}>
* <li>List Item1</li>
* <li>List Item2</li>
* </InView>
* ```
*/
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 <Slot ref={intersectionObserverRef}>{children}</Slot>;
};
return (
<Wrapper ref={useMergeRefs(ref, intersectionObserverRef)} {...props}>
{children}
</Wrapper>
);
}
);
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/components/Iterator/Iterator.spec.tsx
Original file line number Diff line number Diff line change
@@ -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 '.';

Expand Down
4 changes: 2 additions & 2 deletions packages/react/src/components/LazyImage/LazyImage.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/components/Mounted/Mounted.spec.tsx
Original file line number Diff line number Diff line change
@@ -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 '.';
Expand Down
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down
Loading

0 comments on commit 35de803

Please sign in to comment.