Skip to content

BottomNav

진행 중 (BottomNav component — work in progress)

  • 모바일 하단 탭바. 아이콘 위에 라벨을 세로로 쌓고, 하단 safe-area(노치/홈 인디케이터)를 흡수하는 앱 셸 크롬.
  • 항목은 전부 설정 기반(config-driven) — 호스트가 getItems / getActive / onSelect 만 넘기면 렌더돼요. 탭을 더하거나 빼는 일이 배열 수정으로 끝납니다.
  • 모든 색·크기·타이포는 .ds-bottom-nav* 토큰에서 옵니다 (인라인 스타일·하드코딩 hex·!important 없음).
  • 현재 소비 앱은 employee-app 하나입니다. 두 번째 모바일 앱이 생기기 전까지는 사실상 전용이지만, 정의는 공유 DS에 있습니다.
규칙 (강제) · 코딩 에이전트용 — 사람은 접힌 채로 두어도 됩니다

BottomNav 강제 규칙. 상세는 아래 본문.

마크업

  • 호스트가 x-data="bottomNav({...})"로 감싸고 packages/components/html/bottom-nav.html을 include 합니다. 손으로 짓지 마세요.
  • 활성 클래스는 is-active 입니다. ⚠️ TabNavtab-active 로 다릅니다.
  • 탭 목록을 템플릿에 하드코딩하지 마세요. 항목은 getItems()가 주는 배열에서만 옵니다. 앱은 이 배열을 별도 config 파일에 두세요(employee-app은 apps/employee-app/src/nav-config.ts).

⚠️ 트랩 1 — 이름이 비슷한 .ds-tabbar는 전혀 다른 컴포넌트입니다.ds-tabbarheight:48px + border-bottom + 밑줄 인디케이터를 가진 상단 탭이에요. 하단 탭바가 아닙니다(그리고 저장소 전체에서 사용처 0). 하단 내비를 만들 때 .ds-tabbar를 쓰면 안 됩니다 — 세로 스택도, safe-area도, 배지 슬롯도 없어요.

⚠️ 트랩 2 — 래퍼에 class="contents"가 없으면 레이아웃이 깨집니다 앱 셸 컨테이너가 display:flex; flex-direction:column 인 경우, x-data 래퍼 <div>가 flex 자식으로 끼어들어 .ds-bottom-navflex-shrink:0이 래퍼에 가려집니다. 래퍼에 class="contents"(display:contents)를 붙여 자식 <nav>를 직접 flex 자식으로 올리세요.

⚠️ 트랩 3 — 키보드 대응은 앱 소유입니다 네이티브 키보드가 뜰 때 하단 내비를 숨기는 규칙(body.keyboard-open .ds-bottom-nav { display:none })은 DS가 아니라 앱 main.css 에 있습니다(Capacitor 전용 상태이므로). 클래스명을 바꾸면 이 규칙도 같이 고쳐야 해요 — 안 고치면 키보드 위에 내비가 남습니다.

아이콘

  • iconSvg(raw SVG)가 icon(레지스트리 이름)보다 우선합니다. 활성 상태는 activeIconSvgactiveIcon → 기본 아이콘 순으로 폴백해요.
  • 크기는 래퍼 .ds-bottom-nav__icon이 소유합니다. 주입하는 svg에 w-6 h-6 같은 크기 클래스를 넣지 마세요.
  • 아이콘에 색을 넣지 마세요. currentColor만 쓰고, 색은 .ds-bottom-nav__item / .is-active가 줍니다.
  • 활성용 fill 글리프는 비활성 글리프와 중심을 맞추세요. 어긋나면 탭 전환 때 아이콘이 미세하게 움직입니다(실제로 메신저 아이콘이 1.125 어긋나 있었습니다 — 아이콘 참고).

  • 셸 폭은 --ds-app-shell-max-width(기본 430px)로 오버라이드합니다. max-width를 직접 덮어쓰지 마세요.

시각 미리보기

실제로 눌리는 라이브 미리보기입니다 — 프로덕션과 동일한 .ds-bottom-nav* 클래스로 렌더되고, 폭도 실제 앱 셸과 같은 430px입니다. 탭을 눌러 전환해 보고, 상단 체크박스로 활성 fill 아이콘을 껐다 켜며 outline과 비교해 보세요.

본문 영역

활성 탭: 근무— 탭을 눌러 전환하고, 위 체크박스로 fill 아이콘을 비교해 보세요

데모의 한계

아이콘은 employee-app nav-config.ts스냅샷이라 앱 config가 바뀌어도 따라오지 않습니다. 하단 safe-area 패딩(env(safe-area-inset-bottom))은 데스크톱 브라우저에서 0이라 눈에 보이지 않고, 네이티브 키보드가 뜰 때 내비가 숨는 동작도 여기서는 재현되지 않습니다.

단일 출처

파트파일
Alpine 컴포넌트packages/components/src/components/BottomNav.ts
HTML partialpackages/components/html/bottom-nav.html
스타일packages/static/styles/design-system.css (.ds-bottom-nav*)
Alpine 등록bottomNav (plugin.ts)
호출처 config 예시apps/employee-app/src/nav-config.ts

토큰 소유권: color·typography·geometry(높이/패딩/아이콘 24px)·배지는 모두 DS 소유(.ds-bottom-nav*). arbitrary [...]·하드코딩 hex·새 !important 없음.

구조

┌─ nav.ds-bottom-nav ─────────────────────────────────────┐
│  border-top · z-index --ds-z-nav · padding-bottom: env(safe-area-inset-bottom) │
│ ┌─ .ds-bottom-nav__list (flex, space-around, px 8) ────┐ │
│ │  ┌ .ds-bottom-nav__item (flex:1, 세로 스택) ───────┐ │ │
│ │  │   [.ds-bottom-nav__icon  24×24]                 │ │ │
│ │  │   라벨 (b6 / 12px)                               │ │ │
│ │  │   [.ds-bottom-nav__badge]  ← 우상단 절대배치      │ │ │
│ │  └ .is-active → 색 강조 + fill 아이콘 ─────────────┘ │ │
│ └──────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘

사용법

호스트가 x-data로 감싸고 partial을 include 합니다. 래퍼의 class="contents"는 필수예요(위 트랩 2).

html
<div class="contents" x-data="bottomNav({
       getItems: () => navItems,
       getActive: () => activeSection,
       onSelect: (id) => switchSection(id),
     })">
  <!-- include "../../packages/components/html/bottom-nav.html" -->
</div>

탭 목록은 앱의 config 파일에 둡니다 — 탭 추가·삭제·순서 변경이 이 배열 수정만으로 끝나야 합니다.

ts
export const BOTTOM_NAV_ITEMS: BottomNavItem[] = [
  { id: "shifts", label: "근무", iconSvg: ICON_SHIFTS, activeIconSvg: ICON_SHIFTS_ACTIVE },
  {
    id: "messenger",
    label: "메신저",
    iconSvg: ICON_MESSENGER,
    activeIconSvg: ICON_MESSENGER_ACTIVE,
    badge: () => Alpine.store("messenger").unreadCount,
  },
  // …
];

옵션

bottomNav(options)

옵션타입기본값설명
getItems() => BottomNavItem[][]항목 목록. 반응형으로 매 렌더 읽습니다
getActive() => string""활성 항목 id
onSelect(id: string) => void비활성(disabled) 아닌 항목을 탭했을 때
badgeMaxnumber99숫자 배지가 이 값을 넘으면 "99+" 형태로

BottomNavItem

필드타입설명
idstring고유 id. onSelect 인자이자 활성 비교 대상
labelstring아이콘 아래 라벨
iconstring?레지스트리 아이콘 이름(getIcon)
iconSvgstring?raw SVG — icon보다 우선
activeIconstring?활성 상태 레지스트리 아이콘
activeIconSvgstring?활성 상태 raw SVG — activeIcon보다 우선
badge() => number | string | null | undefined배지 값. 0·null·"" 면 숨김
hidden() => boolean항목을 목록에서 제거(권한·피처플래그)
disabledboolean?렌더하되 탭 동작 차단

hidden렌더에서 아예 빠지고, disabled보이되 눌리지 않습니다 — 권한으로 메뉴를 감출 때는 hidden, "곧 제공" 표시에는 disabled를 쓰세요.

아이콘

이중 주입 — 레지스트리 또는 raw SVG

레지스트리(icons.ts)에 있는 글리프면 icon으로 이름만 주고, 없거나 기존 화면의 모양을 픽셀 그대로 유지해야 하면 iconSvg로 직접 넘깁니다. employee-app의 4개 탭이 후자예요 — 레지스트리의 ic-clock·ic-message는 모양이 다른 별도 디자인이고 "더보기"용 점 3개는 레지스트리에 아예 없습니다.

활성 상태는 fill 타입

선택된 탭은 색만 바뀌는 게 아니라 글리프가 outline → solid(fill)로 바뀝니다. 형태로도 구분되어 현재 위치가 분명해져요.

상태svg 속성
비활성fill="none" stroke="currentColor" stroke-width="1.5"
활성fill="currentColor" (stroke 없음)

활성 변형은 선택 사항입니다. activeIconSvg/activeIcon을 주지 않은 항목은 활성일 때도 기본 아이콘을 그대로 써요.

⚠️ 중심 정렬 — 실제로 밟은 지뢰

비활성/활성 글리프의 광학 중심이 다르면 탭을 누를 때 아이콘이 흔들립니다. 그리고 한 글리프 안에서 요소끼리 어긋나는 경우도 있어요.

employee-app의 메신저 아이콘이 그랬습니다. 말풍선 원의 중심은 x=11.25인데, 점 3개는 Heroicons 원본 좌표(중심 8.25 · 12.375 · 16.5, 평균 12.375)를 그대로 물려받았습니다. 말풍선만 다른 path로 교체되면서 점이 1.125 오른쪽으로 치우친 상태로 남은 것이죠. 시작점을 M8.625M7.5로 내려 세 점의 중심을 7.125 · 11.25 · 15.375(평균 11.25)로 맞췄습니다.

fill 글리프의 구멍은 더 크게. outline에서 stroke-width:1.5로 그린 점(r=0.375)은 실제로 지름 ~2.25로 보이지만, solid에서 같은 r로 구멍을 뚫으면 지름 0.75라 거의 보이지 않습니다. 메신저 solid는 r을 1로, 더보기 solid는 0.75 → 1.5로 키웠어요.

배지

배지는 메신저 전용이 아니라 어느 항목에나 붙습니다. badge()가 반응형으로 읽히므로 스토어 값을 그대로 연결하면 돼요.

ts
badge: () => Alpine.store("messenger").unreadCount,
  • 0 · null · undefined · "" → 숨김
  • 숫자가 badgeMax(기본 99)를 넘으면 "99+"
  • 문자열은 그대로 출력 (예: badge: () => "N")

앱 소유로 남는 것

DS는 컴포넌트와 스타일만 갖습니다. 아래는 앱이 계속 소유해요.

항목위치이유
탭 목록·아이콘앱 config (nav-config.ts)앱마다 다름
body.keyboard-open 숨김main.cssCapacitor 네이티브 키보드 전용 상태
셸 폭--ds-app-shell-max-width앱 컨테이너와 맞춰야 함

관련

  • AppHeader — 같은 앱 셸의 상단 크롬
  • TabNav — 페이지 안 밑줄형 상단 탭 (활성 클래스가 tab-active로 다름)
  • SubTab — 상위 탭 아래 하위 탭