diff --git a/.changeset/cyan-weeks-approve.md b/.changeset/cyan-weeks-approve.md
new file mode 100644
index 00000000..9bdffd20
--- /dev/null
+++ b/.changeset/cyan-weeks-approve.md
@@ -0,0 +1,5 @@
+---
+'@modern-kit/react': patch
+---
+
+feat(react): useIntersectionObserver 개선 및 InView, LazyImage 수정 - @ssi02014
diff --git a/docs/docs/react/components/InView.mdx b/docs/docs/react/components/InView.mdx
index 41f3d21f..c95b6bbc 100644
--- a/docs/docs/react/components/InView.mdx
+++ b/docs/docs/react/components/InView.mdx
@@ -2,7 +2,13 @@ import { InView } from '@modern-kit/react';
# InView
-`Viewport`에 노출될 때 props로 넘겨주는 `action` 콜백 함수를 호출하는 컴포넌트입니다.
+`InView`는 **[useIntersectionObserver](https://modern-agile-team.github.io/modern-kit/docs/react/hooks/useIntersectionObserver)**를 선언적으로 활용 할 수 있는 컴포넌트입니다.
+
+`Viewport`에 노출될 때(`onIntersectStart`) 혹은 나갈 때(`onIntersectEnd`) 특정 action 함수를 호출 할 수 있는 컴포넌트입니다.
+
+`calledOnceVisible`을 활용해 컴포넌트가 `viewport에 노출 될 때 한번 onIntersectStart을 호출` 할 수 있습니다.
+
+Intersection Observer Option을 설정할 수 있습니다.(하단 `Note` 참고)
@@ -11,6 +17,18 @@ import { InView } from '@modern-kit/react';
## Interface
```ts title="typescript"
+interface IntersectionObserverInit {
+ root?: Element | Document | null;
+ rootMargin?: string;
+ threshold?: number | number[];
+}
+
+interface UseIntersectionObserverProps extends IntersectionObserverInit {
+ onIntersectStart?: (entry: IntersectionObserverEntry) => void;
+ onIntersectEnd?: (entry: IntersectionObserverEntry) => void;
+ calledOnceVisible?: boolean;
+}
+
type InViewProps = React.ComponentProps<'div'> & UseIntersectionObserverProps;
const InView: React.ForwardRefExoticComponent<
@@ -24,46 +42,86 @@ const InView: React.ForwardRefExoticComponent<
import { InView } from '@modern-kit/react';
const Example = () => {
- const onAction = () => {
+ const handleIntersectStart = () => {
+ /* action */
+ }
+
+ const handleIntersectEnd = () => {
/* action */
}
return (
{/* ... */}
- Box1
-
+
+ Box1
+
+ ;
);
};
```
## Example
-
-
스크롤 해주세요.
-
console.log("action callback(1)")}
- calledOnce
- >
-
-
Box1
-
브라우저 개발자 도구의 콘솔을 확인해주세요.
-
action 콜백 함수가 최초 1회만 호출됩니다.
-
calledOnce: true
-
-
-
-
console.log("action callback(2)")}
- >
+export const Example = () => {
+ const inViewStyle = {
+ width: '100%',
+ color: 'white',
+ textAlign: 'center',
+ fontSize: '21px',
+ padding: '0 20px',
+ }
+ return (
-
Box2
-
브라우저 개발자 도구의 콘솔을 확인해주세요.
-
action 콜백 함수가 여러 번 호출됩니다.
-
calledOnce: false
+
+ 스크롤 해주세요.
+
+
console.log('action onIntersectStart(1)')}
+ onIntersectEnd={() => console.log('action onIntersectEnd(1)')}
+ calledOnceVisible
+ >
+
+
Box1
+
브라우저 개발자 도구의 콘솔을 확인해주세요.
+
onIntersectStart가 최초 1회만 호출됩니다.
+
calledOnceVisible: true
+
+
+
+
console.log('action onIntersectStart(2)')}
+ onIntersectEnd={() => console.log('action onIntersectEnd(2)')}
+ >
+
+
Box2
+
브라우저 개발자 도구의 콘솔을 확인해주세요.
+
onIntersectStart, onIntersectEnd 함수가 여러 번 호출됩니다.
+
calledOnceVisible: false
+
+
+
-
-
-
\ No newline at end of file
+ );
+};
+
+
+
+## Note
+- [Intersection Observer API](https://developer.mozilla.org/ko/docs/Web/API/IntersectionObserver)
\ No newline at end of file
diff --git a/docs/docs/react/components/LazyImage.mdx b/docs/docs/react/components/LazyImage.mdx
index b708c987..93899a8e 100644
--- a/docs/docs/react/components/LazyImage.mdx
+++ b/docs/docs/react/components/LazyImage.mdx
@@ -2,7 +2,7 @@ import { LazyImage } from '@modern-kit/react';
# LazyImage
-`Viewport`에 노출될 때 할당된 이미지를 `Lazy Loading` 하는 이미지 컴포넌트입니다.
+**[useIntersectionObserver](https://modern-agile-team.github.io/modern-kit/docs/react/hooks/useIntersectionObserver)**를 활용해 `Viewport`에 노출될 때 할당된 이미지를 `Lazy Loading` 하는 이미지 컴포넌트입니다.
`width`, `height` 값을 입력해 이미지의 크기를 조절 할 수 있으며, 동시에 `Layout Shift`를 개선할 수 있습니다.
@@ -15,13 +15,21 @@ Intersection Observer Option을 설정할 수 있습니다.(하단 `Note` 참고
## Interface
```ts title="typescript"
+interface IntersectionObserverInit {
+ root?: Element | Document | null;
+ rootMargin?: string;
+ threshold?: number | number[];
+}
+
interface LazyImageProps
extends React.ComponentProps<'img'>,
IntersectionObserverInit {
src: string;
}
-const LazyImage: React.ForwardRefExoticComponent & React.RefAttributes>
+const LazyImage: React.ForwardRefExoticComponent<
+ Omit & React.RefAttributes
+>;
```
## Usage
@@ -92,44 +100,56 @@ const Example = () => {
## Example
-
-
- 스크롤 해주세요.
-
-
console.log('img click1')}
- />
-
-
-
- console.log('img click2')}
- />
-
-
-
- console.log('img click3')}
- />
-
+export const Example = () => {
+ return (
+
+
+ 스크롤 해주세요.
+
+
console.log('img click1')}
+ />
+
+
+
+ console.log('img click2')}
+ />
+
+
+
+ console.log('img click3')}
+ />
+
+ );
+};
+
+
+
## Note
- [Intersection Observer API](https://developer.mozilla.org/ko/docs/Web/API/IntersectionObserver)
\ No newline at end of file
diff --git a/docs/docs/react/hooks/useIntersectionObserver.mdx b/docs/docs/react/hooks/useIntersectionObserver.mdx
index 09f5143c..7038c164 100644
--- a/docs/docs/react/hooks/useIntersectionObserver.mdx
+++ b/docs/docs/react/hooks/useIntersectionObserver.mdx
@@ -1,6 +1,10 @@
+import { useIntersectionObserver } from '@modern-kit/react';
+
# useIntersectionObserver
-`ref`를 할당한 타겟 엘리먼트가 `Viewport`에 노출되는 시점에 `action` 콜백 함수를 호출시키는 커스텀 훅입니다.
+`ref`를 할당한 타겟 엘리먼트가 `Viewport`에 노출될 때(`onIntersectStart`) 혹은 나갈 때(`onIntersectEnd`) 특정 action 함수를 호출 할 수 있는 컴포넌트입니다.
+
+`calledOnceVisible`을 활용해 타겟 엘리먼트가 `viewport에 노출 될 때 한번 onIntersectStart을 호출` 할 수 있습니다.
Intersection Observer Option을 설정할 수 있습니다.(하단 `Note` 참고)
@@ -11,18 +15,28 @@ Intersection Observer Option을 설정할 수 있습니다.(하단 `Note` 참고
## Interface
```ts title="typescript"
+interface IntersectionObserverInit {
+ root?: Element | Document | null;
+ rootMargin?: string;
+ threshold?: number | number[];
+}
+
interface UseIntersectionObserverProps extends IntersectionObserverInit {
- action: (entry: IntersectionObserverEntry) => void;
- calledOnce?: boolean;
+ onIntersectStart?: (entry: IntersectionObserverEntry) => void;
+ onIntersectEnd?: (entry: IntersectionObserverEntry) => void;
+ calledOnceVisible?: boolean;
}
const useIntersectionObserver: ({
- action,
- calledOnce,
- root,
- threshold,
- rootMargin,
-}: UseIntersectionObserverProps) => (node: T) => void;
+ onIntersectStart,
+ onIntersectEnd,
+ calledOnceVisible, // default: false
+ root, // default: null
+ threshold, // default: 0
+ rootMargin, // default: '0px 0px 0px 0px'
+}: UseIntersectionObserverProps) => {
+ ref: (node: T) => void;
+};
```
## Usage
@@ -30,22 +44,63 @@ const useIntersectionObserver: ({
import { useIntersectionObserver } from '@modern-kit/react';
const Example = () => {
- const divRef = useIntersectionObserver({
- action: () => { /* action */},
- });
- const imgRef = useIntersectionObserver({
- action: (entry) => { /* 필요하다면 IntersectionObserverEntry 를 사용할 수 있습니다. */},
+ const { ref: targetRef } = useIntersectionObserver({
+ onIntersectStart: (entry) => {
+ console.log("onIntersectStart: ", entry);
+ },
+ onIntersectEnd: (entry) => {
+ console.log("onIntersectEnd: ", entry);
+ },
+ calledOnceVisible: false
});
+
+ const boxStyle = {
+ height: "800px",
+ backgroundColor: "teal"
+ }
return (
- {/* ... */}
-
Box
-
![img](url)
+
+
+ 타겟 요소
+ 개발자 도구 콘솔을 확인해주세요.
+
+
);
};
```
+## Exmaple
+
+export const Example = () => {
+ const { ref: targetRef } = useIntersectionObserver({
+ onIntersectStart: (entry) => {
+ console.log("onIntersectStart: ", entry);
+ },
+ onIntersectEnd: (entry) => {
+ console.log("onIntersectEnd: ", entry);
+ },
+ calledOnceVisible: false
+ });
+ const boxStyle = {
+ height: "800px",
+ backgroundColor: "teal"
+ }
+ return (
+
+
+
+ 타겟 요소
+ 개발자 도구 콘솔을 확인해주세요.
+
+
+
+ );
+};
+
+
+
## Note
- [Intersection Observer API](https://developer.mozilla.org/ko/docs/Web/API/IntersectionObserver)
\ No newline at end of file
diff --git a/packages/react/src/components/InView/InView.spec.tsx b/packages/react/src/components/InView/InView.spec.tsx
index d17f1b2a..57448b24 100644
--- a/packages/react/src/components/InView/InView.spec.tsx
+++ b/packages/react/src/components/InView/InView.spec.tsx
@@ -15,80 +15,72 @@ afterEach(() => {
mockIntersectionObserverCleanup();
});
+interface TestComponentProps {
+ onIntersectStart: () => void;
+ onIntersectEnd: () => void;
+ calledOnceVisible?: boolean;
+}
+
const TestComponent = ({
- action1,
- action2,
-}: {
- action1: () => void;
- action2: () => void;
-}) => {
+ onIntersectStart,
+ onIntersectEnd,
+ calledOnceVisible,
+}: TestComponentProps) => {
return (
-
-
- box1
-
- box2
-
+
+ box
+
);
};
describe('InView Component', () => {
+ const intersectStartMock = vi.fn();
+ const intersectEndMock = vi.fn();
+
it('should call the action function when the InView component is exposed to the viewport', async () => {
- const mockFn1 = vi.fn();
- const mockFn2 = vi.fn();
- renderSetup();
+ renderSetup(
+
+ );
- const box1 = screen.getByText('box1');
- const box2 = screen.getByText('box2');
+ const box = screen.getByText('box');
- expect(mockFn1).toBeCalledTimes(0);
- expect(mockFn2).toBeCalledTimes(0);
+ expect(intersectStartMock).toBeCalledTimes(0);
+ expect(intersectEndMock).toBeCalledTimes(0);
- await waitFor(() => mockIntersecting({ type: 'view', element: box1 }));
- expect(mockFn1).toBeCalledTimes(1);
+ await waitFor(() => mockIntersecting({ type: 'view', element: box }));
+ expect(intersectStartMock).toBeCalledTimes(1);
- await waitFor(() => mockIntersecting({ type: 'view', element: box2 }));
- expect(mockFn2).toBeCalledTimes(1);
+ await waitFor(() => mockIntersecting({ type: 'hide', element: box }));
+ expect(intersectEndMock).toBeCalledTimes(1);
});
it('should call the action callback function once if the calledOnce prop is true', async () => {
- const mockFn1 = vi.fn();
- const mockFn2 = vi.fn();
- renderSetup();
-
- const box1 = screen.getByText('box1');
-
- expect(mockFn2).toBeCalledTimes(0);
-
- await waitFor(() => mockIntersecting({ type: 'view', element: box1 }));
- expect(mockFn1).toBeCalledTimes(1);
-
- await waitFor(() => mockIntersecting({ type: 'hide', element: box1 }));
- await waitFor(() => mockIntersecting({ type: 'view', element: box1 }));
- await waitFor(() => mockIntersecting({ type: 'hide', element: box1 }));
- await waitFor(() => mockIntersecting({ type: 'view', element: box1 }));
-
- expect(mockFn1).toBeCalledTimes(1);
- });
-
- it('should call the action callback function every time it is exposed to the viewport if the calledOnce prop is false', async () => {
- const mockFn1 = vi.fn();
- const mockFn2 = vi.fn();
- renderSetup();
+ renderSetup(
+
+ );
- const box2 = screen.getByText('box2');
+ const box = screen.getByText('box');
- expect(mockFn2).toBeCalledTimes(0);
+ await waitFor(() => mockIntersecting({ type: 'view', element: box }));
- await waitFor(() => mockIntersecting({ type: 'view', element: box2 }));
- expect(mockFn2).toBeCalledTimes(1);
+ expect(intersectStartMock).toBeCalledTimes(1);
- await waitFor(() => mockIntersecting({ type: 'hide', element: box2 }));
- await waitFor(() => mockIntersecting({ type: 'view', element: box2 }));
- expect(mockFn2).toBeCalledTimes(2);
+ await waitFor(() => mockIntersecting({ type: 'hide', element: box }));
+ 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: 'hide', element: box2 }));
- await waitFor(() => mockIntersecting({ type: 'view', element: box2 }));
- expect(mockFn2).toBeCalledTimes(3);
+ expect(intersectStartMock).toBeCalledTimes(1);
+ expect(intersectEndMock).toBeCalledTimes(0);
});
});
diff --git a/packages/react/src/components/InView/index.tsx b/packages/react/src/components/InView/index.tsx
index 2105ce07..52c162d9 100644
--- a/packages/react/src/components/InView/index.tsx
+++ b/packages/react/src/components/InView/index.tsx
@@ -11,16 +11,25 @@ export const InView = forwardRef<
HTMLDivElement,
PropsWithChildren
>((props, ref) => {
- const { action, calledOnce, threshold, root, rootMargin, ...restProps } =
- props;
-
- const intersectionObserverRef = useIntersectionObserver({
- action,
- calledOnce,
+ const {
+ onIntersectStart,
+ onIntersectEnd,
+ calledOnceVisible,
threshold,
root,
rootMargin,
- });
+ ...restProps
+ } = props;
+
+ const { ref: intersectionObserverRef } =
+ useIntersectionObserver({
+ onIntersectStart,
+ onIntersectEnd,
+ calledOnceVisible,
+ threshold,
+ root,
+ rootMargin,
+ });
return (
diff --git a/packages/react/src/components/LazyImage/index.tsx b/packages/react/src/components/LazyImage/index.tsx
index d09f582d..c9b3cd37 100644
--- a/packages/react/src/components/LazyImage/index.tsx
+++ b/packages/react/src/components/LazyImage/index.tsx
@@ -13,12 +13,12 @@ export const LazyImage = forwardRef
(
{ src, style, threshold, root, rootMargin, ...restProps }: LazyImageProps,
ref
) => {
- const imgRef = useIntersectionObserver({
- action: (entry) => {
+ const { ref: imgRef } = useIntersectionObserver({
+ onIntersectStart: (entry) => {
const targetImgElement = entry.target as HTMLImageElement;
targetImgElement.src = src;
},
- calledOnce: true,
+ calledOnceVisible: true,
threshold,
root,
rootMargin,
diff --git a/packages/react/src/hooks/useIntersectionObserver/index.ts b/packages/react/src/hooks/useIntersectionObserver/index.ts
index 636c458e..7c8a5e7c 100644
--- a/packages/react/src/hooks/useIntersectionObserver/index.ts
+++ b/packages/react/src/hooks/useIntersectionObserver/index.ts
@@ -1,53 +1,65 @@
-import { useRef } from 'react';
+import { useCallback, useRef } from 'react';
import { usePreservedCallback } from '../usePreservedCallback';
-import { Nullable } from '@modern-kit/types';
import { noop } from '@modern-kit/utils';
+import { Nullable } from '@modern-kit/types';
export interface UseIntersectionObserverProps extends IntersectionObserverInit {
- action: (entry: IntersectionObserverEntry) => void;
- calledOnce?: boolean;
+ onIntersectStart?: (entry: IntersectionObserverEntry) => void;
+ onIntersectEnd?: (entry: IntersectionObserverEntry) => void;
+ calledOnceVisible?: boolean;
}
export const useIntersectionObserver = ({
- action,
- calledOnce = false,
+ onIntersectStart,
+ onIntersectEnd,
+ calledOnceVisible = false,
root = null,
- threshold = [0],
+ threshold = 0,
rootMargin = '0px 0px 0px 0px',
}: UseIntersectionObserverProps) => {
const intersectionObserverRef = useRef>(null);
+ const preservedIntersectStart = usePreservedCallback(
+ onIntersectStart ?? noop
+ );
+ const preservedIntersectEnd = usePreservedCallback(onIntersectEnd ?? noop);
- const callbackAction = usePreservedCallback(action ?? noop);
-
- const observerAction = usePreservedCallback(
+ const intersectionObserverCallback = useCallback(
([entry]: IntersectionObserverEntry[], observer: IntersectionObserver) => {
- if (entry && entry.isIntersecting) {
+ if (!entry) return;
+
+ if (entry.isIntersecting) {
const targetElement = entry.target as T;
- callbackAction(entry);
+ preservedIntersectStart(entry);
- if (calledOnce) {
+ if (calledOnceVisible) {
observer.unobserve(targetElement);
}
+ } else {
+ preservedIntersectEnd(entry);
}
- }
+ },
+ [calledOnceVisible, preservedIntersectStart, preservedIntersectEnd]
);
- const targetRef = usePreservedCallback((node: T) => {
+ const targetRef = (node: T) => {
if (intersectionObserverRef.current) {
intersectionObserverRef.current.disconnect();
}
- intersectionObserverRef.current = new IntersectionObserver(observerAction, {
- root,
- threshold,
- rootMargin,
- });
+ intersectionObserverRef.current = new IntersectionObserver(
+ intersectionObserverCallback,
+ {
+ threshold,
+ root,
+ rootMargin,
+ }
+ );
if (node) {
intersectionObserverRef.current.observe(node);
}
- });
+ };
- return targetRef;
+ return { ref: targetRef };
};
diff --git a/packages/react/src/hooks/useIntersectionObserver/useIntersectionObserver.spec.tsx b/packages/react/src/hooks/useIntersectionObserver/useIntersectionObserver.spec.tsx
index 41820829..3a5d6edb 100644
--- a/packages/react/src/hooks/useIntersectionObserver/useIntersectionObserver.spec.tsx
+++ b/packages/react/src/hooks/useIntersectionObserver/useIntersectionObserver.spec.tsx
@@ -15,86 +15,70 @@ afterEach(() => {
mockIntersectionObserverCleanup();
});
+interface TestComponentProps {
+ onIntersectStart: () => void;
+ onIntersectEnd: () => void;
+ calledOnceVisible?: boolean;
+}
+
const TestComponent = ({
- action1,
- action2,
-}: {
- action1: () => void;
- action2: () => void;
-}) => {
- const boxRef1 = useIntersectionObserver({
- action: action1,
- calledOnce: true,
- });
- const boxRef2 = useIntersectionObserver({
- action: action2,
+ onIntersectStart,
+ onIntersectEnd,
+ calledOnceVisible,
+}: TestComponentProps) => {
+ const { ref: boxRef } = useIntersectionObserver({
+ onIntersectStart,
+ onIntersectEnd,
+ calledOnceVisible,
});
- return (
-
- );
+ return box
;
};
describe('useIntersectionObserver', () => {
- it('should call the action callback function when the target element assigned to the returned "ref" is exposed to the Viewport', async () => {
- const mockFn1 = vi.fn();
- const mockFn2 = vi.fn();
- renderSetup();
-
- const box1 = screen.getByText('box1');
- const box2 = screen.getByText('box2');
-
- expect(mockFn1).toBeCalledTimes(0);
- expect(mockFn2).toBeCalledTimes(0);
-
- await waitFor(() => mockIntersecting({ type: 'view', element: box1 }));
- expect(mockFn1).toBeCalledTimes(1);
+ const intersectStartMock = vi.fn();
+ const intersectEndMock = vi.fn();
- await waitFor(() => mockIntersecting({ type: 'view', element: box2 }));
- expect(mockFn2).toBeCalledTimes(1);
- });
-
- it('should call the action callback function only once when the calledOnce option is true', async () => {
- const mockFn1 = vi.fn();
- const mockFn2 = vi.fn();
- renderSetup();
-
- const box1 = screen.getByText('box1');
+ it('should call the action callback function when the target element assigned to the returned "ref" is exposed to the Viewport', async () => {
+ renderSetup(
+
+ );
- expect(mockFn2).toBeCalledTimes(0);
+ const box = screen.getByText('box');
- await waitFor(() => mockIntersecting({ type: 'view', element: box1 }));
- expect(mockFn1).toBeCalledTimes(1);
+ expect(intersectStartMock).toBeCalledTimes(0);
+ expect(intersectEndMock).toBeCalledTimes(0);
- await waitFor(() => mockIntersecting({ type: 'hide', element: box1 }));
- await waitFor(() => mockIntersecting({ type: 'view', element: box1 }));
- await waitFor(() => mockIntersecting({ type: 'hide', element: box1 }));
- await waitFor(() => mockIntersecting({ type: 'view', element: box1 }));
+ await waitFor(() => mockIntersecting({ type: 'view', element: box }));
+ expect(intersectStartMock).toBeCalledTimes(1);
- expect(mockFn1).toBeCalledTimes(1);
+ await waitFor(() => mockIntersecting({ type: 'hide', element: box }));
+ expect(intersectEndMock).toBeCalledTimes(1);
});
- it('should call the action callback function every time the target element is exposed to the Viewport when the calledOnce option is false', async () => {
- const mockFn1 = vi.fn();
- const mockFn2 = vi.fn();
- renderSetup();
-
- const box2 = screen.getByText('box2');
+ it('should call the action callback function only once when the calledOnceVisible option is true', async () => {
+ renderSetup(
+
+ );
- expect(mockFn2).toBeCalledTimes(0);
+ const box = screen.getByText('box');
- await waitFor(() => mockIntersecting({ type: 'view', element: box2 }));
- expect(mockFn2).toBeCalledTimes(1);
+ await waitFor(() => mockIntersecting({ type: 'view', element: box }));
+ expect(intersectStartMock).toBeCalledTimes(1);
- await waitFor(() => mockIntersecting({ type: 'hide', element: box2 }));
- await waitFor(() => mockIntersecting({ type: 'view', element: box2 }));
- expect(mockFn2).toBeCalledTimes(2);
+ await waitFor(() => mockIntersecting({ type: 'hide', element: box }));
+ 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: 'hide', element: box2 }));
- await waitFor(() => mockIntersecting({ type: 'view', element: box2 }));
- expect(mockFn2).toBeCalledTimes(3);
+ expect(intersectStartMock).toBeCalledTimes(1);
+ expect(intersectEndMock).toBeCalledTimes(0);
});
});