diff --git a/README.md b/README.md index ed69923..a0eb988 100644 --- a/README.md +++ b/README.md @@ -328,3 +328,44 @@ const SomeComponent = () => { ); }; ``` + +### useModal + +A hook for easily managing an animated modal through a portal. + +#### Function Arguments + +modalProps object is accepted. This object is structured as follows: + +```ts +interface UseModalProps { + modalRoot?: ModalRoot; + overlayClose?: boolean; + overlayAnimation?: { + showClassName?: string; + hideClassName?: string; + }; + modalAnimation?: { + showClassName?: string; + hideClassName?: string; + }; +} +``` + +`modalRoot`: The HTMLElement where the modal will be rendered. The default is `document.body`. + +`overlayClose`: Sets whether clicking on the overlay closes the modal. The default is `true`. + +`overlayAnimation`: The animation className applied to the overlay. It can accept two key-value pairs: `showClassName` and `hideClassName`. + +`modalAnimation`: The animation className applied to the modal. It can accept two key-value pairs: `showClassName` and `hideClassName`. + +#### Return Values + +`Modal`: A component that renders its children to the specified root through a portal. + +`show`: Opens the modal. + +`hide`: Closes the modal. + +`isShow`: Indicates whether the modal is open. diff --git a/src/index.ts b/src/index.ts index 621c9bd..7410274 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ import useThrottle from './useThrottle/useThrottle'; import useDebounce from './useDebounce/useDebounce'; import useLocalStorage from './useLocalStorage/useLocalStorage'; import useDisclosure from './useDisclosure/useDisclosure'; +import useModal from './useModal/useModal'; export { useInput, @@ -26,4 +27,5 @@ export { useDebounce, useLocalStorage, useDisclosure, + useModal, }; diff --git a/src/stories/useModal/Docs.mdx b/src/stories/useModal/Docs.mdx new file mode 100644 index 0000000..6bbf053 --- /dev/null +++ b/src/stories/useModal/Docs.mdx @@ -0,0 +1,77 @@ +import { Canvas, Meta, Description } from '@storybook/blocks'; +import * as Modal from './Modal.stories'; + + + +# useModal + +애니메이션이 적용된 Modal을 portal을 통해 간편하게 관리하기 위한 훅입니다. + +## 함수 인자 + +modalProps객체를 받습니다. 해당 객체는 아래와 같이 구성됩니다. + +```ts +interface UseModalProps { + modalRoot?: ModalRoot; + overlayClose?: boolean; + overlayAnimation?: { + showClassName?: string; + hideClassName?: string; + }; + modalAnimation?: { + showClassName?: string; + hideClassName?: string; + }; +} +``` + +`modalRoot`: 모달을 렌더링할 HTMLElement입니다. default는 `document.body`입니다. + +`overlayClose`: overlay를 눌러 modal을 닫을지를 설정합니다. default는 `true`입니다. + +`overlayAnimation`: Overlay에 적용될 애니메이션 className입니다. `showClassName`과 `hideClassName` 두 가지 key-value를 받을 수 있습니다. + +`modalAnimation`: Modal에 적용될 애니메이션 className입니다. `showClassName`과 `hideClassName` 두 가지 key-value를 받을 수 있습니다. + +## 반환값 + +`Modal`: 컴포넌트로,해당 컴포넌트로 감싸진 children이 지정한 root에 portal을 통해 렌더링 됩니다. + +`show`: 모달을 엽니다. + +`hide`: 모달을 닫습니다. + +`isShow`: 모달이 열려있는지 상태를 나타냅니다. + +```tsx +function Modal() { + const { Modal, show, isShow, hide } = useModal({ + modalAnimation: { + showClassName: showStyle, + hideClassName: hideStyle, + }, + overlayAnimation: { + showClassName: overlayShow, + hideClassName: overlayHide, + }, + }); + + const handleClick = () => { + if (isShow) hide(); + show(); + }; + + return ( +
+ + +
모달!
+ +
+
+ ); +} +``` + + diff --git a/src/stories/useModal/Modal.css.ts b/src/stories/useModal/Modal.css.ts new file mode 100644 index 0000000..6d1959c --- /dev/null +++ b/src/stories/useModal/Modal.css.ts @@ -0,0 +1,78 @@ +import { keyframes, style } from '@vanilla-extract/css'; + +export const Overlay = style({ + position: 'fixed', + top: 0, + left: 0, + right: 0, + bottom: 0, + background: 'rgba(0, 0, 0, 0.5)', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', +}); + +export const ModalContainer = style({ + backgroundColor: 'white', + padding: '30px 60px 30px 60px', + borderRadius: 25, + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + flexDirection: 'column', + gap: 10, +}); + +const showKeyframe = keyframes({ + from: { + opacity: 0, + transform: 'scale(0)', + }, + to: { + opacity: 1, + transform: 'scale(1)', + }, +}); + +const hideKeyframe = keyframes({ + from: { + opacity: 1, + transform: ' scale(1)', + }, + to: { + opacity: 0, + transform: 'scale(0)', + }, +}); +const overlayShowKeyframe = keyframes({ + from: { + opacity: 0, + }, + to: { + opacity: 1, + }, +}); + +const overlayHideKeyframe = keyframes({ + from: { + opacity: 1, + }, + to: { + opacity: 0, + }, +}); + +export const showStyle = style({ + animation: `${showKeyframe} 500ms forwards`, +}); + +export const hideStyle = style({ + animation: `${hideKeyframe} 500ms forwards`, +}); + +export const overlayShow = style({ + animation: `${overlayShowKeyframe} 500ms forwards`, +}); +export const overlayHide = style({ + animation: `${overlayHideKeyframe} 500ms forwards`, +}); diff --git a/src/stories/useModal/Modal.stories.ts b/src/stories/useModal/Modal.stories.ts new file mode 100644 index 0000000..08e91c3 --- /dev/null +++ b/src/stories/useModal/Modal.stories.ts @@ -0,0 +1,21 @@ +import { Meta, StoryObj } from '@storybook/react'; +import Modal from './Modal'; + +const meta = { + title: 'hooks/useModal', + component: Modal, + parameters: { + layout: 'centered', + docs: { + canvas: {}, + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const defaultStory: Story = { + args: {}, +}; diff --git a/src/stories/useModal/Modal.tsx b/src/stories/useModal/Modal.tsx new file mode 100644 index 0000000..06982e1 --- /dev/null +++ b/src/stories/useModal/Modal.tsx @@ -0,0 +1,38 @@ +import useModal from '@/useModal/useModal'; +import React from 'react'; +import { ModalContainer, Overlay, hideStyle, overlayHide, overlayShow, showStyle } from './Modal.css'; + +export default function Modal() { + const { Modal, show, isShow, hide } = useModal({ + modalAnimation: { + showClassName: showStyle, + hideClassName: hideStyle, + }, + overlayAnimation: { + showClassName: overlayShow, + hideClassName: overlayHide, + }, + }); + + const handleClick = () => { + if (isShow) hide(); + show(); + }; + + return ( +
+ + +
모달!
+ +
+
+ ); +} diff --git a/src/useAnimation/useAnimation.tsx b/src/useAnimation/useAnimation.tsx index 8aa34ac..4b72630 100644 --- a/src/useAnimation/useAnimation.tsx +++ b/src/useAnimation/useAnimation.tsx @@ -1,4 +1,4 @@ -import React, { CSSProperties, ReactNode, useState } from 'react'; +import React, { useState } from 'react'; export function _useAnimation(mountAnimationClassName?: string, unmountAnimationClassName?: string, unmountCallback?: () => void) { const [animationClassName, setAnimationClassName] = useState(mountAnimationClassName); @@ -31,10 +31,10 @@ export default function useAnimation({ mountClassName, unmountClassName }: { mou const show = () => setIsShow(true); const hide = () => triggerUnmountAnimation(); - const AnimationWrapper = ({ children, style, className }: { children: ReactNode; style?: CSSProperties; className?: string }) => { + const AnimationWrapper = ({ children, className, ...rest }: { className?: string } & React.ComponentProps<'div'>) => { return ( isShow && ( -
+
{children}
) diff --git a/src/useBoolean/_useBoolean.test.ts b/src/useBoolean/_useBoolean.test.ts index 0bd7858..9b79935 100644 --- a/src/useBoolean/_useBoolean.test.ts +++ b/src/useBoolean/_useBoolean.test.ts @@ -3,7 +3,7 @@ import { renderHook, act } from '@testing-library/react'; describe('useBoolean 기능테스트', () => { it('useBoolean은 boolean상태를 나타내는 값과 그 boolean을 변경할 수 있는 값을 배열로 반환한다.', () => { - const { result } = renderHook(() => useBoolean(false)); + const { result } = renderHook(() => useBoolean()); expect(result.current[0]).toBe(false); act(() => { diff --git a/src/useDisclosure/_useDisclosure.test.ts b/src/useDisclosure/_useDisclosure.test.ts index 6e4f45a..cea0bf3 100644 --- a/src/useDisclosure/_useDisclosure.test.ts +++ b/src/useDisclosure/_useDisclosure.test.ts @@ -3,7 +3,7 @@ import { renderHook, act } from '@testing-library/react'; describe('useDisclosure 기능테스트', () => { it('useDisclosure는 modal, disclosure와 같이 컴포넌트의 열림과 닫힘 상태를 조절할 수 있는 기능들을 반환한다.', () => { - const { result } = renderHook(() => useDisclosure(false)); + const { result } = renderHook(() => useDisclosure()); expect(result.current.isOpen).toBe(false); act(() => { diff --git a/src/useModal/useModal.test.tsx b/src/useModal/useModal.test.tsx new file mode 100644 index 0000000..89bbf62 --- /dev/null +++ b/src/useModal/useModal.test.tsx @@ -0,0 +1,152 @@ +import useModal from './useModal'; +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; + +const SHOW_CLASSNAME = 'show'; +const HIDE_CLASSNAME = 'hide'; + +describe('useModal 기능 테스트', () => { + it('modal을 열면 portal을 통해 element 내부에 렌더링 할 수 있다.', async () => { + const Test = () => { + const { Modal, show, hide } = useModal(); + return ( +
+ + + +
+
+
+ ); + }; + render(); + fireEvent.click(await screen.findByRole('show')); + expect(await screen.findByRole('test')).toBeTruthy(); + }); + + it('overlayClose가 true인 경우, overlay를 눌러 modal을 닫을 수 있다.', async () => { + const Test = () => { + const { Modal, show, hide } = useModal(); + return ( +
+ + + +
+ +
+
+ ); + }; + render(); + fireEvent.click(await screen.findByRole('show')); + expect(await screen.findByRole('test')).toBeTruthy(); + fireEvent.click(await screen.findByRole('hide')); + await expect(screen.findByRole('test')).rejects.toThrow(); + + fireEvent.click(await screen.findByRole('show')); + expect(await screen.findByRole('test')).toBeTruthy(); + fireEvent.click(await screen.findByRole('modal-hide')); + await expect(screen.findByRole('test')).rejects.toThrow(); + + fireEvent.click(await screen.findByRole('show')); + const modalInner = await screen.findByRole('test'); + fireEvent.click(modalInner!.parentElement!.parentElement!); + await expect(screen.findByRole('test')).rejects.toThrow(); + }); + + it('overlayClose가 false인 경우, overlay를 누르면 modal이 닫히지 않는다.', async () => { + const Test = () => { + const { Modal, show, hide } = useModal({ overlayClose: false }); + return ( +
+ + + +
+ +
+
+ ); + }; + render(); + + fireEvent.click(await screen.findByRole('show')); + expect(await screen.findByRole('test')).toBeTruthy(); + const modalInner = await screen.findByRole('test'); + fireEvent.click(modalInner!.parentElement!.parentElement!); + await expect(screen.findByRole('test')).toBeTruthy(); + }); + + it('지정한 element에 modal을 렌더링할 수 있다.', async () => { + const testRoot = document.createElement('div'); + testRoot.id = 'test-root'; + document.body.appendChild(testRoot); + + const Test = () => { + const { Modal, show, hide } = useModal({ + modalRoot: testRoot, + }); + return ( +
+ + + +
+
+
+ ); + }; + render(); + fireEvent.click(await screen.findByRole('show')); + expect(testRoot.querySelector('#test')).toBeTruthy(); + }); + + it('modal에 animation이 적용된 경우, className을 열고 닫을 때 변경할 수 있다.', async () => { + const Test = () => { + const { Modal, show, hide } = useModal({ + modalAnimation: { + showClassName: SHOW_CLASSNAME, + hideClassName: HIDE_CLASSNAME, + }, + }); + return ( +
+ + + +
+
+
+ ); + }; + render(); + fireEvent.click(await screen.findByRole('show')); + const modalInner = await screen.findByRole('test'); + expect(modalInner.parentElement?.className).toContain(SHOW_CLASSNAME); + }); +}); diff --git a/src/useModal/useModal.tsx b/src/useModal/useModal.tsx new file mode 100644 index 0000000..c630762 --- /dev/null +++ b/src/useModal/useModal.tsx @@ -0,0 +1,65 @@ +import React, { CSSProperties, ReactNode } from 'react'; +import { createPortal } from 'react-dom'; +import { useAnimation } from '..'; +type ModalRoot = Element | DocumentFragment; + +export interface UseModalAnimations { + overlayAnimation?: { + showClassName?: string; + hideClassName?: string; + }; + modalAnimation?: { + showClassName?: string; + hideClassName?: string; + }; +} + +interface UseModalProps extends UseModalAnimations { + modalRoot?: ModalRoot; + overlayClose?: boolean; +} + +interface ModalProps { + overlayClassName?: string; + modalClassName?: string; + style?: CSSProperties; + children: ReactNode; +} + +const DefaultModal = ({ children, modalRoot }: { children: ReactNode; modalRoot?: ModalRoot }) => { + return createPortal(children, modalRoot || document.body); +}; + +export default function useModal(modalProps?: UseModalProps) { + const overlayClose = modalProps?.overlayClose || true; + const ModalAnimation = useAnimation({ + mountClassName: modalProps?.modalAnimation?.showClassName, + unmountClassName: modalProps?.modalAnimation?.hideClassName, + }); + const OverlayAnimation = useAnimation({ + mountClassName: modalProps?.overlayAnimation?.showClassName, + unmountClassName: modalProps?.overlayAnimation?.hideClassName, + }); + + const show = () => { + OverlayAnimation.show(); + ModalAnimation.show(); + }; + + const hide = () => { + OverlayAnimation.hide(); + ModalAnimation.hide(); + }; + + const Modal = ({ children, overlayClassName, modalClassName, style }: ModalProps) => ( + + + e.stopPropagation()}> + {children} + + + + ); + + return { Modal, show, hide, isShow: OverlayAnimation.isShow }; +} diff --git a/src/useSwitch/_useSwitch.test.ts b/src/useSwitch/_useSwitch.test.ts index 89f81ba..75b0b2c 100644 --- a/src/useSwitch/_useSwitch.test.ts +++ b/src/useSwitch/_useSwitch.test.ts @@ -3,7 +3,7 @@ import { renderHook, act } from '@testing-library/react'; describe('useSwitch 기능테스트', () => { it('useSwitch는 스위치(혹은 토글)의 켜짐 상태와 켜짐상태를 조절할 수 있는 함수들을 반환한다.', () => { - const { result } = renderHook(() => useSwitch(false)); + const { result } = renderHook(() => useSwitch()); expect(result.current.isOn).toBe(false); act(() => {