Skip to content

Mobile Navigation

@jennifersoft/apm-components의 모바일 GNB 문서입니다. 이 문서는 @jennifersoft/apm-components@1.4.1 기준으로 정리했습니다.

데스크톱 GNB는 NavigationBar를 참고하세요. 모바일은 하단 탭 바(MobileNavigationBar)와 바텀시트(MobileNavigationSheet) 두 컴포넌트로 구성됩니다.

인터랙티브 데모

IMPORTANT

MobileNavigationSheet는 내부에서 <Teleport to="body"> 를 사용합니다. 따라서 위 프레임 안에 넣어도 시트는 body로 빠져나가 화면 전체를 덮습니다. 데모에서 더보기를 누르면 문서 전체 위에 시트가 뜨는 것이 정상 동작입니다. 조상 요소에 transform이나 overflow: hidden을 줘도 가둘 수 없습니다.

실제 앱에서는 시트가 뷰포트를 덮는 것이 의도된 동작이므로 문제가 되지 않지만, 시트를 특정 영역 안에 가두려는 시도는 통하지 않는다는 점을 알고 있어야 합니다.

메뉴 데이터 계약

typescript
interface MobileNavigationItem {
    key: MobileNavigationItemKey;
    label: string;
    icon: IconTypes;          // @jennifersoft/vue-components-v2의 ICON_TYPE
    route: string | null;     // null이면 라우팅 없이 select만 발생
    selected?: boolean;
    disabled?: boolean;
    badge?: number | string;  // 숫자는 99를 넘으면 99+로 표시
    spotlight?: boolean;      // 강조 표시(예: Chat)
}

const MOBILE_NAVIGATION_ITEM_KEY = {
    DASHBOARD: 'dashboard',
    CUSTOM_DASHBOARD: 'custom-dashboard',
    CHAT: 'chat',
    TALK: 'talk',
    NOTIFICATIONS: 'notifications',
    SETTINGS: 'settings',
    MORE: '__more__',
} as const;

MobileNavigationItemKey는 위 상수 외에 임의 문자열도 허용합니다(string & {}).

기본 메뉴와 노출 순서

메뉴 구성은 하나로 통일되어 있습니다. mobilePrimaryItems를 넘기지 않으면 아래 내장 기본 메뉴가 그대로 쓰입니다.

typescript
MOBILE_NAVIGATION_ITEM_SEQUENCE
// ['dashboard', 'custom-dashboard', 'talk', 'notifications', 'settings']
key기본 라벨route
dashboardDashboard/mobile/dashboard
custom-dashboardCustom dashboard/mobile/dashboard/custom
talkTalk/mobile/talk
notificationsNotifications/mobile/notifications
settingsSettings/mobile/settings

기본 메뉴는 영문 라벨이므로, 다국어가 필요한 제품 앱은 번역한 메뉴를 직접 넘겨야 합니다.

IMPORTANT

mobilePrimaryItems를 넘겨도 최종 노출 항목과 순서는 mobilePrimaryItemSequence가 결정합니다. 이를 생략하면 위 내장 순서가 쓰이고, 그 순서에 없는 key는 넘겨도 렌더되지 않습니다. 내장 순서에 없는 메뉴(예: chat)를 넣으려면 mobilePrimaryItemSequence를 함께 넘기세요.

vue
<MobileNavigationBar
    :mobile-primary-items="primaryItems"
    :mobile-primary-item-sequence="['dashboard', 'chat', 'settings']"
/>

MobileNavigationBar

하단 고정 탭 바입니다.

Props

Prop타입기본값설명
mobilePrimaryItemsreadonly MobileNavigationItem[]내장 기본 메뉴탭 바에 노출할 항목
mobilePrimaryItemSequencereadonly MobileNavigationItemKey[]내장 순서노출 항목과 순서를 결정
mobileOverflowItemsreadonly MobileNavigationItem[]undefined더보기 시트로 밀어낼 항목
selectedKeyMobileNavigationItemKey | nullundefined현재 선택 항목
sheetOpenbooleanfalse시트 열림 상태 (v-model)
mobileMaxBarWidthnumber512바 최대 폭
sheetIdstring'mobile-navigation-sheet'aria-controls 연결용
ariaLabelstring'Mobile primary navigation'
classstringundefined

이벤트

typescript
emit('select', item: MobileNavigationItem)
emit('open-sheet')
emit('update:sheetOpen', value: boolean)

밀도 자동 전환

항목 폭이 MOBILE_NAVIGATION_DENSITY_THRESHOLD(62px) 아래로 좁아지면 density'compact'로 자동 전환되어 라벨 표시가 줄어듭니다. ResizeObserver로 관측하므로 호출자가 계산할 필요는 없습니다.

배지는 MOBILE_NAVIGATION_BADGE_MAXIMUM(99)을 넘으면 잘라서 표시합니다.

MobileNavigationSheet

더보기 및 하위 메뉴를 표시하는 바텀시트입니다.

Props

Prop타입기본값설명
openboolean— (필수)열림 여부
stackreadonly MobileNavigationSheetStackItem[]— (필수)탐색 단계 스택
dismissOnDragbooleanundefined드래그로 닫기 허용
sheetIdstring'mobile-navigation-sheet'
ariaLabelstring'Mobile navigation sheet'
backLabelstring'Back'
closeLabelstring'Close'
expandablebooleanfalse그래버를 위로 끌어 전체 높이로 확장
expandedbooleanfalse확장 상태 (v-model)
dragZoneHeightnumber0패널 상단에서 드래그를 시작할 수 있는 높이(px)
classstringundefined
typescript
interface MobileNavigationSheetStackItem {
    key: string;
    title: string;
    items: readonly MobileNavigationItem[];
    parentKey?: string;
}

stack마지막 원소가 현재 화면입니다. 하위 메뉴로 들어가면 push, back을 받으면 pop 하는 구조입니다.

이벤트

typescript
emit('select', item: MobileNavigationItem)
emit('back')
emit('close')
emit('update:expanded', expanded: boolean)

dragZoneHeight

기본값 0이면 그래버만 잡을 수 있습니다. 시트가 자체 헤더를 그리고 그 영역까지 손잡이로 쓰고 싶을 때, 그래버를 포함한 전체 높이를 지정합니다.

IMPORTANT

드래그 영역 안이라도 버튼 같은 조작 요소를 누른 경우에는 드래그를 시작하지 않습니다. 헤더에 버튼을 배치해도 클릭이 먹히므로 dragZoneHeight를 줄일 필요가 없습니다.

드래그로 패널 높이의 1/3(MOBILE_NAVIGATION_SHEET_DRAG_CLOSE_RATIO) 이상 내리면 닫힙니다.

본문 스크롤 잠금

시트가 열린 동안 배경이 스크롤되지 않도록 잠그는 유틸입니다. 호출 횟수를 세는 방식이라 중첩 호출이 안전하고, 마지막 unlock에서만 원래 값으로 복구합니다.

typescript
import {
    lockMobileBodyScroll,
    unlockMobileBodyScroll,
} from '@jennifersoft/apm-components';

onMounted(lockMobileBodyScroll);
onUnmounted(unlockMobileBodyScroll);

MobileNavigationSheet는 내부에서 이미 사용합니다. 직접 만든 오버레이에만 호출하세요.

사용 예제

vue
<script setup lang="ts">
import { ref, computed } from 'vue';
import {
    MobileNavigationBar,
    MobileNavigationSheet,
} from '@jennifersoft/apm-components';
import type {
    MobileNavigationItem,
    MobileNavigationItemKey,
    MobileNavigationSheetStackItem,
} from '@jennifersoft/apm-components';

const sheetOpen = ref(false);
const expanded = ref(false);
const selectedKey = ref('dashboard');
const stack = ref<MobileNavigationSheetStackItem[]>([]);

// 노출 항목과 순서는 sequence 가 결정한다. 내장 순서에 의존하지 않도록 직접 지정한다
const primaryItemSequence: MobileNavigationItemKey[] = [
    'dashboard',
    'talk',
    'notifications',
    'settings',
];

const primaryItems: MobileNavigationItem[] = [
    { key: 'dashboard', label: '대시보드', icon: ICON_TYPE.dashboard, route: '/mobile/dashboard' },
    { key: 'talk', label: '토크', icon: ICON_TYPE.talk, route: '/mobile/talk' },
    { key: 'notifications', label: '알림', icon: ICON_TYPE.bell, route: '/mobile/notifications' },
    { key: 'settings', label: '설정', icon: ICON_TYPE.settings, route: '/mobile/settings' },
];

const overflowItems: MobileNavigationItem[] = [
    { key: 'about', label: '정보', icon: ICON_TYPE.info, route: null },
];

function onSelect(item: MobileNavigationItem) {
    if (item.route === null) return;
    selectedKey.value = item.key;
    router.push(item.route);
}

function onOpenSheet() {
    stack.value = [{ key: 'more', title: '더보기', items: overflowItems }];
    sheetOpen.value = true;
}
</script>

<template>
    <MobileNavigationBar
        :mobile-primary-items="primaryItems"
        :mobile-primary-item-sequence="primaryItemSequence"
        :mobile-overflow-items="overflowItems"
        :selected-key="selectedKey"
        v-model:sheet-open="sheetOpen"
        @select="onSelect"
        @open-sheet="onOpenSheet"
    />

    <MobileNavigationSheet
        :open="sheetOpen"
        :stack="stack"
        expandable
        v-model:expanded="expanded"
        dismiss-on-drag
        back-label="뒤로"
        close-label="닫기"
        @select="onSelect"
        @back="stack.pop()"
        @close="sheetOpen = false"
    />
</template>

상수

typescript
import {
    MOBILE_NAVIGATION_ITEM_KEY,
    MOBILE_NAVIGATION_MORE_ITEM,
    MOBILE_NAVIGATION_DEFAULT_MAX_BAR_WIDTH,   // 512
    MOBILE_NAVIGATION_DENSITY_THRESHOLD,       // 62
    MOBILE_NAVIGATION_BADGE_MAXIMUM,           // 99
    MOBILE_NAVIGATION_DEFAULT_SHEET_ID,
    MOBILE_NAVIGATION_DEFAULT_ARIA_LABEL,
    MOBILE_NAVIGATION_DEFAULT_SHEET_ARIA_LABEL,
    MOBILE_NAVIGATION_DEFAULT_BACK_LABEL,
    MOBILE_NAVIGATION_DEFAULT_CLOSE_LABEL,
} from '@jennifersoft/apm-components';

NOTE

1.4.1에서 MOBILE_NAVIGATION_TYPEmobileNavigationType prop이 제거되었습니다. LLM 사용 여부로 GNB를 나누던 구성이 하나로 통일되면서, MOBILE_NAVIGATION_DEFAULT_ITEMSMOBILE_NAVIGATION_ITEM_SEQUENCE도 타입별 맵에서 단일 배열로 바뀌었습니다.

주의사항

  • MobileNavigationSheet<Teleport to="body">로 렌더됩니다. 조상의 transform / overflow로 가둘 수 없고, 항상 뷰포트를 덮습니다.
  • 메뉴는 mobilePrimaryItems + mobilePrimaryItemSequence로 결정합니다. 1.4.1에서 mobileNavigationType prop 은 제거되었습니다.
  • 넘긴 항목이 안 보이면 대부분 mobilePrimaryItemSequence에 그 key가 없어서입니다.
  • 내장 기본 메뉴 라벨은 영문입니다. 다국어 앱은 mobilePrimaryItems를 직접 번역해 넘기세요.
  • route: null 항목은 라우팅 없이 select만 발생합니다. 모달 열기 등에 사용합니다.
  • stack을 직접 mutate하지 말고 새 배열로 교체하는 편이 전환 애니메이션과 잘 맞습니다.