diff --git a/frontend/src/components/common/Drawer/Drawer.styled.ts b/frontend/src/components/common/Drawer/Drawer.styled.ts index de7021e90..93433e435 100644 --- a/frontend/src/components/common/Drawer/Drawer.styled.ts +++ b/frontend/src/components/common/Drawer/Drawer.styled.ts @@ -1,34 +1,41 @@ import styled from "@emotion/styled"; -export const DrawerContainer = styled.div<{ isOpen: boolean }>` - display: flex; - flex-direction: column; +import theme from "@styles/theme"; +import { PRIMITIVE_COLORS } from "@styles/tokens"; + +export const Overlay = styled.div<{ isOpen: boolean }>` + visibility: ${({ isOpen }) => (isOpen ? "visible" : "hidden")}; position: fixed; - top: 0; - right: ${({ isOpen }) => (isOpen ? "0" : "-210px")}; - z-index: ${({ theme }) => theme.zIndex.drawer}; - width: 210px; + z-index: ${({ theme }) => theme.zIndex.drawerOverlay}; + width: 100%; height: 100%; + inset: 0; - background-color: #fff; + background-color: rgb(0 0 0 / 30%); + opacity: ${({ isOpen }) => (isOpen ? 1 : 0)}; - transition: right 0.3s ease-in-out; + animation: ${({ isOpen, theme }) => (isOpen ? theme.animation.fade.in : theme.animation.fade.out)} + ${theme.animation.duration.default} ease-in-out; `; -export const Overlay = styled.div<{ isOpen: boolean }>` +export const DrawerContainer = styled.div<{ isOpen: boolean }>` + display: flex; + flex-direction: column; visibility: ${({ isOpen }) => (isOpen ? "visible" : "hidden")}; position: fixed; top: 0; - left: 0; - z-index: ${({ theme }) => theme.zIndex.drawerOverlay}; - width: 100%; + right: 0; + z-index: ${({ theme }) => theme.zIndex.drawer}; + width: 210px; height: 100%; - background-color: rgb(0 0 0 / 30%); - opacity: ${({ isOpen }) => (isOpen ? 1 : 0)}; - transition: - opacity 0.3s ease-in-out, - visibility 0.3s ease-in-out; + background-color: ${PRIMITIVE_COLORS.white}; + + animation: ${({ isOpen, theme }) => + isOpen + ? theme.animation.slide.in({ from: "100%", to: 0 }) + : theme.animation.slide.out({ from: 0, to: "100%" })} + ${theme.animation.duration.default} ease-in-out; `; export const DrawerHeader = styled.div` @@ -46,9 +53,11 @@ export const DrawerContent = styled.div` `; export const TriggerButton = styled.button` - background: none; border: none; + background-color: none; + font-size: 1.5rem; + cursor: pointer; `; diff --git a/frontend/src/components/common/Drawer/Drawer.tsx b/frontend/src/components/common/Drawer/Drawer.tsx index bc23750a1..c1958792e 100644 --- a/frontend/src/components/common/Drawer/Drawer.tsx +++ b/frontend/src/components/common/Drawer/Drawer.tsx @@ -6,16 +6,19 @@ import DrawerProvider, { useDrawerContext } from "@contexts/DrawerProvider"; import useModalControl from "@hooks/useModalControl"; import usePressESC from "@hooks/usePressESC"; import useToggle from "@hooks/useToggle"; +import useUnmountAnimation from "@hooks/useUnmountAnimation"; import VisuallyHidden from "../VisuallyHidden/VisuallyHidden"; import * as S from "./Drawer.styled"; const Drawer = ({ children }: React.PropsWithChildren) => { const [isOpen, , , toggle] = useToggle(); - usePressESC(isOpen, toggle); + usePressESC(isOpen, toggle); useModalControl(isOpen, toggle); + const { isRendered } = useUnmountAnimation({ isOpen }); + let headerContent: React.ReactNode | null = null; let drawerContent: React.ReactNode | null = null; const otherContent: React.ReactNode[] = []; @@ -38,13 +41,16 @@ const Drawer = ({ children }: React.PropsWithChildren) => { {isOpen ? "사용자 메뉴가 열렸습니다." : "사용자 메뉴가 닫혔습니다."} {otherContent} - - {isOpen && + + {isRendered && ReactDOM.createPortal( - - {headerContent} - {drawerContent} - , + <> + + + {headerContent} + {drawerContent} + + , document.body, )} diff --git a/frontend/src/components/common/FloatingButton/FloatingButton.styled.ts b/frontend/src/components/common/FloatingButton/FloatingButton.styled.ts index a5dda5445..189315700 100644 --- a/frontend/src/components/common/FloatingButton/FloatingButton.styled.ts +++ b/frontend/src/components/common/FloatingButton/FloatingButton.styled.ts @@ -1,6 +1,7 @@ import { css } from "@emotion/react"; import styled from "@emotion/styled"; +import theme from "@styles/theme"; import { PRIMITIVE_COLORS } from "@styles/tokens"; export const FloatingButtonContainer = styled.div` @@ -13,22 +14,27 @@ export const FloatingButtonContainer = styled.div` z-index: ${({ theme }) => theme.zIndex.floating}; `; -export const BackdropLayout = styled.div` +export const BackdropLayout = styled.div<{ $isOpen: boolean }>` + visibility: ${({ $isOpen }) => ($isOpen ? "visible" : "hidden")}; position: fixed; width: 100%; height: 100%; - inset: 0; background-color: ${({ theme }) => theme.colors.dimmed}; + + animation: ${({ $isOpen, theme }) => + $isOpen ? theme.animation.fade.in : theme.animation.fade.out} + ${theme.animation.duration.default} ease-in-out; + inset: 0; cursor: pointer; `; export const SubButtonContainer = styled.div<{ $isOpen: boolean }>` display: flex; flex-direction: column; + visibility: ${({ $isOpen }) => ($isOpen ? "visible" : "hidden")}; position: absolute; bottom: 100%; - gap: ${({ theme }) => theme.spacing.l}; width: 16rem; padding: ${({ theme }) => theme.spacing.l} ${({ theme }) => theme.spacing.m}; @@ -36,13 +42,13 @@ export const SubButtonContainer = styled.div<{ $isOpen: boolean }>` background-color: ${PRIMITIVE_COLORS.gray[700]}; - transition: all 0.3s ease-out; - - ${({ $isOpen }) => css` - opacity: ${$isOpen ? 1 : 0}; - visibility: ${$isOpen ? "visible" : "hidden"}; - transform: translateY(${$isOpen ? -0.8 : 2}rem); - `} + animation: ${({ $isOpen, theme }) => + $isOpen + ? theme.animation.slide.up({ from: 2, to: -0.8 }) + : theme.animation.slide.down({ from: -0.8, to: 2 })} + ${theme.animation.duration.default} ease-in-out; + gap: ${({ theme }) => theme.spacing.l}; + animation-fill-mode: forwards; `; export const SubButton = styled.button` diff --git a/frontend/src/components/common/FloatingButton/FloatingButton.tsx b/frontend/src/components/common/FloatingButton/FloatingButton.tsx index 7cf2a6eec..e50e2b174 100644 --- a/frontend/src/components/common/FloatingButton/FloatingButton.tsx +++ b/frontend/src/components/common/FloatingButton/FloatingButton.tsx @@ -2,6 +2,7 @@ import { useNavigate } from "react-router-dom"; import useModalControl from "@hooks/useModalControl"; import useToggle from "@hooks/useToggle"; +import useUnmountAnimation from "@hooks/useUnmountAnimation"; import { removeEmoji } from "@utils/removeEmojis"; @@ -23,6 +24,7 @@ const FloatingButton = () => { }; useModalControl(isOpen, handleToggleButton); + const { isRendered } = useUnmountAnimation({ isOpen }); return ( @@ -31,9 +33,9 @@ const FloatingButton = () => { ? "여행기 및 여행 계획 작성 메뉴가 열렸습니다. 닫으려면 esc버튼을 눌러주세요." : "여행기 및 여행 계획 작성 메뉴가 닫혔습니다."} - {isOpen && ( + {isRendered && ( <> - + {SUB_BUTTONS.map(({ text, route }) => ( diff --git a/frontend/src/components/common/Icon/Icon.styled.ts b/frontend/src/components/common/Icon/Icon.styled.ts new file mode 100644 index 000000000..d81b48623 --- /dev/null +++ b/frontend/src/components/common/Icon/Icon.styled.ts @@ -0,0 +1,7 @@ +import styled from "@emotion/styled"; + +export const Wrapper = styled.div<{ size: string }>` + display: flex; + align-items: center; + height: ${({ size }) => size}px; +`; diff --git a/frontend/src/components/common/Icon/Icon.tsx b/frontend/src/components/common/Icon/Icon.tsx index 20264f387..a6f1fef59 100644 --- a/frontend/src/components/common/Icon/Icon.tsx +++ b/frontend/src/components/common/Icon/Icon.tsx @@ -1,5 +1,6 @@ import { StrokeLineCap, StrokeLineJoin } from "@components/common/Icon/Icon.type"; +import * as S from "./Icon.styled"; import SVG_ICONS_MAP from "./svg-icons.json"; interface IconProps extends React.ComponentPropsWithoutRef<"svg"> { @@ -10,24 +11,28 @@ interface IconProps extends React.ComponentPropsWithoutRef<"svg"> { const Icon = ({ iconType, color, size, ...attributes }: IconProps) => { return ( - - - + + + + + ); }; diff --git a/frontend/src/components/common/Modal/EditRegisterModalBottomSheet/EditRegisterModalBottomSheet.tsx b/frontend/src/components/common/Modal/EditRegisterModalBottomSheet/EditRegisterModalBottomSheet.tsx index d298065af..3bd41aa7a 100644 --- a/frontend/src/components/common/Modal/EditRegisterModalBottomSheet/EditRegisterModalBottomSheet.tsx +++ b/frontend/src/components/common/Modal/EditRegisterModalBottomSheet/EditRegisterModalBottomSheet.tsx @@ -24,40 +24,38 @@ const EditRegisterModalBottomSheet = ({ onConfirm, }: EditRegisterModalBottomSheetProps) => { return ( - isOpen && ( - - - - - - - - {mainText} - - {subText} - - - + + + + + + + + {mainText} + + {subText} + + + - - - - - - ) + + + + + ); }; diff --git a/frontend/src/components/common/Modal/SingleSelectionTagModalBottomSheet/SingleSelectionTagModalBottomSheet.styled.ts b/frontend/src/components/common/Modal/SingleSelectionTagModalBottomSheet/SingleSelectionTagModalBottomSheet.styled.ts index cb5768d52..e982403f6 100644 --- a/frontend/src/components/common/Modal/SingleSelectionTagModalBottomSheet/SingleSelectionTagModalBottomSheet.styled.ts +++ b/frontend/src/components/common/Modal/SingleSelectionTagModalBottomSheet/SingleSelectionTagModalBottomSheet.styled.ts @@ -16,4 +16,5 @@ export const HandleBar = styled.div` export const modalBodyStyle = css` align-items: flex-start; gap: ${theme.spacing.l}; + padding-bottom: ${theme.spacing.l}; `; diff --git a/frontend/src/components/pages/main/MainPage.tsx b/frontend/src/components/pages/main/MainPage.tsx index df648abca..5b3629cd6 100644 --- a/frontend/src/components/pages/main/MainPage.tsx +++ b/frontend/src/components/pages/main/MainPage.tsx @@ -19,6 +19,7 @@ import useKeyDown from "@hooks/useKeyDown/useKeyDown"; import useMultiSelectionTag from "@hooks/useMultiSelectionTag"; import useSingleSelectionTag from "@hooks/useSingleSelectionTag"; import useTravelogueCardFocus from "@hooks/useTravelogueCardFocus"; +import useUnmountAnimation from "@hooks/useUnmountAnimation"; import { ERROR_MESSAGE_MAP } from "@constants/errorMessage"; import { FORM_VALIDATIONS_MAP } from "@constants/formValidation"; @@ -114,6 +115,14 @@ const MainPage = () => { const cardRefs = useTravelogueCardFocus(isFetchingNextPage); + const { isRendered: shouldSortingRender } = useUnmountAnimation({ + isOpen: sorting.isModalOpen, + }); + + const { isRendered: shouldFilterRender } = useUnmountAnimation({ + isOpen: travelPeriod.isModalOpen, + }); + if (isPaused) { alert(ERROR_MESSAGE_MAP.network); } @@ -271,7 +280,7 @@ const MainPage = () => { ? "여행기 정렬 메뉴가 열렸습니다." : "여행기 정렬 메뉴가 닫혔습니다."} - {sorting.isModalOpen && ( + {shouldSortingRender && ( { ? "여행기 필터 메뉴가 열렸습니다." : "여행기 필터 메뉴가 닫혔습니다."} - {travelPeriod.isModalOpen && ( + {shouldFilterRender && ( { const { editModal, profileImage, profileNickname, profileEdit, userProfile } = useMyPage(); + const { isRendered } = useUnmountAnimation({ isOpen: editModal.isEditModalOpen }); const showErrorAlert = (error: Error | null) => { if (error && !IGNORED_ERROR_MESSAGES.includes(error.message)) alert(error.message); @@ -126,7 +129,7 @@ const MyPage = () => { css={S.listStyle} /> - {editModal.isEditModalOpen && ( + {isRendered && ( { const [isOpen, handleOpenBottomSheet, handleCloseBottomSheet] = useToggle(); + const { isRendered } = useUnmountAnimation({ isOpen }); const { state: { @@ -197,14 +199,16 @@ const TravelogueEditPage = () => { - + {isRendered && ( + + )} ); }; diff --git a/frontend/src/constants/route.ts b/frontend/src/constants/route.ts index f31a17cf3..ec9dbeabc 100644 --- a/frontend/src/constants/route.ts +++ b/frontend/src/constants/route.ts @@ -1,7 +1,7 @@ export const ROUTE_PATHS_MAP = { back: -1, root: "/", - main: "main", + main: "/main", travelogue: (id?: number | string) => (id ? `/travelogue/${id}` : "/travelogue/:id"), travelPlan: (id?: number | string) => (id ? `/travel-plan/${id}` : "/travel-plan/:id"), travelogueRegister: "/travelogue/register", diff --git a/frontend/src/hooks/useUnmountAnimation.ts b/frontend/src/hooks/useUnmountAnimation.ts new file mode 100644 index 000000000..3b6f0a479 --- /dev/null +++ b/frontend/src/hooks/useUnmountAnimation.ts @@ -0,0 +1,26 @@ +import { useEffect, useState } from "react"; + +interface useUnmountAnimationProps { + isOpen: boolean; + animationDuration?: number; +} + +const useUnmountAnimation = ({ isOpen, animationDuration = 300 }: useUnmountAnimationProps) => { + const [isRendered, setIsRendered] = useState(false); + + useEffect(() => { + if (isOpen) { + setIsRendered(true); + } else { + const timer = setTimeout(() => { + setIsRendered(false); + }, animationDuration); + + return () => clearTimeout(timer); + } + }, [isOpen, animationDuration]); + + return { isRendered }; +}; + +export default useUnmountAnimation; diff --git a/frontend/src/styles/theme.ts b/frontend/src/styles/theme.ts index 07b8e04df..4b053a916 100644 --- a/frontend/src/styles/theme.ts +++ b/frontend/src/styles/theme.ts @@ -1,12 +1,13 @@ import { Theme } from "@emotion/react"; -import { SEMANTIC_COLORS, SPACING, TYPOGRAPHY, Z_INDEX } from "@styles/tokens"; +import { ANIMATION, SEMANTIC_COLORS, SPACING, TYPOGRAPHY, Z_INDEX } from "@styles/tokens"; const theme: Theme = { typography: TYPOGRAPHY, colors: SEMANTIC_COLORS, spacing: SPACING, zIndex: Z_INDEX, + animation: ANIMATION, }; export default theme; diff --git a/frontend/src/styles/tokens/animation.ts b/frontend/src/styles/tokens/animation.ts new file mode 100644 index 000000000..88714ffee --- /dev/null +++ b/frontend/src/styles/tokens/animation.ts @@ -0,0 +1,82 @@ +import { keyframes } from "@emotion/react"; + +interface SlideAnimationProps { + from: string | number; + to: string | number; +} + +const formatValue = (value: string | number) => (typeof value === "number" ? `${value}rem` : value); + +export const ANIMATION = { + duration: { + default: "0.3s", + }, + fade: { + in: keyframes` + from { + opacity: 0; + visibility: hidden; + } + to { + opacity: 1; + visibility: visible; + } + `, + out: keyframes` + from { + opacity: 1; + visibility: visible; + } + to { + opacity: 0; + visibility: hidden; + } + `, + }, + slide: { + up: ({ from, to }: SlideAnimationProps) => keyframes` + from { + opacity: 0; + transform: translateY(${formatValue(from)}); + visibility: hidden; + } + to { + opacity: 1; + transform: translateY(${formatValue(to)}); + visibility: visible; + } + `, + down: ({ from, to }: SlideAnimationProps) => keyframes` + from { + opacity: 1; + transform: translateY(${formatValue(from)}); + visibility: visible; + } + to { + opacity: 0; + transform: translateY(${formatValue(to)}); + visibility: hidden; + } + `, + in: ({ from, to }: SlideAnimationProps) => keyframes` + from { + transform: translateX(${formatValue(from)}); + visibility: visible; + } + to { + transform: translateX(${formatValue(to)}); + visibility: hidden; + } + `, + out: ({ from, to }: SlideAnimationProps) => keyframes` + from { + transform: translateX(${formatValue(from)}); + visibility: hidden; + } + to { + transform: translateX(${formatValue(to)}); + visibility: visible; + } + `, + }, +}; diff --git a/frontend/src/styles/tokens/index.ts b/frontend/src/styles/tokens/index.ts index 6819db706..9394ec341 100644 --- a/frontend/src/styles/tokens/index.ts +++ b/frontend/src/styles/tokens/index.ts @@ -2,3 +2,4 @@ export * from "./colors"; export * from "./typography"; export * from "./spacing"; export * from "./zIndex"; +export * from "./animation"; diff --git a/frontend/src/types/style/emotion.d.ts b/frontend/src/types/style/emotion.d.ts index f88c79a91..2d2e50ccf 100644 --- a/frontend/src/types/style/emotion.d.ts +++ b/frontend/src/types/style/emotion.d.ts @@ -1,6 +1,6 @@ import "@emotion/react"; -import { SEMANTIC_COLORS, SPACING, TYPOGRAPHY, Z_INDEX } from "@styles/tokens"; +import { ANIMATION, SEMANTIC_COLORS, SPACING, TYPOGRAPHY, Z_INDEX } from "@styles/tokens"; declare module "@emotion/react" { export interface Theme { @@ -8,5 +8,6 @@ declare module "@emotion/react" { colors: typeof SEMANTIC_COLORS; spacing: typeof SPACING; zIndex: typeof Z_INDEX; + animation: typeof ANIMATION; } }