Skip to content

Commit 34dac06

Browse files
authored
merge: useModal 훅 구현 (#24)
* feat: useAnimation의 AnimationWrapper가 div 타입 props를 받을 수 있도록 변경 * feat: 애니메이션을 지원하는useModal 기능 추가 * test: useModal 기능 테스트 추가 * docs: useModal Storybook 추가 * docs: README useModal 추가 * test: branch test coverage 개선 * feat: overlayClose옵션 추가 * test: overlayClose test 추가 * docs: Storybook, README 업데이트 * chore: UseModalAnimation type interface로 변경
1 parent fec3b6e commit 34dac06

File tree

12 files changed

+480
-6
lines changed

12 files changed

+480
-6
lines changed

README.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,3 +328,44 @@ const SomeComponent = () => {
328328
);
329329
};
330330
```
331+
332+
### useModal
333+
334+
A hook for easily managing an animated modal through a portal.
335+
336+
#### Function Arguments
337+
338+
modalProps object is accepted. This object is structured as follows:
339+
340+
```ts
341+
interface UseModalProps {
342+
modalRoot?: ModalRoot;
343+
overlayClose?: boolean;
344+
overlayAnimation?: {
345+
showClassName?: string;
346+
hideClassName?: string;
347+
};
348+
modalAnimation?: {
349+
showClassName?: string;
350+
hideClassName?: string;
351+
};
352+
}
353+
```
354+
355+
`modalRoot`: The HTMLElement where the modal will be rendered. The default is `document.body`.
356+
357+
`overlayClose`: Sets whether clicking on the overlay closes the modal. The default is `true`.
358+
359+
`overlayAnimation`: The animation className applied to the overlay. It can accept two key-value pairs: `showClassName` and `hideClassName`.
360+
361+
`modalAnimation`: The animation className applied to the modal. It can accept two key-value pairs: `showClassName` and `hideClassName`.
362+
363+
#### Return Values
364+
365+
`Modal`: A component that renders its children to the specified root through a portal.
366+
367+
`show`: Opens the modal.
368+
369+
`hide`: Closes the modal.
370+
371+
`isShow`: Indicates whether the modal is open.

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import useThrottle from './useThrottle/useThrottle';
1111
import useDebounce from './useDebounce/useDebounce';
1212
import useLocalStorage from './useLocalStorage/useLocalStorage';
1313
import useDisclosure from './useDisclosure/useDisclosure';
14+
import useModal from './useModal/useModal';
1415

1516
export {
1617
useInput,
@@ -26,4 +27,5 @@ export {
2627
useDebounce,
2728
useLocalStorage,
2829
useDisclosure,
30+
useModal,
2931
};

src/stories/useModal/Docs.mdx

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { Canvas, Meta, Description } from '@storybook/blocks';
2+
import * as Modal from './Modal.stories';
3+
4+
<Meta of={Modal} />
5+
6+
# useModal
7+
8+
애니메이션이 적용된 Modal을 portal을 통해 간편하게 관리하기 위한 훅입니다.
9+
10+
## 함수 인자
11+
12+
modalProps객체를 받습니다. 해당 객체는 아래와 같이 구성됩니다.
13+
14+
```ts
15+
interface UseModalProps {
16+
modalRoot?: ModalRoot;
17+
overlayClose?: boolean;
18+
overlayAnimation?: {
19+
showClassName?: string;
20+
hideClassName?: string;
21+
};
22+
modalAnimation?: {
23+
showClassName?: string;
24+
hideClassName?: string;
25+
};
26+
}
27+
```
28+
29+
`modalRoot`: 모달을 렌더링할 HTMLElement입니다. default는 `document.body`입니다.
30+
31+
`overlayClose`: overlay를 눌러 modal을 닫을지를 설정합니다. default는 `true`입니다.
32+
33+
`overlayAnimation`: Overlay에 적용될 애니메이션 className입니다. `showClassName``hideClassName` 두 가지 key-value를 받을 수 있습니다.
34+
35+
`modalAnimation`: Modal에 적용될 애니메이션 className입니다. `showClassName``hideClassName` 두 가지 key-value를 받을 수 있습니다.
36+
37+
## 반환값
38+
39+
`Modal`: 컴포넌트로,해당 컴포넌트로 감싸진 children이 지정한 root에 portal을 통해 렌더링 됩니다.
40+
41+
`show`: 모달을 엽니다.
42+
43+
`hide`: 모달을 닫습니다.
44+
45+
`isShow`: 모달이 열려있는지 상태를 나타냅니다.
46+
47+
```tsx
48+
function Modal() {
49+
const { Modal, show, isShow, hide } = useModal({
50+
modalAnimation: {
51+
showClassName: showStyle,
52+
hideClassName: hideStyle,
53+
},
54+
overlayAnimation: {
55+
showClassName: overlayShow,
56+
hideClassName: overlayHide,
57+
},
58+
});
59+
60+
const handleClick = () => {
61+
if (isShow) hide();
62+
show();
63+
};
64+
65+
return (
66+
<div>
67+
<button onClick={handleClick}>{isShow ? 'hide' : 'show'}</button>
68+
<Modal overlayClassName={Overlay} modalClassName={ModalContainer}>
69+
<div>모달!</div>
70+
<button onClick={hide}>닫기</button>
71+
</Modal>
72+
</div>
73+
);
74+
}
75+
```
76+
77+
<Canvas of={Modal.defaultStory} />

src/stories/useModal/Modal.css.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { keyframes, style } from '@vanilla-extract/css';
2+
3+
export const Overlay = style({
4+
position: 'fixed',
5+
top: 0,
6+
left: 0,
7+
right: 0,
8+
bottom: 0,
9+
background: 'rgba(0, 0, 0, 0.5)',
10+
display: 'flex',
11+
justifyContent: 'center',
12+
alignItems: 'center',
13+
});
14+
15+
export const ModalContainer = style({
16+
backgroundColor: 'white',
17+
padding: '30px 60px 30px 60px',
18+
borderRadius: 25,
19+
display: 'flex',
20+
justifyContent: 'center',
21+
alignItems: 'center',
22+
flexDirection: 'column',
23+
gap: 10,
24+
});
25+
26+
const showKeyframe = keyframes({
27+
from: {
28+
opacity: 0,
29+
transform: 'scale(0)',
30+
},
31+
to: {
32+
opacity: 1,
33+
transform: 'scale(1)',
34+
},
35+
});
36+
37+
const hideKeyframe = keyframes({
38+
from: {
39+
opacity: 1,
40+
transform: ' scale(1)',
41+
},
42+
to: {
43+
opacity: 0,
44+
transform: 'scale(0)',
45+
},
46+
});
47+
const overlayShowKeyframe = keyframes({
48+
from: {
49+
opacity: 0,
50+
},
51+
to: {
52+
opacity: 1,
53+
},
54+
});
55+
56+
const overlayHideKeyframe = keyframes({
57+
from: {
58+
opacity: 1,
59+
},
60+
to: {
61+
opacity: 0,
62+
},
63+
});
64+
65+
export const showStyle = style({
66+
animation: `${showKeyframe} 500ms forwards`,
67+
});
68+
69+
export const hideStyle = style({
70+
animation: `${hideKeyframe} 500ms forwards`,
71+
});
72+
73+
export const overlayShow = style({
74+
animation: `${overlayShowKeyframe} 500ms forwards`,
75+
});
76+
export const overlayHide = style({
77+
animation: `${overlayHideKeyframe} 500ms forwards`,
78+
});

src/stories/useModal/Modal.stories.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { Meta, StoryObj } from '@storybook/react';
2+
import Modal from './Modal';
3+
4+
const meta = {
5+
title: 'hooks/useModal',
6+
component: Modal,
7+
parameters: {
8+
layout: 'centered',
9+
docs: {
10+
canvas: {},
11+
},
12+
},
13+
} satisfies Meta<typeof Modal>;
14+
15+
export default meta;
16+
17+
type Story = StoryObj<typeof meta>;
18+
19+
export const defaultStory: Story = {
20+
args: {},
21+
};

src/stories/useModal/Modal.tsx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import useModal from '@/useModal/useModal';
2+
import React from 'react';
3+
import { ModalContainer, Overlay, hideStyle, overlayHide, overlayShow, showStyle } from './Modal.css';
4+
5+
export default function Modal() {
6+
const { Modal, show, isShow, hide } = useModal({
7+
modalAnimation: {
8+
showClassName: showStyle,
9+
hideClassName: hideStyle,
10+
},
11+
overlayAnimation: {
12+
showClassName: overlayShow,
13+
hideClassName: overlayHide,
14+
},
15+
});
16+
17+
const handleClick = () => {
18+
if (isShow) hide();
19+
show();
20+
};
21+
22+
return (
23+
<div>
24+
<button onClick={handleClick}>{isShow ? 'hide' : 'show'}</button>
25+
<Modal overlayClassName={Overlay} modalClassName={ModalContainer}>
26+
<div>모달!</div>
27+
<button
28+
style={{
29+
fontSize: 10,
30+
}}
31+
onClick={hide}
32+
>
33+
닫기
34+
</button>
35+
</Modal>
36+
</div>
37+
);
38+
}

src/useAnimation/useAnimation.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { CSSProperties, ReactNode, useState } from 'react';
1+
import React, { useState } from 'react';
22

33
export function _useAnimation(mountAnimationClassName?: string, unmountAnimationClassName?: string, unmountCallback?: () => void) {
44
const [animationClassName, setAnimationClassName] = useState<string | undefined>(mountAnimationClassName);
@@ -31,10 +31,10 @@ export default function useAnimation({ mountClassName, unmountClassName }: { mou
3131
const show = () => setIsShow(true);
3232
const hide = () => triggerUnmountAnimation();
3333

34-
const AnimationWrapper = ({ children, style, className }: { children: ReactNode; style?: CSSProperties; className?: string }) => {
34+
const AnimationWrapper = ({ children, className, ...rest }: { className?: string } & React.ComponentProps<'div'>) => {
3535
return (
3636
isShow && (
37-
<div className={`${animationClassName} ${className}`} onAnimationEnd={handleUnmountAnimationEnd} style={style}>
37+
<div className={`${animationClassName} ${className}`} onAnimationEnd={handleUnmountAnimationEnd} {...rest}>
3838
{children}
3939
</div>
4040
)

src/useBoolean/_useBoolean.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { renderHook, act } from '@testing-library/react';
33

44
describe('useBoolean 기능테스트', () => {
55
it('useBoolean은 boolean상태를 나타내는 값과 그 boolean을 변경할 수 있는 값을 배열로 반환한다.', () => {
6-
const { result } = renderHook(() => useBoolean(false));
6+
const { result } = renderHook(() => useBoolean());
77

88
expect(result.current[0]).toBe(false);
99
act(() => {

src/useDisclosure/_useDisclosure.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { renderHook, act } from '@testing-library/react';
33

44
describe('useDisclosure 기능테스트', () => {
55
it('useDisclosure는 modal, disclosure와 같이 컴포넌트의 열림과 닫힘 상태를 조절할 수 있는 기능들을 반환한다.', () => {
6-
const { result } = renderHook(() => useDisclosure(false));
6+
const { result } = renderHook(() => useDisclosure());
77

88
expect(result.current.isOpen).toBe(false);
99
act(() => {

0 commit comments

Comments
 (0)