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입니다. ⚠️ TabNav는tab-active로 다릅니다. - 탭 목록을 템플릿에 하드코딩하지 마세요. 항목은
getItems()가 주는 배열에서만 옵니다. 앱은 이 배열을 별도 config 파일에 두세요(employee-app은apps/employee-app/src/nav-config.ts).
⚠️ 트랩 1 — 이름이 비슷한 .ds-tabbar는 전혀 다른 컴포넌트입니다.ds-tabbar는 height:48px + border-bottom + 밑줄 인디케이터를 가진 상단 탭이에요. 하단 탭바가 아닙니다(그리고 저장소 전체에서 사용처 0). 하단 내비를 만들 때 .ds-tabbar를 쓰면 안 됩니다 — 세로 스택도, safe-area도, 배지 슬롯도 없어요.
⚠️ 트랩 2 — 래퍼에 class="contents"가 없으면 레이아웃이 깨집니다 앱 셸 컨테이너가 display:flex; flex-direction:column 인 경우, x-data 래퍼 <div>가 flex 자식으로 끼어들어 .ds-bottom-nav의 flex-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(레지스트리 이름)보다 우선합니다. 활성 상태는activeIconSvg→activeIcon→ 기본 아이콘 순으로 폴백해요.- 크기는 래퍼
.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 partial | packages/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).
<div class="contents" x-data="bottomNav({
getItems: () => navItems,
getActive: () => activeSection,
onSelect: (id) => switchSection(id),
})">
<!-- include "../../packages/components/html/bottom-nav.html" -->
</div>탭 목록은 앱의 config 파일에 둡니다 — 탭 추가·삭제·순서 변경이 이 배열 수정만으로 끝나야 합니다.
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) 아닌 항목을 탭했을 때 |
badgeMax | number | 99 | 숫자 배지가 이 값을 넘으면 "99+" 형태로 |
BottomNavItem
| 필드 | 타입 | 설명 |
|---|---|---|
id | string | 고유 id. onSelect 인자이자 활성 비교 대상 |
label | string | 아이콘 아래 라벨 |
icon | string? | 레지스트리 아이콘 이름(getIcon) |
iconSvg | string? | raw SVG — icon보다 우선 |
activeIcon | string? | 활성 상태 레지스트리 아이콘 |
activeIconSvg | string? | 활성 상태 raw SVG — activeIcon보다 우선 |
badge | () => number | string | null | undefined | 배지 값. 0·null·"" 면 숨김 |
hidden | () => boolean | 항목을 목록에서 제거(권한·피처플래그) |
disabled | boolean? | 렌더하되 탭 동작 차단 |
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.625 → M7.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()가 반응형으로 읽히므로 스토어 값을 그대로 연결하면 돼요.
badge: () => Alpine.store("messenger").unreadCount,0·null·undefined·""→ 숨김- 숫자가
badgeMax(기본 99)를 넘으면"99+" - 문자열은 그대로 출력 (예:
badge: () => "N")
앱 소유로 남는 것
DS는 컴포넌트와 스타일만 갖습니다. 아래는 앱이 계속 소유해요.
| 항목 | 위치 | 이유 |
|---|---|---|
| 탭 목록·아이콘 | 앱 config (nav-config.ts) | 앱마다 다름 |
body.keyboard-open 숨김 | 앱 main.css | Capacitor 네이티브 키보드 전용 상태 |
| 셸 폭 | --ds-app-shell-max-width | 앱 컨테이너와 맞춰야 함 |