좌측 메뉴 리스트 - 마무리

손대중·2022년 6월 27일
0

이제 실제 메뉴 데이터를 가지고 메뉴 리스트를 렌더링하는 로직을 추가해보자.

요구사항은 아래와 같다. (말로 푸니까 어려운데 실제로는 매우 직관적인 요구사항임)

  • 메뉴 depth 에 따라 좌측 공백을 추가
  • 자식 메뉴들 노출 여부에 따라 +- 아이콘 노출
  • 자식 메뉴들이 없는 - leaf 메뉴인 경우 아이콘 미노출
  • 메뉴 클릭시
    • url 변경
      • url 은 선택된 메뉴부터 최초의 부모 메뉴들의 name 으로 구성
      • ex) 메뉴1/메뉴1-1/메뉴1-1-1/......
    • 자식 메뉴들 노출 / 미노출
    • activate 표시

모든 로직들을 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 여부
  • 렌더링하기 편하게 메뉴 데이터를 1차원 배열로 재편성

tree 구조에서 1차원 배열로 변경하는 건 쉽지만, 고려해봐야 할만한 점이 몇가지가 있다.

  • 1차원 배열에는 실제 show 상태인 메뉴들만 들어가 있어야 함.
    • 즉, 메뉴가 hide 상태가 되면 1차원 배열에서 삭제되어야 함.
  • 메뉴 click 시 url 이 변경된다는 건 ???
    • 이 말은 url 에 따라 메뉴 데이터가 바뀌고 렌더링 되어야 한다는 것.
    • 즉 url 변경을 감지하고 메뉴 데이터를 구성하는 코드가 있어야 함.

현재 url 정보, url 변경을 체크하는 건 svelte-spa-routerlocation 을 사용한다. (내부에서는 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-routerpush 함수를 사용하고, 부모 컴포넌트에 이벤트 전달하는 건 sveltecreateEventDispatcher 를 사용한다. (createEventDispatcherhttps://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>

원래는 메뉴 데이터가 열릴때 - 자식 메뉴들이 노출될때 애니메이션도 추가하고 싶었는데... 나중에 하자.

0개의 댓글