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

공통 컴포넌트: Modal #42

Merged
merged 11 commits into from
Jan 7, 2024
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
"jsdom": "^23.0.1",
"sass": "^1.69.6",
"storybook": "^7.6.6",
"storybook-react-context": "^0.6.0",
"stylelint": "^16.1.0",
"stylelint-config-property-sort-order-smacss": "^10.0.0",
"stylelint-config-standard-scss": "^12.0.0",
Expand Down
4 changes: 4 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
--black: #090A0A;
--white: #FFF;
--pink: #F67272;

/* -------------- z-index -------------- */
--dimmed-zindex: 10;
--modal-zindex: 11;
}

body {
Expand Down
6 changes: 5 additions & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import localFont from 'next/font/local';

import './globals.css';

import { ModalContextProvider } from '@contexts/ModalContext';
import StoreProvider from '@providers/StoreProvider';
import TanstackQueryProvider from '@providers/TanstackQueryProvider';

Expand Down Expand Up @@ -32,9 +33,12 @@ export default function RootLayout({
<body className={pretendard.className}>
<TanstackQueryProvider>
<StoreProvider>
{children}
<ModalContextProvider>
{children}
</ModalContextProvider>
</StoreProvider>
</TanstackQueryProvider>
<div id="portal-root" />
</body>
</html>
);
Expand Down
17 changes: 17 additions & 0 deletions src/components/shared/Modal/Modal.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
.container {
display: flex;
position: fixed;
z-index: var(--modal-zindex);
top: 50%;
left: 50%;
box-sizing: border-box;
flex-direction: column;
align-items: center;
width: 280px;
height: 220px;
padding: 24px;
overflow: hidden;
transform: translate(-50%, -50%);
border-radius: 10px;
background-color: var(--white);
}
47 changes: 47 additions & 0 deletions src/components/shared/Modal/Modal.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { Meta, StoryObj } from '@storybook/react';

// eslint-disable-next-line import/no-extraneous-dependencies
import { withReactContext } from 'storybook-react-context';

import { ModalContext } from '@contexts/ModalContext';

import Modal from './Modal';

const meta = {
title: 'Shared/Modal',
decorators: [
withReactContext({
Context: ModalContext,
initialState: {
open: true,
title: '탈퇴하시겠습니까?',
description: '회원을 탈퇴하면 차량용품 추천 서비스를 제공받을 수 없습니다. 정말로 탈퇴하시겠습니까?',
topButtonLabel: '예',
bottomButtonLabel: '아니오',
onTopButtonClick: () => { },
onBottomButtonClick: () => { },
},
}),
],
component: Modal,
parameters: {
},
tags: ['autodocs'],
argTypes: {
},
} satisfies Meta<typeof Modal>;

export default meta;
type Story = StoryObj<typeof meta>;

export const normal: Story = {
args: {
open: true,
title: '탈퇴하시겠습니까?',
description: '회원을 탈퇴하면 차량용품 추천 서비스를 제공받을 수 없습니다. 정말로 탈퇴하시겠습니까?',
topButtonLabel: '예',
bottomButtonLabel: '아니오',
onTopButtonClick: () => { },
onBottomButtonClick: () => { },
},
};
53 changes: 53 additions & 0 deletions src/components/shared/Modal/Modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
'use client';

import classNames from 'classnames/bind';

// eslint-disable-next-line import/no-cycle
import useModal from '@contexts/ModalContext';
import useOutsideClick from '@hooks/useOutsideClick';
import Button from '@shared/button/Button';
import Dimmed from '@shared/dimmed/Dimmed';
import Spacing from '@shared/spacing/Spacing';
import Text from '@shared/text/Text';

import styles from './Modal.module.scss';

const cx = classNames.bind(styles);

interface ModalProps {
open: boolean
title: React.ReactNode
description: React.ReactNode
topButtonLabel: React.ReactNode
bottomButtonLabel: React.ReactNode
onTopButtonClick: () => void
onBottomButtonClick: () => void
}

function Modal({
// eslint-disable-next-line max-len
open, title, description, topButtonLabel, bottomButtonLabel, onTopButtonClick, onBottomButtonClick,
}: ModalProps) {
const { close } = useModal();
const modalRef = useOutsideClick(close);

if (open === false) {
return null;
}

return (
<Dimmed>
<div className={cx('container')} ref={modalRef}>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

시맨틱 태그를 위해서 모달 컨텐츠는 article태그나 dialog태그는 어떤가요??

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

레퍼런스 많이 찾아봤는데 div 태그를 많이 사용하시길래 저도 div로 작성했어요

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아하 알겠습니다

<Text bold>{title}</Text>
<Spacing size={8} />
<Text typography="t7" color="gray200">{description}</Text>
<Spacing size={24} />
<Button onClick={onTopButtonClick} full color="secondary">{topButtonLabel}</Button>
<Spacing size={12} />
<Button onClick={onBottomButtonClick} full>{bottomButtonLabel}</Button>
</div>
</Dimmed>
);
}

export default Modal;
6 changes: 6 additions & 0 deletions src/components/shared/dimmed/Dimmed.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.container {
position: fixed;
z-index: var(--dimmed-zindex);
background-color: rgb(0 0 0 / 70%);
inset: 0;
}
15 changes: 15 additions & 0 deletions src/components/shared/dimmed/Dimmed.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import classNames from 'classnames/bind';

import styles from './Dimmed.module.scss';

const cx = classNames.bind(styles);

function Dimmed({ children }: { children: React.ReactNode }) {
return (
<div className={cx('container')}>
{children}
</div>
);
}

export default Dimmed;
76 changes: 76 additions & 0 deletions src/contexts/ModalContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
'use client';

import {
ComponentProps, createContext, useCallback, useMemo, useState, useContext,
} from 'react';
import { createPortal } from 'react-dom';

// eslint-disable-next-line import/no-cycle
import Modal from '@shared/Modal/Modal';

type ModalProps = ComponentProps<typeof Modal>;
type ModalOptions = Omit<ModalProps, 'open'>;

interface ModalContextValue {
open: (options: ModalOptions) => void
close: () => void
}

const defaultValues: ModalProps = {
open: false,
title: null,
description: null,
topButtonLabel: null,
bottomButtonLabel: null,
onTopButtonClick: () => { },
onBottomButtonClick: () => { },
};

export const ModalContext = createContext<ModalContextValue | undefined>(undefined);

export function ModalContextProvider({ children }: { children: React.ReactNode }) {
const [modalState, setModalState] = useState(defaultValues);

const PORTAL_ROOT = typeof window !== 'undefined' ? document.getElementById('portal-root') : null;

const close = useCallback(() => {
setModalState(defaultValues);
}, []);

// eslint-disable-next-line max-len
const open = useCallback(({ onTopButtonClick, onBottomButtonClick, ...options }: ModalOptions) => {
setModalState({
...options,
onTopButtonClick: () => {
close();
onTopButtonClick();
},
onBottomButtonClick: () => {
close();
onBottomButtonClick();
},
open: true,
});
}, [close]);

const values = useMemo(() => { return { open, close }; }, [open, close]);

return (
<ModalContext.Provider value={values}>
{children}
{PORTAL_ROOT != null ? createPortal(<Modal {...modalState} />, PORTAL_ROOT) : null}
</ModalContext.Provider>
);
}

function useModal() {
const values = useContext(ModalContext);

if (values == null) {
throw new Error('ModalContext 내부에서 사용해주세요');
}

return values;
}

export default useModal;
23 changes: 23 additions & 0 deletions src/hooks/useOutsideClick.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { useEffect, useRef } from 'react';

const useOutsideClick = (callback: () => void) => {
const ref = useRef<HTMLDivElement>(null);

useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
callback();
}
};

document.addEventListener('mousedown', handleClickOutside);

return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [callback]);

return ref;
};

export default useOutsideClick;
8 changes: 7 additions & 1 deletion src/stores/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@ import { configureStore } from '@reduxjs/toolkit';

export const makeStore = () => {
return configureStore({
reducer: {},
reducer: {
},
middleware: (getDefaultMiddleware) => {
return getDefaultMiddleware({
serializableCheck: false,
});
},
});
};

Expand Down
10 changes: 10 additions & 0 deletions src/styles/button.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ export const buttonColorMap = {
color: colors.white,
border: `1px solid ${colors.primary}`,
},
secondary: {
backgroundColor: colors.secondary,
color: colors.white,
border: `1px solid ${colors.secondary}`,
},
gray: {
backgroundColor: colors.tertiary,
color: colors.white,
Expand All @@ -19,6 +24,11 @@ export const buttonWeakMap = {
color: colors.primary,
border: `1px solid ${colors.primary}`,
},
secondary: {
backgroundColor: colors.white,
color: colors.secondary,
border: `1px solid ${colors.secondary}`,
},
gray: {
backgroundColor: colors.white,
color: colors.tertiary,
Expand Down
4 changes: 2 additions & 2 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@
"@constants": [
"src/constants/index"
],
"@constants/*": [
"src/constants/*"
"@contexts/*": [
"src/contexts/*"
],
"@models/*": [
"src/models/*"
Expand Down
Loading
Loading