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

[Feature] - 모달 컴포넌트 구현 #200

Merged
merged 8 commits into from
Aug 3, 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
1 change: 1 addition & 0 deletions frontend/src/assets/svg/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ export { default as ImageUpload } from "./image-upload.svg";
export { default as KaKao } from "./kakao.svg";
export { default as KoreanLogo } from "./korean-logo.svg";
export { default as Plus } from "./plus.svg";
export { default as tturiUrl } from "./tturi.svg?url";
10 changes: 5 additions & 5 deletions frontend/src/components/common/Icon/svg-icons.json
Original file line number Diff line number Diff line change
Expand Up @@ -201,9 +201,9 @@
"width": 11,
"height": 11,
"path": "M1.1002 11L0 9.9L4.40079 5.5L0 1.1L1.1002 0L5.50098 4.4L9.90177 0L11.002 1.1L6.60118 5.5L11.002 9.9L9.90177 11L5.50098 6.6L1.1002 11Z",
"stroke": "currentColor",
"strokeWidth": "1",
"strokeLinecap": "butt",
"strokeLinejoin": "miter"
"stroke": "",
"strokeWidth": "0",
"strokeLinecap": "",
"strokeLinejoin": ""
}
}
}
6 changes: 6 additions & 0 deletions frontend/src/components/common/Input/Input.styled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ export const Input = styled.input`
outline: none;
}

&:disabled {
background-color: ${({ theme }) => theme.colors.background.disabled};

color: ${({ theme }) => theme.colors.text.secondary};
}

&::placeholder {
color: ${({ theme }) => theme.colors.text.secondary};
}
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/common/Input/Input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import * as S from "./Input.styled";
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
count?: number;
maxCount?: number;
label: string;
label?: string;
}

const Input = ({ label, count, maxCount, ...props }: InputProps) => {
Expand Down
123 changes: 123 additions & 0 deletions frontend/src/components/common/Modal/Modal.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { useState } from "react";

import type { Meta } from "@storybook/react";
import { fn } from "@storybook/test";

import Button from "@components/common/Button/Button";
import Input from "@components/common/Input/Input";
import Text from "@components/common/Text/Text";

import theme from "@styles/theme";

import Modal from "./Modal";

const meta = {
title: "common/Modal",
component: Modal,
parameters: {
layout: "fullscreen",
viewport: {
defaultViewport: "desktop",
},
controls: { disable: true },
},
args: { onCloseModal: fn() },
} satisfies Meta<typeof Modal>;

export default meta;

export const ShareModal = {
render: () => {
const [isOpen, setIsOpen] = useState(false);
const onToggleModal = () => setIsOpen((prev) => !prev);

return (
<>
<Button onClick={onToggleModal} variants="primary">
모달 열기
</Button>
{isOpen && (
<Modal isOpen={isOpen} onCloseModal={onToggleModal}>
<Modal.Header />
<Modal.Body direction="column" style={{ gap: "1.6rem" }}>
<img
style={{ width: "11rem", height: "12.5rem" }}
src="https://github.com/user-attachments/assets/e37a2008-976f-4f08-9372-f9c144890529"
/>

<Text textType="body" style={{ fontWeight: 700 }}>
여행기를 공유할까요?
</Text>
<div style={{ width: "29rem", position: "relative" }}>
<Input
disabled
value={"https://touroot.kr/travel-plans/a932jdfnd3"}
style={{ outline: "none", border: "none" }}
/>
<Button
variants="primary"
style={{
width: "4rem",
height: "2.4rem",
position: "absolute",
right: "1rem",
top: "1.6rem",
fontSize: "1.2rem",
}}
>
복사
</Button>
</div>
</Modal.Body>
</Modal>
)}
</>
);
},
};

export const TravelPlanDeleteModal = {
render: () => {
const [isOpen, setIsOpen] = useState(false);
const onToggleModal = () => setIsOpen((prev) => !prev);

return (
<>
<Button onClick={onToggleModal} variants="primary">
모달 열기
</Button>
{isOpen && (
<Modal isOpen={isOpen} onCloseModal={onToggleModal}>
<Modal.Header />
<Modal.Body direction="column" style={{ gap: "1.6rem", padding: "2.6rem 0" }}>
<img
style={{ width: "6rem", height: "7rem" }}
src="https://github.com/user-attachments/assets/e37a2008-976f-4f08-9372-f9c144890529"
/>

<div
style={{
display: "flex",
flexDirection: "column",
gap: "0.8rem",
justifyContent: "center",
alignItems: "center",
}}
>
<Text textType="body" style={{ fontWeight: 700 }}>
여행 계획을 삭제할까요?
</Text>
<Text textType="detail" style={{ color: theme.colors.text.secondary }}>
삭제한 후에는 여행 계획을 다시 복구할 수 없어요.
</Text>
</div>
</Modal.Body>
<Modal.Footer>
<Button variants="primary">삭제</Button>
</Modal.Footer>
</Modal>
)}
</>
);
},
};
36 changes: 36 additions & 0 deletions frontend/src/components/common/Modal/Modal.style.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import styled from "@emotion/styled";

import { PRIMITIVE_COLORS } from "@styles/tokens";

export const Layout = styled.section`
display: flex;
justify-content: center;
align-items: center;
position: fixed;
inset: 0;
z-index: 1000;
`;

export const BackdropLayout = styled.div`
position: absolute;
width: 100%;
height: 100%;

background-color: ${({ theme }) => theme.colors.dimmed};
cursor: pointer;
`;

export const ModalBoxLayout = styled.div`
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
width: calc(100% - ${({ theme }) => theme.spacing.m} * 2);
max-height: 80vh;
max-width: calc(48rem - ${({ theme }) => theme.spacing.m} * 2);
margin: ${({ theme }) => theme.spacing.m};
border-radius: ${({ theme }) => theme.spacing.s};

background-color: ${PRIMITIVE_COLORS.white};
box-shadow: 0 0 5px rgb(0 0 0 / 15%);
`;
37 changes: 37 additions & 0 deletions frontend/src/components/common/Modal/Modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import React from "react";
import ReactDOM from "react-dom";

import ModalProvider from "@contexts/ModalProvider";

import ModalBody from "@components/common/Modal/ModalBody/ModalBody";
import ModalFooter from "@components/common/Modal/ModalFooter/ModalFooter";
import ModalHeader from "@components/common/Modal/ModalHeader/ModalHeader";

import useModalControl from "@hooks/useModalControl";

import * as S from "./Modal.style";

interface ModalProps extends React.PropsWithChildren {
onCloseModal: () => void;
isOpen: boolean;
}

const Modal = ({ children, onCloseModal, isOpen }: ModalProps) => {
useModalControl(isOpen, onCloseModal);

return ReactDOM.createPortal(
<ModalProvider onCloseModal={onCloseModal}>
<S.Layout>
<S.BackdropLayout onClick={onCloseModal} />
<S.ModalBoxLayout>{children}</S.ModalBoxLayout>
</S.Layout>
</ModalProvider>,
document.querySelector("#root") as HTMLElement,
);
};

export default Modal;

Modal.Header = ModalHeader;
Modal.Body = ModalBody;
Modal.Footer = ModalFooter;
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import styled from "@emotion/styled";

export const Layout = styled.div<{ $direction: React.CSSProperties["flexDirection"] }>`
display: flex;
flex: 1;
flex-direction: ${({ $direction }) => $direction};
justify-content: center;
align-items: center;
width: 100%;
overflow-y: auto;
scrollbar-width: thin;
-ms-overflow-style: none;

&::-webkit-scrollbar {
width: 6px;
}

&::-webkit-scrollbar-thumb {
border-radius: ${({ theme }) => theme.spacing.s};

background-color: ${({ theme }) => theme.colors.border};
}
`;
17 changes: 17 additions & 0 deletions frontend/src/components/common/Modal/ModalBody/ModalBody.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { HTMLAttributes } from "react";

import * as S from "./ModalBody.styled";

interface ModalBodyProps extends React.PropsWithChildren, HTMLAttributes<HTMLDivElement> {
direction?: React.CSSProperties["flexDirection"];
}

const ModalBody = ({ children, direction = "row", ...props }: ModalBodyProps) => {
return (
<S.Layout $direction={direction} {...props}>
{children}
</S.Layout>
);
};

export default ModalBody;
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import styled from "@emotion/styled";

export const Layout = styled.div<{ $direction: React.CSSProperties["flexDirection"] }>`
display: flex;
flex-direction: ${({ $direction }) => $direction};
width: 100%;
padding: ${({ theme }) => theme.spacing.m};
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import * as S from "./ModalFooter.styled";

export interface ModalFooterProps extends React.PropsWithChildren {
direction?: React.CSSProperties["flexDirection"];
}

const ModalFooter = ({ children, direction = "row" }: ModalFooterProps) => {
return <S.Layout $direction={direction}>{children}</S.Layout>;
};

export default ModalFooter;
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import styled from "@emotion/styled";

export const Layout = styled.header`
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.6rem;
`;

export const TitleWrapper = styled.div`
flex-grow: 1;

text-align: center;
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { useModalContext } from "@contexts/ModalProvider";

import IconButton from "@components/common/IconButton/IconButton";

import * as S from "./ModalHeader.styled";

interface ModalHeaderProps extends React.PropsWithChildren {
hasCloseIcon?: boolean;
buttonPosition?: "left" | "right";
}

const ModalHeader = ({
children,
hasCloseIcon = true,
buttonPosition = "right",
}: ModalHeaderProps) => {
const onCloseModal = useModalContext();
return (
<S.Layout>
{buttonPosition === "left" && hasCloseIcon && (
<IconButton onClick={onCloseModal} size="12" iconType="x-icon" />
)}
<S.TitleWrapper>{children}</S.TitleWrapper>
{buttonPosition === "right" && hasCloseIcon && (
<IconButton onClick={onCloseModal} size="12" iconType="x-icon" />
)}
</S.Layout>
);
};

export default ModalHeader;
25 changes: 25 additions & 0 deletions frontend/src/contexts/ModalProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { createContext, useContext } from "react";

import { ERROR_MESSAGE_MAP } from "@constants/errorMessage";

export const OnCloseModalContext = createContext(() => {});

export interface UserProviderProps extends React.PropsWithChildren {
onCloseModal: () => void;
}

const ModalProvider = ({ children, onCloseModal }: UserProviderProps) => {
return (
<OnCloseModalContext.Provider value={onCloseModal}>{children}</OnCloseModalContext.Provider>
);
};

export default ModalProvider;

export const useModalContext = () => {

Check warning on line 19 in frontend/src/contexts/ModalProvider.tsx

View workflow job for this annotation

GitHub Actions / frontend-ci

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
const onCloseModal = useContext(OnCloseModalContext);

if (!onCloseModal) throw new Error(ERROR_MESSAGE_MAP.provider);

return onCloseModal;
};
21 changes: 21 additions & 0 deletions frontend/src/hooks/useModalControl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { useEffect } from 'react';
import usePressESC from '@hooks/usePressESC';

/**
* modal control에 관한 훅입니다.
* 1. 사용자가 esc를 눌렀을 때 모달이 닫힌다.
* 2. 사용자가 모달을 열었을 때 외부 스크롤을 하지 못하도록 막는다.
*/
const useModalControl = <T extends (...args: unknown[]) => void>(isOpen: boolean, onToggle: T) => {
usePressESC(isOpen, onToggle);

useEffect(() => {
if (isOpen) document.body.style.overflow = 'hidden';

return () => {
document.body.style.overflow = 'auto';
};
}, [isOpen]);
};

export default useModalControl;
Loading
Loading