⬆️ 실제로는 더 부드럽게 움직이는데...ㅠㅠ ㅋㅋㅋㅋ
회사에서 새 프로젝트를 시작하게 되었습니다.
여느 때와 같이 사이드바를 구현하던 중 문득, 자식 메뉴를 가진 부모 메뉴를 클릭했을 때
자식 메뉴가 아래로 부드럽게 펼쳐지는 효과를 주고 싶었습니다.
그러지 마...
처음에는
- overflow
- transition
- max-height
- height
위 css 속성들을 이용해 구현을 시작했습니다.
늘 그렇듯, 매번 조져지는건 저였습니다...
위 css 속성들만 이용하여 구현하기에는 아래의 문제가 있었습니다.
늘 그렇듯, 예상과는 다르게 문제가 발생했습니다.
CSS 속성만으로는 해결하기 어려운 부분이 있었기 때문이죠.
❗ 문제: 자식 메뉴의 리스트 수가 고정적이지 않다 ❗
하지만 자식 메뉴의 개수가 각 부모 메뉴마다 다르기 때문에
고정된 높이를 설정하는 것은 불가능합니다.
결국 JavaScript를 활용하여,
자식 메뉴의 실제 높이(scrollHeight)를 계산하여 동적으로 적용하는 방법으로 구현했습니다.
✅ 구현 방법
<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>
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이 완전히 렌더링되기 전에 함수가 실행되기 때문입니다.
❗ 왜 문제가 발생할까?
그렇기에 꼭 nextTick 을 이용하여 모든 DOM 업데이트가 완료된 후 호출할 수 있도록 해야합니다.
왜 부모 title과 scrollHeight를 함께 객체로 저장했을까? 🤔
자식 메뉴의 높이(scrollHeight)만을 단순히 배열로 저장하지 않고,
{ title, maxHeight } 형태의 객체로 저장한 이유는 바로 "메뉴의 순서와 구조" 때문입니다.
❗ 문제 상황
제 메뉴 트리는 자식 메뉴가 없는 메뉴와 자식 메뉴가 있는 메뉴가 혼합되어 있습니다.
이때, 메뉴 가 항상 "부모 메뉴 → 자식 메뉴" 또는 "자식 메뉴 → 부모 메뉴" 순서로 나열된다는 보장이 없습니다. 즉, 자식 메뉴가 없는 메뉴가 앞에 올 수도 있고, 그 뒤에 자식 메뉴가 있는 메뉴가 올 수도 있습니다.
만약 순서에만 의존하여 높이 값을 저장한다면:
만약 순서에만 의존하여 높이 값을 저장한다면 잘못된 메뉴에 잘못된 height가 적용되어 UI 오류로 이어질 수 있습니다!
✅ 해결 방법: title과 maxHeight를 함께 저장하기
CSS만으로 해결하고 싶었지만 결국 Javascript의 힘을 빌려야했습니다.
처음에는 overflow, transition, max-height 등의 CSS 속성만으로
깔끔하게 처리할 수 있을 거라고 생각했지만,
자식 메뉴의 개수와 동적 컨텐츠 변화라는 현실적인 문제 앞에서
결국 JavaScript의 도움이 필요했습니다...실제로 CodePen의 수 많은 예제들을 보면 이런 아코디언 효과를 가지는 서브 메뉴를 구현할 때는 js를 함께 사용하더군요. 또는 details 태그와 summary 태그를 사용하기도 하던데 저는 details 태그 없이 구현하고 싶었습니다.
결과적으로, 만족스러운 UI를 완성할 수 있었고, 더 나은 UI를 위해 고민하고, 실패하고, 다시 도전하는 과정 자체가 성장의 기회였다고 생각합니다.