[Vue.ts] 모달창 만들자

LSA·2026년 1월 2일

이걸해냄

목록 보기
13/13
post-thumbnail

이전 글에서 이어집니다.

기본적인 컴포넌트를 만들었으니 이제는 모달과 같은 글로벌한 상태를 다루는 컴포넌트를 만들 것입니다.

일단 모달을 만들려면
1. 공통 모달 UI 컴포넌트
2. 모달동작을 관리하는 context
3. 개별적으로 들어갈 모달의 내용 컴포넌트
가 필요하죠.

React 로직 살펴보기(안봐도됨)

기존 프로젝트에선 모달을 Context로 만들어 관리했는데 어떻게 구성되었는지 한번 훑어보겠습니다.아래는 modal context 코드입니다.

//src/context/ModalContext.tsx
import { useState, useContext, createContext, useMemo, ReactNode } from "react";

interface IModalProviderProps {
  children: ReactNode;
}

interface IModalClassType {
  //isOpen을 객체로 관리해서 이런 식으로 나옵니다.
  //{ aboutMe : true } = 자기소개 모달 오픈
  isOpen: { [key: string]: boolean };
  openModal: (modalName: string) => void;
  closeModal: (modalName: string) => void;
}

export const ModalContext = createContext<IModalClassType>({
  isOpen: {},
  openModal: () => {},
  closeModal: () => {},
});

export const ModalProvider = ({ children }: IModalProviderProps) => {
  const [isOpen, setIsOpen] = useState<{ [key: string]: boolean }>({});

  const disableScroll = () => {
    document.body.style.cssText = `
    overflow-y:hidden;
    width:100%;
  `;
  };

  const ableScroll = () => {
    document.body.style.cssText = "";
  };

  const openModal = (modalName: string) => {
    disableScroll();
    setIsOpen({ [modalName]: true });
  };

  const closeModal = (modalName: string) => {
    ableScroll();
    setIsOpen({ [modalName]: false });
  };

 const contextValue = { isOpen, openModal, closeModal };

  return (
    <ModalContext.Provider value={contextValue}>
      {children}
    </ModalContext.Provider>
  );
};

export const useModal = (): IModalClassType => {
  const context = useContext(ModalContext);
  if (!context) {
    throw new Error("ModalProvider Error");
  }
  return context;
};

간단히 설명하면 모달의 열고닫기 동작, 어떤 모달이 열렸는지의 상태를 객체로 관리한 다음 useModal()이라는 훅으로 따로 빼준 겁니다. (에러 처리만 한거라서 굳이 훅으로 만들 필요는 없다고 생각하지만..오래전에 짠 코드라서 왜 이렇게 짰는지는 모르겠습니다.)

개인적으로 context api에서 불편하게 느끼는 점은 이렇게 글로벌한 컨텍스트를 하나 만들어주면 반드시 App.tsx 최상단에 Provider로 한번 감싸줘야 한다는 것입니다.

import { ThemeProvider } from "styled-components";
import { theme } from "@/styles/theme";
import { ResetCss } from "@/styles/reset";
import { GlobalStyle } from "@/styles/global";
import { useMediaQuery } from "react-responsive";
import MainLayout from "./components/system/templates/MainLayout";
import { ModalProvider } from "./context/ModalContext";

function App() {
  //mediaQuery
  const isMobile = useMediaQuery({ query: "(max-width:1023px)" });
 return (
    <ThemeProvider theme={theme}>
      <ResetCss />
      <GlobalStyle />
      <ModalProvider> // << 안하면 말짱도루묵됨
        <MainLayout isMobile={isMobile} />
      </ModalProvider>
    </ThemeProvider>
  );
}

export default App;

이번엔 공통으로 사용할 모달 컴포넌트 UI입니다.

//src/components/system/templates/Modal/index.tsx
import {
  ModalOverlay,
  ModalContainer,
  ModalWrapper,
  ModalBodyStyle,
} from "./styles";
import ModalHeader from "./ModalHeader";
import { useEffect, useState } from "react";

/**
 * 모달창 컴포넌트
 * @param show
 * @param title
 * @param subTitle
 * @param data 이것이 모달에 들어갈 개별 콘텐츠 컴포넌트
 * @param closeModal
 * @param isMobile
 * @returns
 */

export interface IModalProps {
  title: string;
  subTitle?: string;
  show?: boolean;
  data?: JSX.Element[];
  isMobile?: boolean;
  closeModal?: () => void;
}

const Modal = ({
  title,
  subTitle,
  closeModal,
  show,
  data,
  isMobile,
}: IModalProps) => {
  //모달의 fade in-out 애니메이션 상태관리
  const [isAnimate, setIsAnimate] = useState(false);

  useEffect(() => {
    let timeoutId: NodeJS.Timeout;
    if (show) {
      setIsAnimate(true);
    } else {
      timeoutId = setTimeout(() => setIsAnimate(false), 300);
    }

    return () => {
      if (timeoutId !== undefined) {
        clearTimeout(timeoutId);
      }
    };
  }, [show]);

  if (!isAnimate) return null;

  return (
    <ModalOverlay
      $show={show}
      top={window.scrollY}
      onClick={isMobile ? undefined : closeModal}
    >
      <ModalWrapper>
        <ModalContainer>
          <ModalHeader
            title={title}
            subTitle={subTitle}
            closeModal={closeModal}
            isMobile={isMobile}
          />
          <ModalBodyStyle>{data}</ModalBodyStyle>
        </ModalContainer>
      </ModalWrapper>
    </ModalOverlay>
  );
};

export default Modal;

UI만 있는건 아니고, useEffect와 타이머를 사용해 0.3초간의 페이드 애니메이션도 들어가 있습니다.이 애니메이션은 어떻게 적용되냐?

export const ModalOverlay = styled.div<IModalProps>`
  visibility: ${({ $show }) => ($show ? "visible" : "hidden")};
  top: ${({ top }) => top}px;
  position: absolute;
  width: 100%;
  height: 100vh;
  background: rgba(0, 0, 0, 0.2);
  z-index: 100;
  cursor: pointer;
  animation: ${({ $show }) => ($show ? "fadeIn" : "fadeOut")} 0.3s ease-out;
  transition: all 0.3s ease-out;
  @keyframes fadeIn {
    from {
      opacity: 0;
    }
    to {
      opacity: 1;
    }
  }
  @keyframes fadeOut {
    from {
      opacity: 1;
    }
    to {
      opacity: 0;
    }
  }

  @media only screen and (max-width: 1023px) {
    background: none;
  }
`;

styled-component로 컴포넌트를 만들었기 때문에 이런 식으로 show prop을 받아와 css 애니메이션을 적용하는 방식입니다.
ModalHeader 라고 컴포넌트를 또 분리하긴 했는데, 단순히 일부 props를 내려받는 컴포넌트라서 굳이 보지 않아도 됩니다.

이렇게 만들어진 Modal을 쓰기 위해선 이런 식으로 사용합니다.

//src/components/system/templates/MainLayout.tsx

  const { isOpen, closeModal } = useModal();

  {/* 자기소개 상세 모달 */}
      <Modal
        isMobile={isMobile}
        title={aboutMeTitle.title}
        show={isOpen["aboutMe"]}
        data={aboutMe.map((data, index) => (
          <AboutMeList data={data} key={index} />
        ))}
        closeModal={() => closeModal("aboutMe")}
      />

      <Header isScroll={isPoint} isMobile={isMobile} />

      <CareerSection isMobile={isMobile} isPoint={isPoint} />

      <DirectionSection isMobile={isMobile} />

      <Footer isMobile={isMobile} />

useModal 훅에서 isOpen,closeModal을 가져와 Modal 컴포넌트 props에 값을 지정해주는 방식입니다. 이것들을 vue로 구현해야하니 어떻게 해야 할지 막막하지만 ai가 옆에서 도와주니까 괜찮습니다.

composable 만들기

컴포저블이 뭔데요?
아래는 뷰 공식 문서의 설명입니다.

나의 표정

조금 더 알기 쉬운 설명


그냥 리액트 훅의 vue 버전이네요. 일단 만들어봅니다.

//src/composables/useModal.ts
import { ref, type Component } from 'vue'

interface ModalOptions {
  component: Component// 개별적으로 들어갈 내용 컴포넌트
  props?: ModalProps
}

interface ModalProps {
  title?: string
  subTitle?: string
  isMobile?: boolean
}

const isOpen = ref(false)
const modalComponent = ref<Component | null>(null)
const modalProps = ref<ModalProps>({})

const disableScroll = () => {
  document.body.style.cssText = 'overflow-y: hidden;'
}

const ableScroll = () => {
  document.body.style.cssText = ''
}

export const useModal = () => {
  const openModal = (options: ModalOptions) => {
    disableScroll()
     // ref로 상태를 바꿀때는 setState가 아닌 자바스크립트 직접 변경 방식으로 값을 바꿔줌
    isOpen.value = true
    modalComponent.value = options.component
    modalProps.value = options.props || {}
   
  }

  const closeModal = () => {
    ableScroll()
    isOpen.value = false
    modalComponent.value = null
    modalProps.value = {}
  }

  return {
    isOpen,
    modalComponent,
    modalProps,
    openModal,
    closeModal,
  }
}

눈에 띄는 차이점
1. isOpen, component, props로 3등분하여 useState()처럼 ref()로 관리합니다. 리액트 프로젝트처럼 props에 show, openModal과 같이 모든 동작이나 상태를 때려넣지는 않았습니다.
2. context같은걸 거치지 않고 바로 훅으로 사용했습니다. (리액트에선 그냥 context api를 연습해보고 싶어서 쓴것임)

동작을 관리하는 컴포저블(훅)은 만들었으니, 이번엔 공통 모달 ui를 만들어봅니다.

모달 UI 만들기

//src/components/common/modal/ModalView.vue
<template>
  <Teleport to="body">
    <Transition name="modal-fade">
      <div
        v-if="isOpen"
        id="modal-overlay"
        @click="closeModal"
        :style="{ top: scrollY + 'px' }"
        class="absolute w-full h-[100vh] bg-black/20 z-50 cursor-pointer"
      >
        <div id="modal-wrapper" class="container-custom h-[100%] flex justify-center relative items-center">
          <div
            id="modal-container"
            @click.stop
            class="bg-white w-full max-h-[90vh] overflow-y-auto overflow-x-hidden rounded-[5px] relative"
          >
            <!--Header -->
            <div
              id="modal-header"
              class="container-custom bg-white pt-[40px] pb-[20px] px-[40px] rounded-t-[5px] sticky top-0"
            >
              <div class="flex justify-between items-center">
                <TitleWithDot :title="modalTitle" class="my-0" />
                <DefaultButton class="rounded-[100%] w-[50px] h-[50px]" @click="closeModal">
                  <Icon icon="bi:arrow-left" width="20" height="20" class="text-green-01" />
                </DefaultButton>
              </div>
              <TypographyTag type="h4" class="mt-[10px] text-gray-7d">{{ modalSubTitle }}</TypographyTag>
            </div>
            <!--Header -->

            <div id="modal-body" class="w-full bg-white px-[40px] py-[20px] rounded-b-[5px]">
              <component :is="modalComponent" v-bind="modalProps" />
            </div>
          </div>
        </div>
      </div>
    </Transition>
  </Teleport>
</template>

<script setup lang="ts">
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
import { useModal } from '@/composables/useModal'
import TitleWithDot from '../TitleWithDot.vue'
import { Icon } from '@iconify/vue'
import TypographyTag from '@/components/typography/TypographyTag.vue'
import DefaultButton from '../DefaultButton.vue'

const { isOpen, modalComponent, modalProps, closeModal } = useModal()
const modalTitle = computed(() => (typeof modalProps.value.title === 'string' ? modalProps.value.title : ''))
const modalSubTitle = computed(() => (typeof modalProps.value.subTitle === 'string' ? modalProps.value.subTitle : ''))
const scrollY = ref(0)

const updateScrollY = () => {
  scrollY.value = window.scrollY
}

onMounted(() => {
  updateScrollY()
  window.addEventListener('scroll', updateScrollY)
})

onBeforeUnmount(() => {
  window.removeEventListener('scroll', updateScrollY)
})
</script>

<style scoped>
.modal-fade-enter-active,
.modal-fade-leave-active {
  transition: all 0.3s ease-out;
}

.modal-fade-enter-from,
.modal-fade-leave-to {
  opacity: 0;
}

.modal-fade-enter-to,
.modal-fade-leave-from {
  opacity: 1;
}
</style>

귀찮아서 코드 전문 갖다붙였습니다. 중요한 부분만 뜯어보겠습니다.

뷰의 내장 컴포넌트

1. <Teleport/>
<Teleport to="body"> 라는 놈이 생겼습니다. Vue 컴포넌트의 DOM을 다른 위치로 순간이동하는 컴포넌트입니다. 모달 컴포넌트가 렌더링되면 <body> 내부에 직접 자식으로 붙여주기 위함입니다. 이렇게 되면 z-index에 영향을 받을 필요가 없고 css 스타일도 분리하기가 쉬워집니다.
2. <Transition/>
정말 고맙게도 페이드인/아웃 애니메이션을 자동으로 먹일수 있는 컴포넌트입니다.name="modal-fade" 라는 어트리뷰트를 통해 애니메이션 클래스를 부여합니다.물론 애니메이션의 세부효과는 아래 style 태그에서 설정해줘야 합니다.클래스명은 다 저렇게 정해져 있다고 하네요. (참고)
3. <component :is="modalComponent" v-bind="modalProps" />
<component> 는 동적으로 다른 컴포넌트를 렌더링하는 녀석입니다. 리액트의 children 보다는 JSX.element를 받아오는 개념에 가깝습니다.

interface ModalProps {
  content: JSX.Element; // 비교하자면 이것과 같음
}
function Modal({ content, title }: ModalProps) {
  return (
    <div className="modal">
      {content}  {/* 뷰에선 <component> */}
    </div>
  )
}

:is는 어떤 컴포넌트를 렌더링할지 지정해주는 역할이며, modalComponent의 값을 따라갑니다. 그리고 v-bind로 modalProps를 내려주는거죠.(title같은 것들)
그리고 추가로..

const modalTitle = computed(() => (typeof modalProps.value.title === 'string' ? modalProps.value.title : ''))
const modalSubTitle = computed(() => (typeof modalProps.value.subTitle === 'string' ? modalProps.value.subTitle : ''))

얘네는 왜 computed까지 써주냐 하신다면 단순히 타입 안정성을 보장해주는 용도입니다.자바스크립트 쓰면 안해도 됩니다.

어쨌든 뷰에서의 모달 컴포넌트는 <ModalHeader/> 같은 컴포넌트 분리 없이 한꺼번에 때려넣었습니다. 다만 컴포넌트 명으로 세세한 부분을 알기 어려우니 id값을 임의로 부여하여 영역표시 정도로만 썼습니다. (div의 천국이라서 정말 알아보기 힘듭니다)

개별 모달 만들기

이제 이 모달을 어떻게 사용할것인가. 기존 react 프로젝트에서는 모달을 <MainLayout/> 안에 넣어 레이아웃 최상단에 배치해두었습니다. context API로 모달의 오픈 상태를 최상단에서 사용하고 있으니 그래도 돼요.

뷰에서도 동일하게 앱 최상단에 모달 컴포넌트를 등록해주긴 해야합니다.

//src/App.vue
<template>
  <router-view />
  <modal-view />//이녀석
</template>

<script setup lang="ts">
import ModalView from './components/common/modal/ModalView.vue'
</script>

파일명은 카멜케이스인데 왜 컴포넌트에선 케밥 케이스를 쓰나요?
뷰는 그래도 돼!
그다음 모달을 볼 수 있는 이벤트를 걸어줄겁니다.

//src/components/common/modal/AboutMeModal.vue
<template>
  <div v-for="(data, index) in aboutMe" :key="index" class="mb-[30px]">
    <TypographyTag type="h3" class="mb-[20px] font-semibold">{{ data.title }}</TypographyTag>
    <TypographyTag type="p" class="whitespace-pre-line">{{ data.description }}</TypographyTag>
  </div>
</template>

<script setup lang="ts">
import TypographyTag from '@/components/typography/TypographyTag.vue'
import { aboutMe } from '@/data/static'
</script>
// src/components/common/HeaderComponent.vue
<template>
  <header class="w-full sticky pt-[40px] pb-[60px] top-0">
    <div class="w-[1000px] mx-auto">
      <div class="mt-[30px] mb-[52px]">
        <TypographyTag type="h1">{{ title[0] }}</TypographyTag>
        <TypographyTag type="h1" className="text-green-01">{{ title[1] }}</TypographyTag>
        <TypographyTag type="h1">{{ title[2] }}</TypographyTag>
      </div>

      <div class="flex justify-between items-end">
        <TypographyTag type="h4" className="whitespace-pre-line font-normal">
          {{ intro }}
        </TypographyTag>
        <DetailButton buttonName="More" @click="openAboutMeModal" />
      </div>
    </div>
  </header>
</template>

<script setup lang="ts">
import TypographyTag from '../typography/TypographyTag.vue'
import AboutMeModal from './modal/AboutMeModal.vue'
import { headerData } from '@/data/static'
import DetailButton from './DetailButton.vue'
import { useModal } from '@/composables/useModal'

const { title, intro } = headerData

const { openModal } = useModal()

const openAboutMeModal = () => {
  openModal({
    component: AboutMeModal,
    props: {
      title: '어떤 사람인가요?',
    },
  })
}
</script>

@click="openAboutMeModal" 으로 버튼에 이벤트를 걸어준 뒤, useModal 훅에서 openModal만 가져와줍니다.그리고 openModal의 파라미터로 미리 만들어둔 AboutMeModal과 title 값을 내려주면 모달이 적용됩니다.
어찌저찌 적용된 모습
이렇게 모달을 적용하는 구조를 만들었으니, 다른 모달도 만들어서 적용하면 되겠습니다.

그런데 사실 모달을 적용하는 구조보다도 저를 힘들게 한 것은 테일윈드를 사용해서 클래스를 덮어씌우는 구조를 만드는 것이었습니다.최소한 styled component는 이런 문제는 없어서 참 편했거든요. 이따로 포스팅을 쓰기엔 내용이 적어 추가로 써봅니다.

vue+tailwindCSS로 클래스 덮어씌우기

저를 당황시킨 녀석들은 바로 이 부분입니다.

클래스만 내려받는 스타일 컴포넌트

저 화살표 버튼은 모달에서만 쓰이지만 점이 찍힌 타이틀은 모달 밖에서도 쓰입니다.기본 위아래 마진값이 32px인 놈이죠. 원래는 이렇게 생겼습니다.
변경 전

//src/components/common/TitleWithDot.vue
<template>
  <div class="my-[32px]">
    <div class="w-[13px] h-[13px] rounded-xl bg-green-01" />
    <TypographyTag type="h2" class="ml-[13px]">{{ title }}</TypographyTag>
  </div>
</template>

<script setup lang="ts">
import TypographyTag from '../typography/TypographyTag.vue'

interface Props {
  title: string
}

defineProps<Props>()
</script>

모달창 안에서는 마진값이 없어져야 해서 my-0이라는 클래스가 새로 붙어야 합니다.그래서 처음에는 이렇게 작성했는데..

<div class="my-[32px]" :class="$attrs.class">

이렇게 되면 class가 병합되어 결과적으로는

<div class="my-[32px] my-0">

로 렌더링됩니다.HTML의 클래스 순서와 상관 없이 Tailwind가 생성하는 CSS 순서에 따라 클래스가 적용되기 때문에, my-0 클래스를 주든 말든 my-[32px] 스타일만 먹히는 상황이 생겨버리는 겁니다. 그래서 확실하게 클래스를 덮을 수 있도록 고쳐야 합니다.

변경 후

<template>
  <div :class="$attrs.class || 'my-[32px]'">//바뀐 부분
    <div class="w-[13px] h-[13px] rounded-xl bg-green-01" />
    <TypographyTag type="h2" class="ml-[13px]">{{ title }}</TypographyTag>
  </div>
</template>

<script setup lang="ts">
import TypographyTag from '../typography/TypographyTag.vue'

interface Props {
  title: string
}

defineProps<Props>()
defineOptions({ inheritAttrs: false })//추가 코드
</script>

동적으로 클래스를 바인딩하도록 :class(v-bind:class의 축약)만 붙여주고 $attrs로 받아온 클래스를 붙여주도록 처리합니다. 그리고 attrs의 자동 상속을 수동으로 제어하기 위해 defineOptions({ inheritAttrs: false }) 라는 코드가 추가되었습니다.

//inheritAttrs: false 처리를 안하면?
<div class="my-[32px] my-0">

이걸 안쓰면 또다시 위처럼 클래스가 중복으로 겹치는 결과가 됩니다.

클래스+이벤트도 내려받는 컴포넌트

근데 다른 attribute를 내려받아야 하는 컴포넌트도 있습니다. 바로 onClick 이벤트가 걸린 버튼 같은 놈들인데요. 이런 애들은 어찌합니까? 일단 완전체부터 봅시다.

//src/components/common/DefaultButton.vue
<template>
  <button
    :class="[
      'flex justify-center items-center cursor-pointer outline-none transition-all duration-300 hover:bg-gray-ed bg-white border-none',
      $attrs.class || 'w-auto h-auto rounded-[5px]',
    ]"
    v-bind="attrsWithoutClass"
  >
    <slot />
  </button>
</template>

<script setup lang="ts">
import { useAttrs, computed } from 'vue'

defineOptions({ inheritAttrs: false })

const attrs = useAttrs()

// class를 제외한 나머지 속성 (이벤트 포함)
const attrsWithoutClass = computed(() => {
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  const { class: _, ...rest } = attrs
  return rest
})
</script>

얘는 기본 스타일이 명백히 정해져 있고, width | height | border-radius 정도만 커스텀하는 녀석입니다.그래서 :class에서는 배열을 사용하네요.
아래는 제가 처음에 작성했던, 스타일링이 안되는 예시입니다.

// 컴포넌트 정의
<button
    :class="[
      'flex justify-center items-center cursor-pointer outline-none transition-all duration-300 hover:bg-gray-ed w-auto h-auto rounded-[5px] bg-white border-none',
      $attrs.class,
    ]"
    v-bind="attrsWithoutClass"
  ></button>
  //사용시    
 <DefaultButton class="rounded-[100%] w-[50px] h-[50px]"@click="closeModal"> </DefaultButton>     

이미 w-auto h-auto rounded-[5px] 로 스타일링을 정의해놓고 rounded-[100%] w-[50px] h-[50px] 로 덮어씌우려 하고 있습니다. 당연히 클래스가 중복되어 HTML에선 이렇게 되어, 내가 원하는 스타일링이 먹히지 않습니다.

<button class="w-auto h-auto rounded-[5px] rounded-[100%] w-[50px] h-[50px] (다른 클래스는 생략)"></button>

그리고 class만 수동으로 제어하고 나머지는 자동으로 내려줘야 한다 는 특징 때문에 defineOptions({ inheritAttrs: false })을 설정한 후 computed()를 사용하여 attribute를 한번 분리해줘야 합니다.안해주면 클릭이벤트고 뭐고 씨알도 안먹힙니다.
참고로 useAttrs()$attrs를 vue의 <script> 안에서 쓰기 위한 함수입니다.

맺음말

확실히 html 문법을 응용해서 그런지 리액트랑 간극이 커서 좀 힘드네요.
어쨌든 모달을 만드는 법은 알았으니 이대로 다른 모달을 만들고, 다음 포스팅에는 모바일용 디자인을 적용하는 내용을 쓸 예정입니다.

profile
진짜 간단하게 작성한 TIL 블로그

0개의 댓글