TabNav
진행 중 (TabNav component — work in progress)
- 페이지 본문 영역에 자리 잡는 설정 기반(config-driven) 탭 바. Figma
.ds-tab-nav스타일. - 호스트가
getTabs/getActive/onSelect만 넘기면 렌더 — 탭별 슬롯(아이콘·텍스트·dot·badge·disabled)은 있으면 켜지는 on/off 구조. - 모든 색·폰트·간격은
.ds-tab-*토큰에서 옴 (마크업에 인라인 스타일 없음). - PR #247에서 신규 추가. 같은 PR에서 이중 밑줄 함정을 공유 파일에서 일원화(함정 참고).
- ⚠️ 이 문서는 웹 전용입니다. 모바일 앱에서는 탭이 독립 컴포넌트가 아니라 상단 헤더의 일부입니다 —
nav.ds-tab-nav를 본문에 두지 말고 AppHeader의getTabs를 쓰세요(탭 줄). 탭 아이템 스타일(.ds-tab-item)만 양쪽이 공유합니다.
규칙 (강제) · 코딩 에이전트용 — 사람은 접힌 채로 두어도 됩니다
TabNav 강제 규칙. 상세는 아래 본문.
마크업
- 호스트가
x-data="tabNav({...})"로 감싸고packages/components/html/tab-nav.html을 include 합니다..ds-tab-nav마크업을 손으로 짓지 마세요. - 활성 클래스는 문자열
tab-active입니다(tab-nav.html의:class바인딩). ⚠️ SubTab은is-active로 다릅니다 — 헷갈리지 마세요. - testid는 자동 생성(
tabnav-<id>)이에요 — 직접 달지 마세요. - 옵션:
size(기본md) ·divider(기본true) ·fill(기본false).
⚠️ 트랩 ① 탭 id는 4곳에서 완전히 같은 문자열이어야 합니다 (실제로 터진 버그) 탭 id는 동시에 ① TabNavItem.id ② TAB_IDS의 URL 세그먼트 ③ x-show="activeTab === '...'" 비교 문자열 ④ loadTabData() 분기 — 네 군데의 리터럴이 전부 일치해야 합니다. 하나라도 어긋나면 탭이 조용히 백지로 렌더됩니다(에러 없음).
- 실제 사고: 스케줄러 탭 id를
schedule→scheduler로 바꿨는데x-show비교 7개가 남아서, 커밋 두 개(48b1c507,8cd003f2)로 겨우 고쳤어요. - id 변경은 원자적으로 — 레포 전체(
.ts·.html·라우터)에서 옛 id가 0건이 될 때까지 한 번에 바꾸세요.
⚠️ 트랩 ② popstate는 activeTab만 세팅하면 안 됩니다 뒤로/앞으로 가기 핸들러는 activeTab 세팅 + 그 탭의 데이터 로더 호출을 같이 해야 해요. 라우터 스토어는 path만 동기화하지 데이터를 안 불러옵니다 — activeTab만 바꾸면 패널이 빈 채로 남아요. 올바른 예: team-manager · hr-system app.ts의 window.addEventListener("popstate", …) 핸들러 (둘 다 탭 세팅 뒤 데이터 로더를 호출합니다).
- URL 별칭(
/overtime·/offsite→work)을 쓴다면 라우터와initialTabFromUrl양쪽에 같은 매핑을 넣어야 합니다. 한쪽만 넣으면 popstate가 빈 화면을 만들어요.
⚠️ 트랩 ③ 탭은 의도적으로 각진 모양입니다.ds-tab-item은 전역 <button> 4px radius 강제(design-system.css의 button:not([class*="bg-"]):not(.ds-tab-item)… 규칙)에서 제외돼 있고, .ds-tab-item 블록 자체가 border-radius: 0 !important를 씁니다. rounded-* 유틸을 붙이지 마세요(안 먹습니다). 그 셀렉터의 :not(.ds-tab-item) 제외를 지우지도 마세요.
⚠️ 트랩 ④ 죽은 CSS 셀렉터 — 여기 고쳐도 아무 일도 안 일어납니다 admin · team-manager main.css의 nav.ds-tab-nav > button.ds-tab-item 셀렉터(자식 결합자 > 주의)는 아무것도 매치하지 않습니다. 공유 파셜은 버튼을 한 단계 더 깊이(nav > div > template > button) 넣거든요. 탭 색을 여기서 고치려 하지 마세요. (자손 결합자를 쓰는 nav.ds-tab-nav .ds-tab-item::after는 살아 있습니다 — 헷갈리지 마세요.)
⚠️ 트랩 ⑤ admin은 탭 크기를 덮어씁니다 문서의 md=40px는 정본 기준이에요. admin main.css의 최상위 .ds-tab-item 블록(min-height: 34px · font-size: 13px)이 이를 덮어씁니다. ⚠️ 같은 파일에 nav.ds-tab-nav > button.ds-tab-item으로 시작하는 블록도 있는데 그건 트랩 ④의 죽은 셀렉터예요 — 자식 결합자가 없는 쪽이 살아있는 오버라이드입니다. 크기 얘기를 할 땐 앱을 확인하세요.
가시성은 allowlist로x-show는 activeTab === 'employees'처럼 켤 탭을 지목하세요. activeTab !== '...' 방식(blacklist)은 나중에 추가되는 탭에 자동으로 열려버려요(fail-open). 실제로 admin dashboard.html의 x-show="activeTab !== 'embed'"가 그런 형태예요.
시각 미리보기
아래는 실제로 클릭되는 라이브 미리보기입니다 — 프로덕션과 동일한
.ds-tab-*클래스로 렌더되어 활성 전환·hover·badge·dot·:has()이중밑줄 억제가 그대로 동작합니다. 탭을 클릭해 상태(status) 변화를 확인하세요.
0) 인터랙티브 — size · divider
상단 체크박스로 ml 크기(해제 = md)와 divider(하단 구분선)를 켜고 꺼 보세요.
활성 탭: 연차 현황 — 탭을 클릭해 전환해 보세요
1) 기본 — 2탭 (admin 문서관리 + 발급내역)
활성 탭: 문서 관리 — 탭을 클릭해 전환해 보세요
2) 슬롯 — leadingIcon · badge · updateDot · disabled
활성 탭: 전체 — 탭을 클릭해 전환해 보세요
3) 보조 텍스트 — leadingText / trailingText
활성 탭: 1월 — 탭을 클릭해 전환해 보세요
단일 출처
| 파트 | 파일 |
|---|---|
| Alpine 컴포넌트 | packages/components/src/components/TabNav.ts |
| HTML partial | packages/components/html/tab-nav.html |
| 스타일 | packages/static/styles/design-system.css (.ds-tab-*) |
| Alpine 등록 | tabNav (plugin.ts) |
토큰 소유권: color·typography·탭 전용 geometry(padding/min-height/underline)는 DS 소유(
.ds-tab-*클래스). 마크업의 레이아웃(flex·gap)만 Tailwind 유틸. arbitrary[...]·하드코딩 hex 없음.
구조
┌─ nav.ds-tab-nav ────────────────────────────────────────────────┐
│ [leadingIcon] [leadingText] 라벨•dot [trailingText] [trailingIcon] [badge] │
│ └─ .ds-tab-item (각 슬롯은 present일 때만 렌더) ────────────────┘ │
│ 활성 탭만 하단 2px 바(.ds-tab-underline) + neutral-900 색 │
└──────────────────────────────────────────────────────────────────┘- 탭 단위 확장: 한 탭은 맨몸 라벨일 수도, 아래 슬롯을 자유 조합할 수도 있음 — 각 슬롯은 값이 있을 때만 켜짐.
leadingIcon/trailingIcon→ 아이콘 레지스트리 SVG (앞/뒤)leadingText/trailingText→ 라벨 옆 작은 보조 텍스트 (앞/뒤)updateDot→ 라벨 우상단 "새 글/안 읽음" 점 (오렌지)badge→ 카운트 pilldisabled→ 비활성(클릭 무시 + opacity 40%)
- 활성 상태: 텍스트
neutral-300 → neutral-900, 하단 2px 바 표시. - hover·pressed: 회색 배경 틴트(
--ds-hover-tint-16/-30). 모바일 앱 셸 탭은 예외로 이 틴트를 쓰지 않습니다 — 터치 기기에서:hover가 탭한 뒤에도 남아 선택하지 않은 탭에 회색 박스가 붙은 것처럼 보이기 때문입니다(AppHeader — 배경 틴트 없음).
크기 (size)
탭은 2단계 크기예요. tabNav({ size })로 지정하고(기본 md), 각 .ds-tab-item에 size 클래스가 붙어요. size축은 padding·높이·라벨 폰트만 바뀌고 색·언더라인 두께(2px)·hover는 공통이에요.
| token | min-height | padding | 라벨 폰트 | 클래스 |
|---|---|---|---|---|
md (기본) | 40px | 10px 12px | --text-b5 (13px) | .ds-tab-item (modifier 없음) |
ml | 48px | 12px 16px | --text-b4 (14px) | .ds-tab-item--ml |
활성 하단바(.ds-tab-underline)는 nav 하단 구분선과 겹치도록 콘텐츠박스 바닥 + 보더 1px만큼 내려요(md -12px / ml -15px) — size 클래스만 바꾸면 자동 정렬돼요. (활성 탭은 회색 1px선을 2px 검정바가 덮어 한 줄로 보여요)
같은 탭을 md(위) / ml(아래) 로 나란히 둔 비교예요. 탭 바 높이(40 → 48)와 라벨 글자(13 → 14px) 차이를 직접 확인하세요. 둘 다 실제
.ds-tab-*클래스로 렌더돼요.
md — 40px · 라벨 13px(b5) (기본)
ml — 48px · 라벨 14px(b4)
하단 구분선 (divider)
탭 바 하단의 회색 1px 선(--color-neutral-200)은 DS(nav.ds-tab-nav)가 소유하고, tabNav({ divider })로 켜고 끌 수 있어요(기본 true). 끄면 nav에 .ds-tab-nav--flush가 붙어 선이 사라지고 아래 콘텐츠와 flush로 붙어요 — 탭 바 바로 아래에 자체 상단선을 가진 영역을 둘 때 이중선을 피할 수 있어요.
활성 바는 구분선과 별개예요
활성 탭의 2px 검은 하단바(.ds-tab-underline)는 이 회색 구분선과 다른 요소(활성 표시)예요. 그래서 활성 탭 아래는 1px 회색이 아니라 2px 검정으로 보여요 — divider를 꺼도 활성 바는 그대로 남아요.
divider: true (기본)
활성 탭: 연차 현황 — 탭을 클릭해 전환해 보세요
divider: false (선 제거)
활성 탭: 연차 현황 — 탭을 클릭해 전환해 보세요
호출 패턴
<div x-data="tabNav({
getTabs: () => myTabs, // () => TabNavItem[]
getActive: () => activeId, // () => string
onSelect: (id) => switchTo(id), // (id) => void
size: 'ml', // 'md'(기본) | 'ml' — 선택
divider: false, // true(기본, 하단선) | false(선 제거) — 선택
})" class="shrink-0">
<!-- include "../../packages/components/html/tab-nav.html" -->
</div>→ partial 한 줄로 nav.ds-tab-nav 전체가 렌더됨. 탭 목록·활성 ID는 호스트 상태에 반응(reactive).
API
tabNav(opts: TabNavOptions) Alpine.data factory.
TabNavOptions (모두 optional)
| Prop | 타입 | 설명 |
|---|---|---|
getTabs | () => TabNavItem[] | 반응형 탭 목록 source |
getActive | () => string | 반응형 활성 탭 id source |
onSelect | (id: string) => void | 비활성 아닌 탭 클릭 시 id로 호출 |
size | 'md' | 'ml' | 탭 크기. 생략 시 md(40px); ml은 큰 탭(48px). 각 탭에 tabNavSizeClass로 반영 |
divider | boolean | 하단 구분선(회색 1px). 생략 시 true; false면 선 제거(flush). nav에 tabNavDividerClass로 반영 |
TabNavItem (탭 1개)
| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
id | string | ✅ | onSelect에 넘어가는 고유 id |
label | string | ✅ | 메인 탭 텍스트 |
leadingIcon | string | 앞 SVG 아이콘 이름 (레지스트리). 생략 시 숨김 | |
trailingIcon | string | 뒤 SVG 아이콘 이름. 생략 시 숨김 | |
leadingText | string | 라벨 앞 보조 텍스트. 생략 시 숨김 | |
trailingText | string | 라벨 뒤 보조 텍스트. 생략 시 숨김 | |
updateDot | boolean | 라벨에 "업데이트/안 읽음" 점 표시 | |
badge | string | number | pill 배지 값(예: 카운트). 비거나 생략 시 숨김 | |
disabled | boolean | 클릭 무시 + 흐리게 |
컴포넌트가 노출하는 메서드 (partial이 사용)
| 멤버 | 설명 |
|---|---|
tabNavItems | getTabs() 결과 (getter) |
tabNavActiveId | getActive() 결과 (getter) |
tabNavIsActive(id) | 활성 여부 |
tabNavSelect(item) | disabled면 무시, 아니면 onSelect(item.id) |
tabNavIconSvg(name) | 아이콘 이름 → SVG 문자열 |
tabNavHasBadge(item) | badge가 비어있지 않은지 |
tabNavSizeClass | size 모디파이어 클래스(""=md / "ds-tab-item--ml"=ml) (getter) |
tabNavDividerClass | 하단 구분선 모디파이어(""=on / "ds-tab-nav--flush"=off) (getter) |
사용된 토큰
| 카테고리 | 토큰 |
|---|---|
| Color (text) | --color-neutral-300(비활성) · --color-neutral-900(활성) · --color-neutral-400/500(보조 라벨) |
| Color (accent/bg) | --color-orange-500(dot) · --ds-tint-light-20(badge bg) · --ds-hover-tint-16/30(hover/active) · --color-neutral-200(nav 하단선) |
| Typography | --text-b5(13px, md 라벨) · --text-b4(14px, ml 라벨) · --text-b6(12px, 보조 라벨) |
| Geometry | md: padding 10px 12px · min-height 40px / ml: padding 12px 16px · min-height 48px · underline 2px (탭 전용, .ds-tab-item / .ds-tab-item--ml에 직접 정의) |
검토 필요 사항
토큰 체계가 아직 커버 못 해 하드코딩으로 남은 항목 (CSS 주석에 명시됨):
- 🟡 badge font-size: 10px — 타입 스케일 최하단(b6=12px)보다 작아 매칭 토큰 없음
- 🟡 dot 5px + 위치 오프셋(top -2px / right -7px) — 마이크로 사이즈, 토큰 없음
- 🟡 underline bottom: -11px — nav 높이에 맞춘 매직 오프셋
- ✅ min-height 40px(md) / 48px(ml) — 폼 컨트롤은 PR #247에서 32px로 통일됐지만
.ds-tab-item은 의도적으로 제외(탭 기준은 40px, ml은 48px) - 🟡 underline 오프셋 md
-12px/ ml-15px— nav 하단 구분선(1px)과 겹치도록 탭 높이에 맞춘 매직 오프셋(토큰화 대상)
함정 (double underline)
활성 밑줄을 그리는 방법이 두 개 공존한다:
- 레거시
.ds-tab-item::after(flex +overflow-x:auto탭에선 가장자리에서 잘릴 수 있어 불안정) - 공유 컴포넌트가 렌더하는 명시적
.ds-tab-underlinespan
둘이 겹치면 이중 밑줄이 난다. PR #247에서 공유 파일 한 곳에 다음 규칙으로 일원화:
.ds-tab-item:has(.ds-tab-underline)::after { display: none; }→ span을 쓰는 탭(공유 tabNav)은 ::after를 끄고, span 없는 커스텀 nav는 ::after 유지. 이전엔 앱마다 자기 main.css에서 각자 끄던 것을 공유 DS로 흡수.
⚠️ :has() 미지원 브라우저(Safari <15.4 등)에선 이 억제가 no-op → 최악의 경우 이중 밑줄만 재발(레이아웃·기능 영향 없음). 타깃 브라우저 baseline 확인 권장.
사용처
- admin 문서관리/발급내역 (apps/admin/src/views/tabs/documents.html) — PR #247 핵심: 두 형제 메뉴를 한 페이지 두 탭으로 통합한 탭 바
- admin 설정 (apps/admin/src/views/tabs/settings.html) · 스케줄러 (scheduler.html)
- hr-system 대시보드 (apps/hr-system/src/views/dashboard.html)
- team-manager 대시보드 (apps/team-manager/src/views/dashboard.html)
목표 & 로드맵
| 항목 | 현재 | 목표 |
|---|---|---|
| 설정 기반 구조 | ✅ getTabs/getActive/onSelect | 유지 |
| 탭별 슬롯 on/off | ✅ icon/text/dot/badge/disabled | 유지 |
| Color 토큰 매핑 | ✅ 완료 | 유지 |
| Typography 토큰 매핑 | ✅ b5(md)/b4(ml)/b6 | 유지 |
| 크기 변형 (size) | ✅ 2단계 md/ml | 유지 |
| 하단 구분선 on/off (divider) | ✅ DS 소유 + --flush | 유지 |
| 이중 밑줄 일원화 | ✅ :has() 공유 규칙 | 유지 (:has() baseline 확인) |
| badge/dot 마이크로 토큰 | 🟡 하드코딩 | 마이크로 사이즈 토큰 정책 결정 후 적용 |
| underline 오프셋 토큰화 | 🟡 md -12px / ml -15px 매직값(구분선과 overlap) | nav 높이 토큰과 연동 검토 |
변경 이력
| 날짜 | 내용 |
|---|---|
| 2026-06-30 | 탭 크기 2단계 변형(size) 추가 — md(기본 40px) / ml(48px, .ds-tab-item--ml). tabNav({ size }) 옵션 + tabNavSizeClass getter, <DemoTabNav size="ml"> 미리보기. ml 활성 하단바 오프셋 -14px 재계산 |
| 2026-06-30 | 하단 구분선 on/off(divider) 추가 — 회색 1px 선을 markup border-b → DS 소유(nav.ds-tab-nav)로 이전, .ds-tab-nav--flush off 모디파이어 + tabNav({ divider }) 옵션 + tabNavDividerClass getter, <DemoTabNav :divider="false"> 미리보기 |
| 2026-06-08 | TabNav 페이지 신규 작성 + 라이브 미리보기(<DemoTabNav>) 적용 (PR #247 기준) |
| PR #247 | TabNav 공유 컴포넌트 신규 (tabNav + tab-nav.html + .ds-tab-* 토큰). 이중 밑줄 :has() 일원화. ds-tab 색/폰트 토큰화 |