Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: InView 컴포넌트 polymorphicForwardRef 적용 #607

Merged
merged 3 commits into from
Nov 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
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