Skip to content

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를 본문에 두지 말고 AppHeadergetTabs를 쓰세요(탭 줄). 탭 아이템 스타일(.ds-tab-item)만 양쪽이 공유합니다.
규칙 (강제) · 코딩 에이전트용 — 사람은 접힌 채로 두어도 됩니다

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

마크업

  • 호스트가 x-data="tabNav({...})"로 감싸고 packages/components/html/tab-nav.html을 include 합니다. .ds-tab-nav 마크업을 손으로 짓지 마세요.
  • 활성 클래스는 문자열 tab-active 입니다(tab-nav.html:class 바인딩). ⚠️ SubTabis-active다릅니다 — 헷갈리지 마세요.
  • testid는 자동 생성(tabnav-<id>)이에요 — 직접 달지 마세요.
  • 옵션: size(기본 md) · divider(기본 true) · fill(기본 false).

⚠️ 트랩 ① 탭 id는 4곳에서 완전히 같은 문자열이어야 합니다 (실제로 터진 버그) 탭 id는 동시에 ① TabNavItem.idTAB_IDS의 URL 세그먼트 ③ x-show="activeTab === '...'" 비교 문자열 ④ loadTabData() 분기 — 네 군데의 리터럴이 전부 일치해야 합니다. 하나라도 어긋나면 탭이 조용히 백지로 렌더됩니다(에러 없음).

  • 실제 사고: 스케줄러 탭 id를 schedulescheduler로 바꿨는데 x-show 비교 7개가 남아서, 커밋 두 개(48b1c507, 8cd003f2)로 겨우 고쳤어요.
  • id 변경은 원자적으로 — 레포 전체(.ts·.html·라우터)에서 옛 id가 0건이 될 때까지 한 번에 바꾸세요.

⚠️ 트랩 ② popstate는 activeTab만 세팅하면 안 됩니다 뒤로/앞으로 가기 핸들러는 activeTab 세팅 + 그 탭의 데이터 로더 호출을 같이 해야 해요. 라우터 스토어는 path만 동기화하지 데이터를 안 불러옵니다 — activeTab만 바꾸면 패널이 빈 채로 남아요. 올바른 예: team-manager · hr-system app.tswindow.addEventListener("popstate", …) 핸들러 (둘 다 탭 세팅 뒤 데이터 로더를 호출합니다).

  • URL 별칭(/overtime·/offsitework)을 쓴다면 라우터와 initialTabFromUrl 양쪽에 같은 매핑을 넣어야 합니다. 한쪽만 넣으면 popstate가 빈 화면을 만들어요.

⚠️ 트랩 ③ 탭은 의도적으로 각진 모양입니다.ds-tab-item은 전역 <button> 4px radius 강제(design-system.cssbutton:not([class*="bg-"]):not(.ds-tab-item)… 규칙)에서 제외돼 있고, .ds-tab-item 블록 자체가 border-radius: 0 !important를 씁니다. rounded-* 유틸을 붙이지 마세요(안 먹습니다). 그 셀렉터의 :not(.ds-tab-item) 제외를 지우지도 마세요.

⚠️ 트랩 ④ 죽은 CSS 셀렉터 — 여기 고쳐도 아무 일도 안 일어납니다 admin · team-manager main.cssnav.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-showactiveTab === 'employees'처럼 켤 탭을 지목하세요. activeTab !== '...' 방식(blacklist)은 나중에 추가되는 탭에 자동으로 열려버려요(fail-open). 실제로 admin dashboard.htmlx-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 partialpackages/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 → 카운트 pill
    • disabled → 비활성(클릭 무시 + 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는 공통이에요.

tokenmin-heightpadding라벨 폰트클래스
md (기본)40px10px 12px--text-b5 (13px).ds-tab-item (modifier 없음)
ml48px12px 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 (선 제거)

활성 탭: 연차 현황 — 탭을 클릭해 전환해 보세요

호출 패턴

html
<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로 반영
dividerboolean하단 구분선(회색 1px). 생략 시 true; false면 선 제거(flush). nav에 tabNavDividerClass로 반영

TabNavItem (탭 1개)

필드타입필수설명
idstringonSelect에 넘어가는 고유 id
labelstring메인 탭 텍스트
leadingIconstring앞 SVG 아이콘 이름 (레지스트리). 생략 시 숨김
trailingIconstring뒤 SVG 아이콘 이름. 생략 시 숨김
leadingTextstring라벨 앞 보조 텍스트. 생략 시 숨김
trailingTextstring라벨 뒤 보조 텍스트. 생략 시 숨김
updateDotboolean라벨에 "업데이트/안 읽음" 점 표시
badgestring | numberpill 배지 값(예: 카운트). 비거나 생략 시 숨김
disabledboolean클릭 무시 + 흐리게

컴포넌트가 노출하는 메서드 (partial이 사용)

멤버설명
tabNavItemsgetTabs() 결과 (getter)
tabNavActiveIdgetActive() 결과 (getter)
tabNavIsActive(id)활성 여부
tabNavSelect(item)disabled면 무시, 아니면 onSelect(item.id)
tabNavIconSvg(name)아이콘 이름 → SVG 문자열
tabNavHasBadge(item)badge가 비어있지 않은지
tabNavSizeClasssize 모디파이어 클래스(""=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, 보조 라벨)
Geometrymd: 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)

활성 밑줄을 그리는 방법이 두 개 공존한다:

  1. 레거시 .ds-tab-item::after (flex + overflow-x:auto 탭에선 가장자리에서 잘릴 수 있어 불안정)
  2. 공유 컴포넌트가 렌더하는 명시적 .ds-tab-underline span

둘이 겹치면 이중 밑줄이 난다. PR #247에서 공유 파일 한 곳에 다음 규칙으로 일원화:

css
.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 확인 권장.

사용처

목표 & 로드맵

항목현재목표
설정 기반 구조✅ 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-08TabNav 페이지 신규 작성 + 라이브 미리보기(<DemoTabNav>) 적용 (PR #247 기준)
PR #247TabNav 공유 컴포넌트 신규 (tabNav + tab-nav.html + .ds-tab-* 토큰). 이중 밑줄 :has() 일원화. ds-tab 색/폰트 토큰화