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

feat(react): useBeforeUnload 훅 추가 #604

Merged
merged 4 commits into from
Nov 25, 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
5 changes: 5 additions & 0 deletions .changeset/pink-dolphins-cheer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modern-kit/react': minor
---

feat(react): useBeforUnload 훅 추가 - @ssi02014
2 changes: 1 addition & 1 deletion docs/docs/react/components/LazyImage.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { LazyImage } from '@modern-kit/react';

# LazyImage

**[useIntersectionObserver](https://modern-agile-team.github.io/modern-kit/docs/react/hooks/useIntersectionObserver)** 를 활용해 `Viewport`에 노출될 때 할당된 이미지를 `Lazy Loading` 하는 이미지 컴포넌트입니다.
**[@modern-kit/react/useIntersectionObserver](https://modern-agile-team.github.io/modern-kit/docs/react/hooks/useIntersectionObserver)** 를 활용해 이미지가 `viewport`에 노출될 때 할당된 이미지를 `Lazy Loading(지연 로딩)` 하는 이미지 컴포넌트입니다.

Intersection Observer Option을 설정할 수 있습니다.(하단 `Note` 참고)

Expand Down
40 changes: 40 additions & 0 deletions docs/docs/react/hooks/useBeforeUnload.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { useBeforeUnload } from '@modern-kit/react'

# useBeforeUnload

**[beforeunload](https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event)** 이벤트를 리액트에서 쉽게 다룰 수 있는 훅입니다.

`beforeunload` 이벤트는 사용자가 페이지를 떠날 때 발생하는 이벤트입니다.

beforeunload 이벤트의 주요 사례는 웹 페이지에서 사용자에게 실제로 페이지를 떠날 것인지 묻는 확인 대화 상자를 표시해 확인하는 것입니다.

사용자가 확인 버튼을 누를 경우 브라우저는 새 페이지로 이동하고 그렇지 않으면 탐색을 취소하고 현재 페이지에 머무릅니다.

<br />

## Code
[🔗 실제 구현 코드 확인](https://github.com/modern-agile-team/modern-kit/blob/main/packages/react/src/hooks/useBeforeUnload/index.ts)

## Interface
```ts title="typescript"
function useBeforeUnload(enabled?: boolean | (() => boolean) = true): void
```

## Usage
```tsx title="typescript"
import { useBeforeUnload } from '@modern-kit/react'

const Example = () => {
useBeforeUnload();
return <div>페이지를 떠나보세요.</div>;
};
```

## Example

export const Example = () => {
useBeforeUnload();
return <div>페이지를 떠나보세요.</div>;
};

<Example />
21 changes: 15 additions & 6 deletions packages/react/src/components/ClientGate/ClientGate.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,35 @@ import { renderToString } from 'react-dom/server';

import { ClientGate } from '.';

const TestComponent = () => {
const TestComponent = ({ fallback }: { fallback?: JSX.Element }) => {
return (
<ClientGate fallback={<div>fallback component</div>}>
<ClientGate fallback={fallback}>
<div>children component</div>
</ClientGate>
);
};

describe('ClientGate', () => {
it('서버사이드 렌더링시에는 fallback 컴포넌트가 나타난다.', () => {
const html = renderToString(<TestComponent />);
it('서버사이드 렌더링시에는 fallback 컴포넌트가 렌더링되어야 합니다.', () => {
const html = renderToString(
<TestComponent fallback={<div>fallback component</div>} />
);

expect(html).toContain('fallback component');
expect(html).not.toContain('children component');
});

it('클라이언트사이드 렌더링시에는 children 컴포넌트가 나타난다.', () => {
renderSetup(<TestComponent />);
it('클라이언트사이드 렌더링시에는 children 컴포넌트가 렌더링되어야 합니다.', () => {
renderSetup(<TestComponent fallback={<div>fallback component</div>} />);

expect(screen.queryByText('fallback component')).not.toBeInTheDocument();
expect(screen.getByText('children component')).toBeInTheDocument();
});

it('서버 사이드 렌더링 시 fallback이 없을 경우 아무것도 렌더링되어서는 안됩니다.', () => {
const html = renderToString(<TestComponent />);

expect(html).not.toContain('fallback component');
expect(html).not.toContain('children component');
});
});
8 changes: 4 additions & 4 deletions packages/react/src/components/LazyImage/LazyImage.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,19 +26,19 @@ const TestComponent = () => {
};

describe('LazyImage', () => {
it('should not load the image before it is exposed to the viewport', () => {
it('이미지가 viewport에 노출되기 전에는 이미지가 로드되지 않아야 합니다.', () => {
renderSetup(<TestComponent />);

const img1 = screen.getByAltText('img1');
const img2 = screen.getByAltText('img2');

expect(img1).not.toHaveAttribute('src', 'img1');
expect(img2).not.toHaveAttribute('src', 'img2');
expect(img1).toHaveAttribute('class', 'lazy-image img1');
expect(img2).toHaveAttribute('class', 'lazy-image img2');
expect(img1).toHaveAttribute('class', 'img1');
expect(img2).toHaveAttribute('class', 'img2');
});

it('should load the image when it is exposed to the viewport', async () => {
it('이미지가 viewport에 노출되면 이미지가 로드되어야 합니다.', async () => {
renderSetup(<TestComponent />);

const img1 = screen.getByAltText('img1');
Expand Down
38 changes: 25 additions & 13 deletions packages/react/src/components/LazyImage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,31 @@ export interface LazyImageProps
src: string;
}

/**
* @description 이미지가 viewport에 노출 될 때 할당된 이미지를 `Lazy Loading(지연 로딩)` 하는 이미지 컴포넌트입니다.
*
* `@modern-kit/react`의 `useIntersectionObserver` 훅을 사용하여 구현되었습니다.
*
* @see https://modern-agile-team.github.io/modern-kit/docs/react/hooks/useIntersectionObserver
*
* @param {LazyImageProps} props - img 태그의 모든 속성 및 IntersectionObserverInit 속성을 지원합니다.
* @param {Element} [params.root=null] - 교차할 때 기준이 되는 root 요소입니다. 기본값은 `null`이며 이는 viewport를 의미합니다.
* @param {number | number[]} [params.threshold=0] - Observer가 콜백을 호출하는 임계값을 나타냅니다.
* @param {string} [params.rootMargin='100px 0px'] - 루트 요소에 대한 마진을 지정합니다. 이는 뷰포트 또는 루트 요소의 경계를 확장하거나 축소하는데 사용됩니다.
*
* @returns {JSX.Element} 지연 로딩을 지원하는 이미지 컴포넌트를 반환합니다.
*
* @example
* ```tsx
* <LazyImage
* src="imageUrl"
* alt="Lazy loaded image"
* rootMargin="100px 0px"
* />
* ```
*/
export const LazyImage = forwardRef<HTMLImageElement, LazyImageProps>(
({ src, threshold, root, rootMargin, alt, className, ...restProps }, ref) => {
({ src, threshold, root, rootMargin, ...restProps }, ref): JSX.Element => {
const { ref: imgRef } = useIntersectionObserver<HTMLImageElement>({
onIntersectStart: (entry) => {
const targetImgElement = entry.target as HTMLImageElement;
Expand All @@ -21,18 +44,7 @@ export const LazyImage = forwardRef<HTMLImageElement, LazyImageProps>(
rootMargin,
});

const customClassName = className
? `lazy-image ${className}`
: 'lazy-image';

return (
<img
className={customClassName}
ref={useMergeRefs(ref, imgRef)}
alt={alt}
{...restProps}
/>
);
return <img ref={useMergeRefs(ref, imgRef)} {...restProps} />;
}
);

Expand Down
17 changes: 13 additions & 4 deletions packages/react/src/components/Mounted/Mounted.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,35 @@ import { renderToString } from 'react-dom/server';

import { Mounted } from '.';

const TestComponent = () => {
const TestComponent = ({ fallback }: { fallback?: JSX.Element }) => {
return (
<Mounted fallback={<div>fallback component</div>}>
<Mounted fallback={fallback}>
<div>children component</div>
</Mounted>
);
};

describe('Mounted', () => {
it('마운트 전에는 fallback 컴포넌트가 나타난다.', () => {
const html = renderToString(<TestComponent />);
const html = renderToString(
<TestComponent fallback={<div>fallback component</div>} />
);

expect(html).toContain('fallback component');
expect(html).not.toContain('children component');
});

it('마운트 후에는 children 컴포넌트가 나타난다.', () => {
renderSetup(<TestComponent />);
renderSetup(<TestComponent fallback={<div>fallback component</div>} />);

expect(screen.queryByText('fallback component')).not.toBeInTheDocument();
expect(screen.getByText('children component')).toBeInTheDocument();
});

it('fallback이 없을 경우 아무것도 렌더링되어서는 안됩니다.', () => {
const html = renderToString(<TestComponent />);

expect(html).not.toContain('fallback component');
expect(html).not.toContain('children component');
});
});
1 change: 1 addition & 0 deletions packages/react/src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './useAsyncPreservedCallback';
export * from './useAsyncProcessQueue';
export * from './useBeforeUnload';
export * from './useBlockPromiseMultipleClick';
export * from './useClipboard';
export * from './useColorScheme';
Expand Down
44 changes: 44 additions & 0 deletions packages/react/src/hooks/useBeforeUnload/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { isFunction } from '@modern-kit/utils';
import { useEffect } from 'react';

/**
* @description beforeunload를 이벤트를 리액트에서 쉽게 다룰 수 있는 훅입니다.
*
* beforeunload 이벤트는 사용자가 페이지를 떠날 때 발생하는 이벤트입니다.
*
* beforeunload 이벤트의 주요 사례는 웹 페이지에서 사용자에게 실제로 페이지를 떠날 것인지 묻는 확인 대화 상자를 표시해 확인하는 것입니다.
* 사용자가 확인 버튼을 누를 경우 브라우저는 새 페이지로 이동하고 그렇지 않으면 탐색을 취소하고 현재 페이지에 머무릅니다.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event
*
* @param {boolean | (() => boolean)} [enabled=true] - 훅의 활성화 여부를 결정합니다. false일 경우 이벤트 리스너가 등록되지 않습니다.
* @returns {void}
*
* @example
* // 기본 사용법
* useBeforeUnload();
*
* @example
* // enabled가 false일 때 beforeunload 이벤트 리스너가 추가되지 않습니다.
* useBeforeUnload(false);
*/
export function useBeforeUnload(
enabled: boolean | (() => boolean) = true
): void {
const enabledToUse = isFunction(enabled) ? enabled() : enabled;

useEffect(() => {
if (!enabledToUse) return;

const handleBeforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault();
return (event.returnValue = '');
};

window.addEventListener('beforeunload', handleBeforeUnload);

return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}, [enabledToUse]);
}
32 changes: 32 additions & 0 deletions packages/react/src/hooks/useBeforeUnload/useBeforUnload.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { renderHook } from '@testing-library/react';
import { useBeforeUnload } from '.';
import { describe, it, vi, expect } from 'vitest';

const addEventListenerSpy = vi.spyOn(window, 'addEventListener');
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener');

describe('useBeforeUnload', () => {
it('기본적으로 beforeunload 이벤트 리스너가 추가되고, 언마운트 시 제거되어야 한다', () => {
const { unmount } = renderHook(() => useBeforeUnload());

expect(addEventListenerSpy).toHaveBeenCalledWith(
'beforeunload',
expect.any(Function)
);

unmount();

expect(removeEventListenerSpy).toHaveBeenCalledWith(
'beforeunload',
expect.any(Function)
);
});

it('enabled가 false일 때 이벤트 리스너가 추가되지 않아야 한다', () => {
const addEventListenerSpy = vi.spyOn(window, 'addEventListener');

renderHook(() => useBeforeUnload(false));

expect(addEventListenerSpy).not.toHaveBeenCalled();
});
});
16 changes: 5 additions & 11 deletions packages/react/src/hooks/useEventListener/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { usePreservedCallback } from '../../hooks/usePreservedCallback';
import { usePreservedState } from '../../hooks/usePreservedState';
import { useIsomorphicLayoutEffect } from '../../hooks/useIsomorphicLayoutEffect';
import {
EventListenerAvailableElement,
Expand Down Expand Up @@ -28,7 +27,7 @@ import {
* | SVGElementEventMap[E]
* | Event
* ) => void} listener - 이벤트가 발생할 때 호출될 콜백 함수입니다.
* @param {AddEventListenerOptions} [options={}] 이벤트 리스너에 대한 옵션 객체입니다.
* @param {AddEventListenerOptions} [options] 이벤트 리스너에 대한 옵션 객체입니다.
* 옵션에는 `once`, `capture`, `passive`와 같은 기본 이벤트 리스너 옵션과 `onBeforeAddListener`과 같은 커스텀 옵션이 포함될 수 있습니다.
* - `onBeforeAddListener`: 이벤트 리스너를 등록하기 전에 특정 작업을 수행하고자 할 때 사용됩니다.
*
Expand Down Expand Up @@ -117,9 +116,8 @@ export function useEventListener<
| MediaQueryListEventMap[M]
| Event
) => void,
options: AddEventListenerOptions = {}
options?: AddEventListenerOptions
): void {
const preservedOptions = usePreservedState(options);
const preservedListener = usePreservedCallback(listener);

useIsomorphicLayoutEffect(() => {
Expand All @@ -128,15 +126,11 @@ export function useEventListener<
if (!targetElement) return;

// event registration
targetElement.addEventListener(type, preservedListener, preservedOptions);
targetElement.addEventListener(type, preservedListener, options);

// clean up
return () => {
targetElement.removeEventListener(
type,
preservedListener,
preservedOptions
);
targetElement.removeEventListener(type, preservedListener, options);
};
}, [type, element, preservedOptions, preservedListener]);
}, [type, element, options, preservedListener]);
}
Loading