[NUXT3] 부드럽게 펼쳐지는 서브메뉴 구현하기

devMini·2025년 2월 6일
0

Nuxt

목록 보기
11/11

⬆️ 실제로는 더 부드럽게 움직이는데...ㅠㅠ ㅋㅋㅋㅋ

사건의 발단😢

회사에서 새 프로젝트를 시작하게 되었습니다.
여느 때와 같이 사이드바를 구현하던 중 문득, 자식 메뉴를 가진 부모 메뉴를 클릭했을 때
자식 메뉴가 아래로 부드럽게 펼쳐지는 효과를 주고 싶었습니다.

그러지 마...


어라 쉬운데? 😏

처음에는

  1. overflow
  2. transition
  3. max-height
  4. height

위 css 속성들을 이용해 구현을 시작했습니다.


이럴줄 알았따.. 😭

늘 그렇듯, 매번 조져지는건 저였습니다...
위 css 속성들만 이용하여 구현하기에는 아래의 문제가 있었습니다.

늘 그렇듯, 예상과는 다르게 문제가 발생했습니다.
CSS 속성만으로는 해결하기 어려운 부분이 있었기 때문이죠.

❗ 문제: 자식 메뉴의 리스트 수가 고정적이지 않다 ❗

  • transition에 height 또는 max-height를 사용할 때,
    이 값이 auto로 설정되어 있으면 애니메이션이 적용되지 않습니다.
  • 따라서 height 또는 max-height에 정확한 수치를 지정해야 합니다. (예: 180px, 320px 등)

하지만 자식 메뉴의 개수가 각 부모 메뉴마다 다르기 때문에
고정된 높이를 설정하는 것은 불가능합니다.

  • 메뉴가 추가될 때마다 height 값을 수정할 수는 없겠죠? 🤔
  • 자식 메뉴의 개수를 강제로 맞추는 것도 말이 안 됩니다.

그래서, 어떻게 해결했을까? 🚀

결국 JavaScript를 활용하여,
자식 메뉴의 실제 높이(scrollHeight)를 계산하여 동적으로 적용하는 방법으로 구현했습니다.

✅ 구현 방법

  1. 각 자식 메뉴 영역의 ref 값을 가져옵니다.
  2. 해당 요소의 scrollHeight 값을 구합니다.
  3. 이 값을 배열에 저장합니다.
  4. max-height 속성에 동적으로 바인딩해줍니다.

🖼️ Template 🖼️

<template>
  <aside :class="['default-layout-sidebar-container', { collapsed: isSidebarCollapsed }]">
     
    ....
     ....
      ...
      <div class="menu-container">
        <ul class="menu-list">
          <li v-for="(parentMenu, index) in defaultMenu" :key="parentMenu.title" class="menu-list-item">
            <!-- Parent menu without child menu -->
            <NuxtLink v-if="!parentMenu.children" class="parent-menu-no-child" :to="parentMenu.path" @click="handleSidebarMenu('no-child', parentMenu.title)">
              <div class="content-container">
                <NuxtIcon :name="parentMenu.icon" class="menu-icon"></NuxtIcon>
                <span class="menu-title">{{ $t(`${parentMenu.title}`) }}</span>
              </div>
            </NuxtLink>

            <!-- Parent menu with child menu -->
            <div v-if="parentMenu.children" :class="['parent-menu-has-child', { 'child-visible': parentMenu.isOpen }]">
              <div :class="['parent-menu-wrapper']" @click="[handleSidebarMenu('has-child', parentMenu.title), trackOpenMenuIndex(index)]">
                <div class="content-container">
                  <div class="menu-info-container">
                    <NuxtIcon :name="parentMenu.icon" class="menu-icon" />
                    <span class="menu-title">{{ $t(`${parentMenu.title}`) }}</span>
                  </div>
                  <div class="chevron-icon-wrapper">
                    <NuxtIcon name="mdi:chevron-right" :class="['icon', { down: parentMenu.isOpen }]" />
                  </div>
                </div>
              </div>
              <!-- Child menu -->
              <div class="child-menu-wrapper">
                <ul ref="childMenuRefs" class="menu-list" :style="{ 'max-height': calcChildMenuHeight(parentMenu.isOpen, parentMenu.title) }">
                  <li v-for="childMenu in parentMenu.children" :key="childMenu.title" class="menu-list-item">
                    <NuxtLink :to="childMenu.path" class="content-container">
                      <NuxtIcon name="mdi:circle-small" class="menu-icon" />
                      <span class="menu-title">{{ childMenu.title }}</span>
                    </NuxtLink>
                  </li>
                </ul>
              </div>
            </div>
          </li>
        </ul>
      </div>
    </div>
  </aside>
</template>

💻 Script 💻

const menu = ref([
  {
    title: 'Main',
    icon: 'mdi:view-dashboard',
    path: '/main'
  },
  {
    title: 'Menu1',
    icon: 'mdi:view-dashboard',
    basePath: '',
    isOpen: false,
    children: [
      {
        title: 'menu1-1',
        path: '/test1'
      },
      {
        title: 'menu1-2',
        path: '/test2'
      },
      {
        title: 'menu1-3',
        path: '/test3'
      }
    ]
  },
  ......
])

const childMenuRefs = ref<HTMLElement[]>([]);
const childMenuHeights = ref<{ title: string; maxHeight: number }[]>([]);

/**
 * 각 부모 메뉴에 해당하는 자식 메뉴의 최대 높이를 계산하여 저장한다.
 */
const setChildMenuHeights = () => {
  const parentMenuTitles = defaultMenu.value.filter((item) => item.children).map(({ title }) => title);

  if (!parentMenuTitles.length) return;

  const menuHeightMappings: { title: string; maxHeight: number }[] = [];
  for (let i = 0; i < childMenuRefs.value.length; i++) {
    menuHeightMappings.push({
      title: parentMenuTitles[i],
      maxHeight: childMenuRefs.value[i].scrollHeight
    });
  }

  childMenuHeights.value = [...menuHeightMappings];
};

/**
 * 주어진 부모 메뉴 제목에 해당하는 자식 메뉴의 최대 높이를 반환한다.
 * 찾지 못하면 기본값 '0px'을 반환하여 메뉴가 닫힌 상태를 유지한다.
 */
const calcChildMenuHeight = (isOpen: boolean, parentTitle: string) => {
  let maxHeight = 0;

  if (!isOpen) {
    return maxHeight;
  }

  const matchedMenu = childMenuHeights.value.find(({ title }) => title === parentTitle);
  if (matchedMenu) {
    maxHeight = matchedMenu.maxHeight;
  }

  return `${maxHeight}px`;
};

onMounted(async () => {
  await nextTick();
  setChildMenuHeights();
});

실제로 코드는 어려운게 없네요

childMenuRefs: 자식 메뉴의 DOM 요소를 참조하기 위한 배열입니다.
=> "저는 ul 태그에 ref를 사용하지만, ul 태그가 없다면 당연히 scrollHeight를 구해야 하는 정확한 태그의 ref값을 구해야 합니다."

childMenuHeights: 부모 메뉴의 제목과 해당 자식 메뉴의 scrollHeight 값을 저장한 배열입니다.
setChildMenuHeights(): 자식 메뉴의 높이를 계산하고 저장하는 함수입니다.
calcChildMenuHeight(): 부모 메뉴가 열렸을 때, 해당 자식 메뉴의 최대 높이를 반환하는 함수입니다.

여기서 주의해야할점은 onMounted 시점에서 바로 setChildMenuHeight 함수를 호출해서는 안된다는 것입니다.
onMounted 시점에서 바로 setChildMenuHeights() 함수를 호출하면 정확한 높이가 계산되지 않을 수 있습니다. 이는 DOM이 완전히 렌더링되기 전에 함수가 실행되기 때문입니다.

❗ 왜 문제가 발생할까?

  • Vue의 onMounted는 컴포넌트가 마운트된 후 실행되지만, 모든 자식 요소가 완전히 렌더링된 후는 아닙니다.
  • 이로 인해 childMenuRefs에 연결된 요소들의 scrollHeight가 0으로 계산되거나 올바르지 않게 계산되는 경우가 발생할 수 있습니다.

그렇기에 꼭 nextTick 을 이용하여 모든 DOM 업데이트가 완료된 후 호출할 수 있도록 해야합니다.

🤚 여기서 잠깐!

왜 부모 title과 scrollHeight를 함께 객체로 저장했을까? 🤔
자식 메뉴의 높이(scrollHeight)만을 단순히 배열로 저장하지 않고,
{ title, maxHeight } 형태의 객체로 저장한 이유는 바로 "메뉴의 순서와 구조" 때문입니다.

❗ 문제 상황
제 메뉴 트리는 자식 메뉴가 없는 메뉴와 자식 메뉴가 있는 메뉴가 혼합되어 있습니다.
이때, 메뉴 가 항상 "부모 메뉴 → 자식 메뉴" 또는 "자식 메뉴 → 부모 메뉴" 순서로 나열된다는 보장이 없습니다. 즉, 자식 메뉴가 없는 메뉴가 앞에 올 수도 있고, 그 뒤에 자식 메뉴가 있는 메뉴가 올 수도 있습니다.
만약 순서에만 의존하여 높이 값을 저장한다면:

  • 부정확한 데이터 매칭 문제가 발생할 수 있습니다.
  • 잘못된 메뉴에 잘못된 height가 적용되어 UI 오류로 이어질 수 있습니다.

만약 순서에만 의존하여 높이 값을 저장한다면 잘못된 메뉴에 잘못된 height가 적용되어 UI 오류로 이어질 수 있습니다!

✅ 해결 방법: title과 maxHeight를 함께 저장하기

  • 부모 메뉴의 title을 기준으로 정확하게 매칭할 수 있습니다.
  • 메뉴의 순서가 바뀌거나 달라져도 정확한 부모 메뉴에 맞는 높이가 적용됩니다.

🎯 마무리

CSS만으로 해결하고 싶었지만 결국 Javascript의 힘을 빌려야했습니다.

처음에는 overflow, transition, max-height 등의 CSS 속성만으로
깔끔하게 처리할 수 있을 거라고 생각했지만,
자식 메뉴의 개수와 동적 컨텐츠 변화라는 현실적인 문제 앞에서
결국 JavaScript의 도움이 필요했습니다...

실제로 CodePen의 수 많은 예제들을 보면 이런 아코디언 효과를 가지는 서브 메뉴를 구현할 때는 js를 함께 사용하더군요. 또는 details 태그와 summary 태그를 사용하기도 하던데 저는 details 태그 없이 구현하고 싶었습니다.

결과적으로, 만족스러운 UI를 완성할 수 있었고, 더 나은 UI를 위해 고민하고, 실패하고, 다시 도전하는 과정 자체가 성장의 기회였다고 생각합니다.

profile
Slowly But Steadily

0개의 댓글