이제 실제 메뉴 데이터를 가지고 메뉴 리스트를 렌더링하는 로직을 추가해보자.
요구사항은 아래와 같다. (말로 푸니까 어려운데 실제로는 매우 직관적인 요구사항임)
+-
아이콘 노출메뉴1/메뉴1-1/메뉴1-1-1/......
모든 로직들을 Aside.svelte
에 우겨넣으면 코드가 길어지므로, MenuItem.svelte
라는 이름으로 별도의 컴포넌트를 만들어 각 메뉴 아이템의 렌더링을 맡기자.
즉 데이터 관련 부분은 Aside.svelte
에서 관리하고, 각 메뉴의 렌더링은 MenuItem.svelte
에서 하는 느낌.
MenuItem.svelte
<Item href="javascript:void(0)" class="aside-menu-item">
...
<Text>{menu.name}</Text>
</Item>
<script>
import { Item, Text, Graphic } from '@smui/list';
import PlusCircleOutline from 'svelte-material-icons/PlusCircleOutline.svelte';
import MinusCircleOutline from 'svelte-material-icons/MinusCircleOutline.svelte';
// Aside.svelte 로부터 받은 메뉴 데이터로 렌더링
export let menu;
</script>
<style>
:global(.aside-menu-item .material-icons) {
margin-right: 8px;
}
:global(.aside-menu-item .material-icons *) {
width: 0.7em;
height: 0.7em;
}
</style>
Aside.svelte
...
<List>
{#each menus as menu}
<MenuItem menu={menu}></MenuItem>
{/each}
</List>
...
<script>
...
import MenuItem from './MenuItem.svelte';
import menu from '../../store/menu.js';
let menus = [];
menu.subscribe(value => {
menus = ...
});
...
</script>
현재 store 의 메뉴 데이터는 tree 구조로 구성되어 있는데, 렌더링하기 용이하게 아래와 같이 재편성할 필요가 있다.
depth
: 메뉴 depth 정보open
: 자식 메뉴들의 show/hide 여부tree 구조에서 1차원 배열로 변경하는 건 쉽지만, 고려해봐야 할만한 점이 몇가지가 있다.
현재 url 정보, url 변경을 체크하는 건 svelte-spa-router
의 location
을 사용한다. (내부에서는 svelte/store
를 쓰는 듯?)
Aside.svelte
<script>
import { location } from 'svelte-spa-router';
// url 변경 감지 (value 가 변경된 url)
// $location 으로 바로 접근할 수도 있음
location.subscribe(value => {
setMenus();
});
menu.subscribe(value => {
// 여기가 호출될 일은 최초 페이지 접근시 혹은 Admin 화면에서 메뉴 데이터가 변경될때 밖에 없기 때문에 배열 초기화
menus = value.map(v => {
v.depth = 0;
v.open = false;
return v;
});
setMenus(true);
});
</script>
setMenus
는 메뉴 데이터를 재편성하는 함수로 url 및 store 의 데이터가 변경될때마다 호출된다.
참고로 메뉴 데이터는 REST API 를 호출해서 가져오기 때문에 처음 페이지에 접속할 경우 아래와 같은 순으로 이벤트가 호출된다.
menu.subscribe (초기 빈 데이터) -> location.subscribe -> REST API 호출 & 응답 -> menu.subscribe (데이터 있음)
이제 이것 저것 메뉴 관련 로직 + activate 관련 코드들을 추가하면 Aside.svelte
은 끝이다.
완성된 Aside.svelte
는 아래와 같다.
Aside.svelte
<!-- Don't include fixed={false} if this is a page wide drawer.
It adds a style for absolute positioning. -->
<Drawer variant={drawerVariant} fixed={false} bind:open={drawerOpen}>
<Header>
<Title>여기에는 과연 뭘 추가할 수 있을까???</Title>
<Subtitle>여기에는 과연 뭘 추가할 수 있을까???</Subtitle>
<Subtitle>여기에는 과연 뭘 추가할 수 있을까???</Subtitle>
</Header>
<Content>
<List>
{#each menus as menu}
<MenuItem menu={menu} activeMenuId={active} on:toggle={toggleChildren}></MenuItem>
{/each}
</List>
</Content>
</Drawer>
<!-- Don't include fixed={false} if this is a page wide drawer.
It adds a style for absolute positioning. -->
<Scrim fixed={false} />
<svelte:window bind:innerWidth={innerWidth}/>
<script>
import { location } from 'svelte-spa-router';
import Drawer, { Content, Header, Title, Subtitle, Scrim } from '@smui/drawer';
import List from '@smui/list';
import MenuItem from './MenuItem.svelte';
import asideOpen from '../../store/aside.js';
import menu from '../../store/menu.js';
let innerWidth;
let drawerVariant = 'static';
let drawerOpen = false;
let menus = [];
let active = '';
// 현재 url 정보에 표현된 메뉴 데이터들을 array 로 만들어서 리턴하는 함수
// ex)
// url 이 'menu1/menu1-1/menu1-1-1' 이라면
// 리턴값은 [{name: 'menu1', ...}, {name: 'menu1-1', ...}, {name: 'menu1-1-1', ...}];
const getMenusByUrl = () => {
const names = $location.split('/');
let result = [];
let tempMenus = $menu;
while (names.length > 0) {
const name = names.shift();
for (let i = 0; i < tempMenus.length; i++) {
if (tempMenus[i].name === name) {
result.push(tempMenus[i]);
tempMenus = tempMenus[i].children;
break;
}
}
}
return result;
};
// getMenusByUrl() 로 뽑아낸 메뉴 데이터를 기존 1차면 배열에 추가
const setFlatMenus = arrCurrent => {
while (arrCurrent.length > 0) {
const current = arrCurrent.shift();
if (menus.filter(m => m._id === current._id).length === 0) {
const parent = current.parent;
menus = menus.flatMap(m => {
if (m._id === parent._id) {
m.open = true;
return [m, ...m.children.map(c => {
c.depth = parent.depth + 1;
return c;
})];
}
return m;
});
}
}
};
// 특정 메뉴의 자식 메뉴들을 1차원 배열에 추가
const addChildrenOnMenus = current => {
menus = menus.flatMap(m => {
if (m._id === current._id) {
return [m, ...m.children.map(c => {
c.depth = current.depth + 1;
c.open = false;
return c;
})];
}
return m;
});
};
// 특정 메뉴의 자식 메뉴들을 1차원 배열에 제거
const removeChildrenOnMenus = current => {
let targets = [...current.children];
let tempMenus = [...menus];
while (targets.length > 0) {
const target = targets.shift();
targets = [...targets, ...target.children];
tempMenus = tempMenus.filter(m => m._id !== target._id);
}
menus = tempMenus;
};
// 특정 메뉴의 자식 메뉴들을 추가 / 제거
const toggleChildren = () => {
const arrCurrent = getMenusByUrl();
const current = arrCurrent[arrCurrent.length - 1];
current.open = !current.open;
if (current.open) {
addChildrenOnMenus(current);
} else {
removeChildrenOnMenus(current);
}
}
// url 혹은 메뉴 데이터가 변경될때마다 1차원 배열 및 activate 를 설정하는 함수
const setMenus = (init = false) => {
if ($location.includes('/admin/')) {
active = '';
return;
}
const arrCurrent = getMenusByUrl();
const current = arrCurrent[arrCurrent.length - 1];
if (!current) {
return;
}
setFlatMenus(arrCurrent);
if (active === current._id) {
if (!current.open) {}
}
if (!init) {
toggleChildren();
}
active = current._id;
};
menu.subscribe(value => {
menus = value.map(v => {
v.depth = 0;
v.open = false;
return v;
});
setMenus(true);
});
location.subscribe(() => {
setMenus();
});
const setVisible = () => {
if (innerWidth > 480) {
drawerVariant = 'static';
asideOpen.update(open => false);
} else {
drawerVariant = 'modal';
}
};
asideOpen.subscribe(value => {
drawerOpen = value;
});
$: setVisible(innerWidth);
</script>
추가로 MenuItem.svelte
에 click 이벤트 및 렌더링 관련 코드를 추가하면 끝이다.
MenuItem.svelte
click 로직은 url 을 변경하거나, 현재 url 과 변경할 url 이 같은 경우 내 자식 메뉴들을 노출하도록 부모 컴포넌트 - Aside.svelte
에 toggle 하도록 이벤트를 생성 & 전달한다.
url 변경은 svelte-spa-router
의 push
함수를 사용하고, 부모 컴포넌트에 이벤트 전달하는 건 svelte
의 createEventDispatcher
를 사용한다. (createEventDispatcher
는 https://svelte.dev/tutorial/component-events 참조)
완성된 코드는 아래와 같다.
MenuItem.svelte
<Item href="javascript:void(0)" class="aside-menu-item" on:click={setActive} activated={activeMenuId === menu._id}>
<span style={`margin-left : ${menu.depth * 25}px; height: 100%;`}></span>
<Graphic class="material-icons" aria-hidden="true" >
{#if menu.children.length > 0 && menu.open}
<MinusCircleOutline></MinusCircleOutline>
{:else if menu.children.length > 0 && !menu.open}
<PlusCircleOutline></PlusCircleOutline>
{/if}
</Graphic>
<Text>{menu.name}</Text>
</Item>
<script>
import { Item, Text, Graphic } from '@smui/list';
import PlusCircleOutline from 'svelte-material-icons/PlusCircleOutline.svelte';
import MinusCircleOutline from 'svelte-material-icons/MinusCircleOutline.svelte';
import { location, push } from 'svelte-spa-router';
import { createEventDispatcher } from 'svelte';
export let menu;
export let activeMenuId;
const dispatch = createEventDispatcher();
// url 변경 혹은 자식 메뉴 toggle
const setActive = () => {
let url = `/${menu.name}`;
let temp = menu;
while (temp.parent) {
temp = temp.parent;
url = `/${temp.name}${url}`;
}
if ($location === url) {
if (menu.children.length > 0) {
dispatch('toggle');
}
} else {
push(url);
}
};
</script>
<style>
:global(.aside-menu-item .material-icons) {
margin-right: 8px;
}
:global(.aside-menu-item .material-icons *) {
width: 0.7em;
height: 0.7em;
}
</style>
원래는 메뉴 데이터가 열릴때 - 자식 메뉴들이 노출될때 애니메이션도 추가하고 싶었는데... 나중에 하자.