Initial commit: HMDesignSystem baseline

This commit is contained in:
shkim4 committed 2026-10-08 16:58:09 +09:00
commit b6a0a61103
486 files changed
+68432

No files matched your search

+356
View File
@@ -0,0 +1,356 @@
# Dashboard (Pattern)
> **SINGLE NAVIGATION MODE / 2026-10-08 Sidebar 운영 정책.** DASHBOARD ONLY 운영 화면은 Sidebar를 사용하지 않는다. 전용 Header(48px)·업무 상태 필터와 Content를 사용하며 Header는 GNB가 아니다. 독립 GNB / LNB / Dashboard 모드는 함께 사용하지 않는다. [공통 정책](../guidelines/navigation-single-mode.md)이 이전 조합·Sidebar 허용 기록보다 우선한다.
## 1. 현재 계약
Dashboard 운영 화면은 전용 Header/Menu·업무 상태 필터와 Content로 구성하며 모든 Sidebar 사용을 금지한다. 외부 GNB와 일반 LNB를 결합하지 않는다. 운영 Reference는 Sidebar 없는 대표 `Patterns/Layout/Dashboard → Dashboard` Story다.
| 공개 API | 역할 |
|---|---|
| `DashboardLayout` | 바깥 scroll root + 내부 frame. 운영 화면은 `header`, `children`을 사용하고 `sidebar`를 전달하지 않음 |
| `DashboardHeader` | Dashboard 내부 Header. 기본 높이 48px |
| `DashboardSidebar` | 기존 호환성·검증 전용 공개 API. 신규 Dashboard 운영 화면 사용 금지. 기존 폭 272px·메뉴 세로 스크롤 기능은 유지 |
| `DashboardMain` | `<main>`, 콘텐츠 및 세로 스크롤 |
| `DashboardContent` | 콘텐츠 stack, gap 24px |
`DashboardSidebar` 공개 Export와 선택적 `sidebar` slot은 기존 소비자 호환성·검증을 위해 유지한다. API 존재가 운영 허용을 뜻하지 않으며 신규 운영 화면에는 사용하지 않는다. GNB/LNB나 Sidebar를 전달하던 운영 화면은 적합한 단일 Navigation Mode의 운영 구성으로 마이그레이션한다. slot은 임의 ReactNode를 받으며 런타임 자동 차단은 없다. 운영 사용 금지와 기능 제거는 별도 작업이며, API 제거·런타임 차단은 소비처 영향 조사와 사용자 결정 후 진행한다.
```tsx
<DashboardLayout
header={<DashboardHeader>Dashboard Header / Menu</DashboardHeader>}
>
<DashboardMain>
<PageHeader>…</PageHeader>
<DashboardContent>…</DashboardContent>
</DashboardMain>
</DashboardLayout>
```
## 2. 최소 폭·스크롤·스타일
- 운영 최소 폭 = Sidebar 없는 960px(`--hm-dashboard-min-width`).
- Sidebar 포함 1232px(960 + 기존 Sidebar 폭 272px)은 호환성·검증 구성의 값이며 현재 운영 기준이 아니다. 해당 구현·CSS는 호환성을 위해 유지한다.
- 최소 폭 이하에서는 바깥 `dashboard-scroll` 안에서 전용 Header/Content 전체를 가로 스크롤한다. 모바일에서도 Sidebar를 추가하지 않는다.
- Main이 세로 스크롤을 담당하고 바깥 frame 높이는 `--hm-dashboard-height`(기본 `100dvh`)다. `className`, `style`, DOM props는 바깥 root에 전달한다.
- Root 기본 접근성은 `role="region"`, `aria-label="Dashboard"`, `tabIndex={0}`. `data-navigation-mode="DASHBOARD"`는 해당 체계를 식별한다.
- Main padding 34px, content gap 24px. Header 콘텐츠 radius 12px와 관련 shadow 유지. 기존 Sidebar 콘텐츠 radius 18px·shadow는 호환성·검증용 구현에만 남는다.
- Dashboard는 `--hm-dashboard-*`와 공통 Semantic만 사용한다. GNB 높이·join, LNB 폭·gradient, 1024px 모바일 숨김 override에 의존하지 않는다.
- Filter Bar는 Dashboard 최소 폭 frame 안에서 한 줄과 기존 control 폭을 유지한다. 넓은 개별 콘텐츠는 Main 또는 카드 내부에서 스크롤한다.
## 3. Storybook
대표 `Patterns/Layout/Dashboard → Dashboard`는 Compact Header 48px + 상태 필터 + 콘텐츠다. Compact 상태 바 geometry(높이 40px, 중심 50% + 18px, 28px control, 36px padding, radius 16px 및 오목한 날개), 전체/진행/보류/완료 count 색상은 기존 기준을 유지한다. count 진행 `#00c896` / 보류 `#ff9500`는 2026-10-07 사용자 결정에 따른 Story 전용 값이다.
### Dashboard 업무 상태 공통 계약
업무 상태 계약은 `packages/ui/src/patterns/dashboard-status/contract.ts`가 기준이다. `active | hold | done`은 실제 상태이고, `all | active | hold | done | issue`는 필터 ID다. `issue`는 상태를 대체하지 않는 독립 Boolean flag이며 `hold + issue`처럼 동시에 표현할 수 있다. `done`의 표시명은 `완료`를 유지한다.
계약은 label, tone, 접근성 label, count 계산(`countDashboardFilter`), 필터 매칭(`matchesDashboardFilter`)을 제공한다. React adapter는 기존 `Badge`, `Button`, `FilterBar`, `Progress`를 사용하고, 다른 Runtime은 동일한 `data-status`, `data-issue="true"`, `data-filter`, `aria-pressed` vocabulary를 사용한다. 선택 상태는 `aria-pressed="true"`가 우선하며 hover/focus/disabled는 대상 Runtime의 interaction state로 동일하게 매핑한다. 상태 색상 원본은 `packages/ui/src/patterns/dashboard-status/dashboard-status.css`이며 `@hm-design/ui/dashboard-status.css`로 공개한다. 기존 `src/patterns/dashboard-status-contract.css`는 원본을 import하는 호환 경로로 유지한다. 이 변수는 기존 Story 색상을 보존하는 compatibility mapping이며 HM Theme token은 변경하지 않는다. Static HTML 기준 샘플은 `docs/patterns/dashboard-status-static.html`이다.
패키지 소비자는 앱 CSS entry에서 Theme 다음에 상태 CSS를 명시적으로 로드한다. React API import만으로 CSS가 자동 적용되지는 않는다.
```css
@import "tailwindcss";
@import "@hm-design/ui/tailwind.css";
@import "@hm-design/ui/theme.css";
@import "@hm-design/ui/dashboard-status.css";
```
상태 CSS는 변수 정의만 제공한다. 기존 `Badge variant="outline"`에는 화면의 wrapper에서 아래 mapping을 적용한다. 완료의 흰색 ink가 유지되므로 기존 어두운 Badge 배경도 함께 유지한다. `issue` Badge는 실제 상태 Badge와 별도로 렌더링한다.
```css
.dashboard-status [data-status="active"] { --dashboard-status-color: var(--hm-dashboard-status-active); }
.dashboard-status [data-status="hold"] { --dashboard-status-color: var(--hm-dashboard-status-hold); }
.dashboard-status [data-status="done"] { --dashboard-status-color: var(--hm-dashboard-status-done); }
.dashboard-status [data-status="issue"][data-issue="true"] { --dashboard-status-color: var(--hm-dashboard-status-issue); }
.dashboard-status [data-slot="badge"][data-status] {
--hm-badge-outline-text: var(--dashboard-status-color);
--hm-badge-outline-background: color-mix(in srgb, var(--hm-sem-palette-green-dark-2) 90%, transparent);
--hm-badge-outline-border-color: color-mix(in srgb, var(--hm-sem-palette-black) 50%, transparent);
}
```
HTML adapter는 같은 상태 변수를 자신의 `color` 등에 연결한다. package dist를 정적 자산으로 배치하면 `theme.css` 다음에 `dashboard-status.css`를 `<link>`로 로드한다. 저장소의 기존 Static HTML 예제는 호환 CSS 경로를 그대로 사용한다. React와 HTML 모두 `done`은 `--hm-dashboard-status-done`을 참조하며, 이 alias는 `:root`와 `[data-hm-theme]`에서 각 scope의 white token으로 재계산한다. active/hold/issue 색상과 Runtime Theme의 73개 계약은 유지한다.
#### React 소비 예제: 데이터 → 상태 변수 → Badge
`active`(진행) → `--hm-dashboard-status-active`, `hold`(보류) → `--hm-dashboard-status-hold`, `done`(완료) → `--hm-dashboard-status-done`이다. 독립 `issue === true`는 `--hm-dashboard-status-issue`로 별도 Badge를 표시한다. `tone`은 의미 분류이며 Badge variant나 CSS를 자동 선택하지 않는다. 위 CSS mapping과 import 순서를 앱 CSS entry에 적용한 다음 아래 공개 API를 사용한다.
```tsx
import {
Badge, dashboardFilterDefinitions, matchesDashboardFilter,
type DashboardFilterId, type DashboardStatusRecord,
} from "@hm-design/ui"
type Work = DashboardStatusRecord & { id: string }
const works: Work[] = [
{ id: "T-1", status: "active" },
{ id: "T-2", status: "hold", issue: true },
{ id: "T-3", status: "done" },
]
export function WorkBadges({ filter = "all" }: { filter?: DashboardFilterId }) {
const visible = works.filter(work => matchesDashboardFilter(work, filter))
return <div className="dashboard-status">
{visible.map(work => <div key={work.id}>
<Badge variant="outline" data-status={work.status}>
{dashboardFilterDefinitions[work.status].label}
</Badge>
{work.issue === true && <Badge variant="outline" data-status="issue" data-issue="true">
{dashboardFilterDefinitions.issue.label}
</Badge>}
</div>)}
</div>
}
```
`filter="issue"`에서도 T-2의 상태는 `hold`로 유지되고 보류 Badge와 이슈 Badge가 함께 표시된다. `issue`를 `DashboardStatusId`에 추가하거나 `destructive` 등 다른 Badge variant에 임의로 연결하지 않는다.
Reference: [React 代表 Story](../../src/patterns/dashboard-layout.stories.tsx)の `statusBadge` と [Story CSS](../../src/patterns/dashboard-layout.stories.css)の Badge mapping、[公開状態 CSS 原本](../../packages/ui/src/patterns/dashboard-status/dashboard-status.css)、[Static HTML](dashboard-status-static.html)。上の例はこれらの状態・色 mappingを公開 APIで消費する例であり、Storyモジュールは importしない。コード・Propsはソースと照合済みで、この文書改善作業では例の実行・画面検証はしていない。
Sidebar 검증 Story `WithSidebar`, `SidebarOnly`, `ThirdLevel`, `LongContent`, `Framed`는 기존 기능·상태 데이터를 보존하는 **VERIFICATION ONLY — 운영 사용 금지** 화면이다. `verification` 태그로 기본 Story 메뉴에서 제외하며 이름과 화면에 운영 금지를 표시한다. 신규 운영 화면 Reference로 사용하지 않는다. `Loading`, `Empty`도 verification 전용이며 Sidebar 없는 Compact 구성을 검사한다. 대표 Story ID `patterns-layout-dashboard-layout--all`과 기존 검증 Story ID는 유지한다. 이전 GNB + LNB / GNB Only / LNB Only Story 구성은 §5의 역사 기록이다.
## 4. 단일 모드 통합 검증 (2026-10-07, headless Edge)
> **HISTORICAL RECORD:** 아래 결과는 2026-10-07 당시 코드의 검증 기록이다. Sidebar 포함 1232px 사례는 현재 운영 허용이나 이번 정책 수정의 검증 결과를 뜻하지 않는다. 현재 운영 계약은 §1–3의 Sidebar 없는 960px 구성이다.
- Navigation/Theme Story × viewport 총 48개 케이스 통과. 1280, 1232, 1024, 1023, 960, 959, 390, 320px에서 주요 모드를 확인했다.
- Dashboard에 외부 GNB/LNB 없음, Compact 960px / Sidebar 포함 1232px 최소 폭과 내부 가로 스크롤 유지, 문서 가로 넘침 0px.
- LNB 1024/1023 전환, Sheet Escape·포커스 복귀·preventDefault 링크 선택·Desktop/Mobile 선택 상태 공유 확인. Dashboard 그룹 Enter/Space, 현재 링크 및 hover 유지 확인.
- Theme Preview는 GNB/LNB 중 하나만 렌더링하고 Dashboard Theme 콘텐츠는 외부 Navigation을 제외한다. Runtime Theme 계약 검증 136개 통과, 브라우저 runtime error 0.
- 타입 검사, UI package build, Storybook static build, Production 앱 build, `git diff --check` 통과. 기존 문서 예시 `bg-[var(...)]` CSS 경고 및 Storybook chunk 크기 경고는 남아 있다.
## 5. 이전 구현·검증 이력 (HISTORICAL RECORD)
아래 내용은 정책 전환 이전 기록을 보존한 것이다. **현재 구현·운영·AI 생성의 근거로 사용하지 않는다.** GNB/LNB 혼합, 모바일 체계 전환, 교차 CSS 변수 및 이전 Story 구성은 위 §1–3과 공통 SINGLE NAVIGATION MODE 정책으로 대체되었다. 아래 테스트 수치는 이전 코드의 결과이며 이번 변경의 검증 결과가 아니다.
<details>
<summary>2026-10-07 단일 모드 전환 이전 기록</summary>
### Dashboard Layout (Pattern)
현재 HM 구현의 기준 문서. 공통 원칙은 `docs/guidelines/프로젝트-지침.md`(§6.10, §6.11)를 따른다.
```text
Layout visual reference: 외부 ui_viz Reference (ZIP 미포함) (dashboard-composition.md §7·§8·§19, Variant A Sidebar / Variant B Top-header)
Implementation: current @hm-design/ui patterns / primitives
```
#### 1. 상태
| 항목 | 내용 |
|---|---|
| 구현 상태 | 구현 완료 (2026-10-03) |
| 기반 | GNB, LNB, Page Header, Card, Badge, Progress, Filter Bar, Data Table 조합 (새 dependency 없음) |
| 대표 Story | `Patterns/Layout/Dashboard Layout` → `Dashboard Layout` (verification: Third Level, GNB Only, LNB Only, Long Content, Loading, Empty, Framed) |
| 함께 만든 Pattern | `Page Header` (`page-header.md`) |
| 보류 | §12 |
#### 2. 목적 / 범위
GNB / LNB / Page Header / Main을 실제 화면으로 조립해 Layout contract를 확정한다. 새 UI를 만들지 않는다. 메뉴·페이지 내용은 소비자가 slot과 children으로 넣고, Dashboard는 메뉴 schema를 모른다. 소유하는 것: 화면 프레임(높이), main 스크롤, 콘텐츠 면(배경·padding·둥근 모서리·그림자), 콘텐츠 세로 리듬(gap).
#### 3. ui-viz visual reference
ui-viz에서 확인한 구조(실제 파일: `compositions/dashboard-variant-specimens.{jsx,css}`, `top-header-specimen.{jsx,css}`, `storybook/{LNB,GNB,Layout}.stories.jsx`, `layout-composition.jsx`, `dashboard-composition.md`):
| 영역 | ui-viz 구조 | 현재 HM 대응 | 이번 구현 |
|---|---|---|---|
| 조립 | Variant A(LNB 272 + 콘텐츠), Variant B(GNB 70 + 콘텐츠), Variant C(Compact 48). **GNB+LNB 동시 사용 예 없음** | `DashboardLayout` | slot 방식. 둘 다 / 하나만 |
| GNB | 70px green, 콘텐츠와 12px 접점 | `GNB` | slot `header` |
| LNB | 272px green, 콘텐츠와 18px 접점 | `LNB` | slot `sidebar` |
| Content surface | `#F5F0E5`, padding 34, 접점 쪽 radius(LNB 18 / GNB 12), 그림자 `-8px 0 18px`(LNB) · `0 -8px 18px`(GNB) rgba(7,30,24,.18), 별도 border 없음 | 신규 | `DashboardMain` |
| 스크롤 | "메뉴/헤더는 고정, 콘텐츠 영역에서만 스크롤"(Confirmed). `overflow-y:auto`, `overscroll-behavior: contain` | 신규 | `DashboardMain`이 스크롤 소유 |
| Page heading | breadcrumb 12px(정적 `p`, 컴포넌트 아님) → 제목 28 → 설명, space-between·align end·gap 24, **하단 20px** | Breadcrumb + 신규 Pattern | `PageHeader` |
| 간격 | 외곽 34, 카드 사이 24, 카드 padding 24, 카드 제목→본문 24, 조회→결과 16 | Card, `DashboardContent` | 24 gap 토큰, 나머지는 Card/Filter Bar 값 |
| 조회 | 결과 카드 안 검색(라벨 좌측, 최대 360) + 표 | Filter Bar + Data Table | Card 안에서 조합 (gap 16) |
| KPI | Dashboard에 Panel/Card 4종(KPI 전용 아님) | Card + Badge + Progress | Story 조합만 |
#### 4. GNB / LNB 조립 정책
| 구성 | 사용 | 근거 |
|---|---|---|
| GNB only (Variant B) | `header` | ui-viz 기준 있음 |
| LNB only (Variant A) | `sidebar` | 모든 viewport에서 Sidebar 유지. 최소 폭 이하에서는 Dashboard 전체 가로 스크롤 |
| GNB + LNB | `header` + `sidebar` | ui-viz에 기준 없음. 사용자 요청으로 조립한 INTENTIONAL 구성(대표 Story) |
##### Brand / Profile ownership (2026-10-06, `lnb.md` §3c)
| 구성 | Desktop Brand | Desktop Profile | Mobile Brand | Mobile Profile |
|---|---|---|---|---|
| GNB + LNB | GNB(`GNBBrand`) | GNB(`GNBUser`), LNBProfile 없음 | GNB Mobile Header | GNB Sheet의 `GNBUser`, 별도 LNB Profile 없음 |
| LNB Only | `LNBBrand` | `LNBProfile` | 동일한 `LNBBrand` | 동일한 `LNBProfile` |
| GNB only | GNB(`GNBBrand`) | GNB(`GNBUser`) | GNB Mobile Header | GNB Profile |
Brand/서비스명과 Profile의 소유권은 소비자가 composition에서 `LNBBrand`와 `LNBProfile`을 넣고 빼서 정한다. LNB Only의 `LNBProfile`은 `DropdownMenuTrigger asChild`로 합성하며 작은 viewport에서도 Sidebar와 함께 유지한다. GNB + LNB에서는 LNBProfile을 렌더링하지 않는다.
GNB+LNB일 때: GNB가 전체 폭 위에 평평하게 놓이고 LNB와 콘텐츠가 그 아래 한 줄. GNB의 12px 접점 띠는 숨기고(`::after`) 콘텐츠는 **왼쪽 모서리만 18px**, 위쪽 오른쪽 모서리는 직각이다. 작은 viewport에서도 이 접점을 유지한다. GNB 자체의 모바일 헤더·Sheet 정책은 그대로 유지한다.
#### 5. 구조 / API
```tsx
<DashboardLayout header={<GNB>…</GNB>} sidebar={<LNB>…</LNB>}>
<DashboardMain>
<PageHeader>…</PageHeader>
<DashboardContent>…Card, Filter Bar, Data Table…</DashboardContent>
</DashboardMain>
</DashboardLayout>
```
- `DashboardLayout`: 바깥 `data-slot="dashboard-scroll"`이 가로 스크롤을 맡고, 안쪽 `data-slot="dashboard-layout"`(flex column)이 최소 폭과 `header`/`sidebar` slot을 유지한다. 기존 `className`, `style`, DOM props는 바깥 scroll root에 전달된다. `data-header`/`data-sidebar`는 안쪽 frame에 있다. DOM 순서는 header → (sidebar → main)이다.
- Scroll root는 기본 `role="region"`, `aria-label="Dashboard"`, `tabIndex={0}`이며 DOM props로 변경할 수 있다. CSS 변수는 root에서 내부 frame으로 상속된다.
- `DashboardMain`: `<main>`, 콘텐츠 면이자 세로 스크롤 컨테이너. 고유 `id`/`aria-label`이 필요하면 소비자가 준다.
- `DashboardContent`: 섹션 세로 stack(gap 24). 섹션 스타일은 입히지 않는다.
- GNB / LNB를 wrapper로 다시 감싸지 않고 그대로 slot에 넣는다.
#### 6. Content Surface (접점 소유권)
GNB / LNB는 콘텐츠 radius를 만들지 않는다. 자기 쪽 "연장 띠"만 둔다(GNB `::after` 12px = `--hm-gnb-join`, LNB `::after` 18px = `--hm-lnb-join`). 콘텐츠 면이 그 위에 positioned로 얹혀 둥근 모서리에서 띠가 보이게 한다(`DashboardMain`이 `position: relative`이고 DOM상 뒤).
| 상태 | radius | shadow |
|---|---|---|
| header | `gnb-join gnb-join 0 0` | `0 -8px 18px rgba(7,30,24,.18)` |
| sidebar (모든 viewport) | `lnb-join 0 0 lnb-join` | `-8px 0 18px rgba(7,30,24,.18)` |
| header + sidebar (모든 viewport) | sidebar와 같음, GNB 띠 숨김 | 같음 |
| (둘 다 없음) | 0 | 없음 |
값은 `--hm-gnb-join`, `--hm-lnb-join`을 읽는다(하드코딩 없음). 콘텐츠 면 뒤 색(`--hm-dashboard-background`)은 LNB 끝 색 `--hm-lnb-background-end`.
#### 7. Main Layout
- padding 34px(`--hm-dashboard-padding`, 모든 viewport), 세로 gap 24px(`--hm-dashboard-gap`), 배경 `--hm-sem-bg-content`.
- `min-width: 0`: Main, `DashboardContent`와 그 직접 자식. 표·Carousel·Combobox가 페이지를 밀지 않는다(검증: 1600px 넓은 내용도 Main `scrollWidth`를 늘리지 않음).
- 스크롤: 바깥 Dashboard frame 높이는 `--hm-dashboard-height`(기본 `100dvh`)이며 가로 scrollbar도 이 높이에 포함된다. Main이 세로 스크롤, 바깥 frame이 Dashboard 전체 가로 스크롤을 맡는다. Main은 `overflow-x:auto`, `overscroll-behavior-y:contain`으로 가로 제스처가 바깥 frame으로 전달된다. LNB는 메뉴 영역만 자체 세로 스크롤한다.
- 임베드 시 높이는 `--hm-dashboard-height`로 바꾼다(Story `Framed`).
#### 8. Page Header
별도 Pattern(`page-header.md`)으로 분리했다. 모든 화면이 반복해서 쓰고(Breadcrumb + h1 + actions), 간격(하단 20)이 ui-viz Layout contract라서다. Breadcrumb는 HM Breadcrumb를 그대로 쓰며 Page Header는 간격만 맡는다.
#### 9. Desktop / Mobile
```yaml
Dashboard Minimum Width Policy: HORIZONTAL_SCROLL
Content minimum width: 960px
With Sidebar: 960px + 272px = 1232px
Below minimum width: KEEP_LAYOUT_AND_SCROLL
Compact / Sidebar mobile hide: NONE
```
- 충분한 폭에서는 기존 배치를 사용한다. 작은 viewport에서는 내부 최소 폭을 유지하고 Dashboard 전용 root에서만 가로 스크롤한다. Sidebar slot은 항상 렌더링된다.
- 960px 근거: KPI 4 × 200px + 간격 3 × 24px + padding 2 × 34px + 세로 scrollbar 여유 20px. Compact 상태 바 실측 약 549px, 브랜드 약 125px, 날개 35px도 이 폭에서 겹치지 않는다.
- Sidebar 구성은 콘텐츠 최소 폭에 LNB 폭을 더한다. Desktop은 기존 `--hm-lnb-width`를 읽고, ≤1023px에서는 Dashboard 내부에만 `--hm-lnb-width: var(--hm-dashboard-sidebar-width, 272px)`와 LNB `display:flex`를 적용한다. 공통 `lnb.css`의 모바일 숨김은 유지된다.
- 기본 콘텐츠 최소 폭은 `--hm-dashboard-min-width`로 조정할 수 있다. 기본값 960px은 현재 Desktop 구성 측정 기준이며 별도 콘텐츠를 넣으면 그 구성을 기준으로 조정한다.
- Compact 메뉴는 숨기거나 wrap하지 않는다. Dashboard 안 Filter Bar도 한 줄과 기존 control 폭을 유지하며 공통 Filter Bar 모바일 정책은 변경하지 않는다. 본문 텍스트는 자연스럽게 줄바꿈한다.
- GNB의 전역 메뉴·모바일 Sheet와 일반 LNB의 공통 responsive 정책, Theme/Color Token, Story ID/hierarchy는 변경하지 않는다.
#### 10. Accessibility / Keyboard
- landmark: `header`(GNB), `nav[aria-label=주 메뉴]`, `aside`(LNB) 안 `nav[aria-label=업무 메뉴]`, `main`, `nav[aria-label=breadcrumb]`. 화면의 `h1`은 Page Header 제목 하나.
- Tab 순서(측정): GNB(브랜드, 메뉴 4, 사용자) → LNB(항목, 그룹, 하위) → Breadcrumb → 페이지 액션 → 검색 → 상태 → 초기화 → 표. focus trap은 Sheet/Dialog에서만.
- skip-to-content 링크는 만들지 않았다(§12). `aside`에는 이름이 없고 안의 `nav`가 이름을 갖는다.
#### 11. 비교 (ui-viz ↔ 현재 HM)
| 항목 | ui-viz | 현재 HM | 분류 |
|---|---|---|---|
| GNB 70 / LNB 272 | 같음 | `--hm-gnb-height` / `--hm-lnb-width` | MATCHED |
| 콘텐츠 면 색·padding 34 | `#F5F0E5`, 34 | 같음 | MATCHED |
| 접점 radius (LNB 18 / GNB 12), shadow | 같음 | 같음. 소유를 Dashboard로 이동 | MATCHED |
| 헤더·메뉴 고정, 콘텐츠만 스크롤 | 같음 | `DashboardMain` 스크롤 | MATCHED |
| 접점 띠가 콘텐츠 뒤에서 이어짐 | 형제 absolute | GNB/LNB `::after` + positioned Main | IMPLEMENTATION_CHANGE |
| Page heading 간격(하단 20, h 28, gap 24) | 같음 | `PageHeader` | MATCHED |
| Breadcrumb | 12px 정적 `p`, 컴포넌트 아님 | HM Breadcrumb(14px, 링크) | INTENTIONAL_CHANGE (사용자 지시) |
| 제목 태그 | `h2` | `h1` | INTENTIONAL_CHANGE (페이지당 h1) |
| 카드 | Content Card(흰색), padding 24 | HM Card | MATCHED |
| 조회 + 표 | 결과 카드 안, 검색 → 표 16 | Card 안 Filter Bar + Data Table, gap 16 | MATCHED |
| 표 density | ui-viz Table | HM Data Table(Table primitive) | MATCHED (primitive 기준) |
| GNB + LNB 동시 | 없음 | 조립(GNB 평평, LNB 아래) | INTENTIONAL_CHANGE / FOLLOW_UP |
| 반응형 | ≤1100px/48px OUT_OF_SCOPE | Dashboard 최소 폭 유지 + native 가로 스크롤. GNB 전역 Sheet 정책 유지 | INTENTIONAL_CHANGE |
| 스크롤바 | 기본 | 기본 | — |
| ui-viz 화면과 픽셀 비교 | — | 미실시(ui-viz Storybook 미실행) | — |
#### 12. 남은 차이 / 누락
| 항목 | 분류 |
|---|---|
| GNB+LNB brand 중복 | 해결 (2026-10-06, §4 Brand ownership: GNB만, LNBBrand 없음) |
| GNB+LNB 조립 시각 확정(두 gradient 이음새) | FOLLOW_UP — 사용자 결정 필요 |
| LNB only의 모바일 탐색 | 동일 Sidebar를 가로 스크롤로 탐색 (2026-10-07 최소 폭 정책, §9) |
| skip-to-content, `main` 이름 | FOLLOW_UP |
| ui-viz Variant C(Compact 48 + Status Filter Bar) | 대표 Story에 구현됨. 최소 폭 960px으로 유지 |
| 실기기 touch/trackpad, ui-viz 픽셀 비교 | 미확인 |
| 문서 스크롤 방식(frame 높이 없이 body 스크롤) 변형 | 필요 시 `--hm-dashboard-height: auto` + GNB `sticky`로 가능하나 검증하지 않음 |
##### 누락 컴포넌트 점검
| 항목 | 필요도 | 이유 | 다음 단계 |
|---|---|---|---|
| KPI Card | 권장 | Card+Badge+Progress를 4곳에서 직접 조합. 반복되면 Pattern | 실제 제품 요구 때 |
| Page-level Empty State | 권장 | 표 Empty만 있고 페이지 전체 빈 상태(첫 사용·권한 없음) 없음 | 요구 확인 후 |
| Page Loading | 후속 | Skeleton + Data Table `loading` 조합으로 충분(Story `Loading`) | 보류 |
| Bulk Action Bar | 권장 | 표 선택 후 일괄 작업 영역 없음 | Data Table 확장 때 |
| Filter Chip / 적용 조건 표시 | 후속 | Filter Bar 문서 §12에 기록됨 | 보류 |
| Mobile Filter Drawer | 후속 | Dashboard에서는 최소 폭 안에서 기존 Filter Bar를 유지 | 보류 |
| Notification Center, Global Search Result | 후속 | GNB 문서 | 보류 |
| Skip link | 권장 | 키보드 사용자: GNB/LNB 건너뛰기 | 접근성 보완 때 |
#### 13. 검증 (2026-10-07, 최소 폭 정책)
현재 검증 결과는 §9의 HORIZONTAL_SCROLL 정책을 따른다. 아래 Desktop 전용 정책 검증은 이전 작업 이력이다.
당시 Storybook 포트 6007, Chromium, 높이 900px에서 실측했다.
| viewport | Compact 내부 폭 | Compact 가로 스크롤 | GNB + LNB 내부 폭 | GNB + LNB 가로 스크롤 | 메뉴·콘텐츠 | 레이아웃 붕괴 |
|---|---:|---|---:|---|---|---|
| 1280 | 1265 | 없음 | 1280 | 없음 | 유지 | 없음 |
| 1024 | 1009 | 없음 | 1232 | 있음 | 유지 | 없음 |
| 1023 | 1008 | 없음 | 1232 | 있음 | 유지 | 없음 |
| 768 | 960 | 있음 | 1232 | 있음 | 유지 | 없음 |
| 390 | 960 | 있음 | 1232 | 있음 | 유지 | 없음 |
| 320 | 960 | 있음 | 1232 | 있음 | 유지 | 없음 |
- 대표 Compact Story는 바깥 Story 설명 때문에 문서에 세로 scrollbar 15px이 있다. 위 내부 폭은 이를 제외한 가용 폭이며 `100vw`를 사용하지 않는다. 모든 조합에서 문서 가로 넘침은 0px이다.
- Sidebar 폭 272px, Main padding 34px, Filter Bar 검색 폭 280px·높이 34px이 모든 폭에서 유지된다. KPI 4개는 작은 viewport에서도 한 줄, 각 약 201px이다. Main 자체 가로 넘침은 0px이다.
- Third Level, GNB Only, LNB Only, Long Content, Loading, Empty, Framed를 1280·320px에서 추가 확인했다. 문서 가로 넘침 0px, 넓은 1600px 내용은 카드 내부에서만 스크롤, Framed 높이는 scrollbar를 포함해 640px으로 유지된다.
- 320px에서 native 가로 wheel 입력으로 Compact scrollLeft 655px / Sidebar 구성 912px(각 최대값)에 도달하고 0px으로 복귀했다. 문서 scrollLeft는 0px으로 유지된다. 스크롤 root의 ArrowRight 입력도 동작한다.
- Compact Tab 순서 전체 → 활성 → 대기 → 비활성 → 검색을 확인했다. 비활성 버튼에 focus하면 scrollLeft 435px으로 자동 이동하며 버튼 전체와 focus indicator가 viewport 안에 보인다. 스크롤 JS, touch-action 제한, 입력 이벤트 차단은 추가하지 않았다. 실제 touch·trackpad·Shift+wheel 장치는 별도 실기기 확인이 필요하다.
- 일반 GNB Story의 모바일 높이 56px·desktop nav 숨김은 유지된다. 공통 GNB/LNB CSS와 JS는 수정하지 않았다.
- 실제 터치 장치 테스트는 수행하지 않았다.
- `npm run typecheck`, 최종 UI package build, Storybook static build, `git diff --check` 통과. 기존 문서 예시 `bg-[var(...)]` CSS 경고와 500kB chunk 경고는 유지된다.
##### 이전 Desktop 전용 정책 검증 (2026-10-07, Storybook / Chrome)
- Dashboard Story 9개 × 1280·1024·1023·390 = 36개 조합 통과. 대표 Compact 메뉴는 Desktop 표시, Mobile 높이 0. Sidebar는 Desktop 폭 272px, Mobile DOM에서 제거된다.
- 1280: GNB + LNB Main 폭 1008px. 1024: Main 폭 752px. 1023·390: Main이 각각 1023·390px 전체 폭을 사용하며 빈 sidebar 공간 없음. 모든 조합에서 문서 및 Main 가로 overflow 0.
- Mobile: Dashboard 로컬 메뉴·Compact 상태 필터가 접근성 tree에 없고 Tab으로 접근되지 않는다. GNB Sheet에는 기존 전역 메뉴만 표시하고 Dashboard 업무 메뉴는 없다.
- Desktop LNB hover·active 변경·그룹 열림/닫힘 통과. 열린 LNB Profile portal은 1024 → 1023 전환 시 함께 제거되고 1024로 복귀하면 sidebar가 다시 표시된다.
- 패키지 build·typecheck 통과. Story 수정 후 루트 `tsc -b` 통과. 브라우저 runtime error 없음.
##### 이전 검증 이력
아래 모바일 메뉴 관련 기록은 이전 정책의 이력이다. 현재 정책은 §9이며 모든 viewport에서 Dashboard 메뉴·콘텐츠를 유지하고 최소 폭 이하에서 가로 스크롤한다.
- 1280: 문서 스크롤 없음(`scrollHeight == clientHeight`), Main만 스크롤(scrollTop 400에서 GNB y 0·LNB y 70 유지). h1 1개, landmark 위 목록.
- 1024 desktop / 1023 mobile 전환, 768, 390: 문서·Main 가로 overflow 0. GNB-only, LNB-only, Long Content(긴 제목 + 1600px 내용) 모두 overflow 0.
- 390: 모바일 Sheet에 전역/로컬 그룹 표시, current 표시.
- 2026-10-06 `Third Level`: 1280·1024 GNB 70 + LNB 272(3뎁스 active "전체 업무", 상위 "업무 관리"·"업무 현황" current), 1023·768·390 GNB 56 + 햄버거, LNB `display:none`, Sheet에서 업무 메뉴 → 업무 관리(+12) → 업무 현황(+24) → 3뎁스 링크, active 동일. Sheet에서 다른 3뎁스를 고르면 LNB active도 같이 바뀐다. 5개 폭 모두 문서 가로 overflow 0.
- 2026-10-06 `LNB Only` 모바일: 1280·1024 GNB 없음 + LNB 272, radius `18 0 0 18`; 1023·768·390 상단 바 56(햄버거 1개, header 1개) + LNB 숨김, radius `12 12 0 0`, 가로 overflow 0. Sheet는 "업무 메뉴" 하나(전역 메뉴 없음), active 동일. `Dashboard Layout`·`Third Level`(GNB + LNB)은 5개 폭 모두 GNB 하나(1023 이하 56 + 햄버거 1개), Sheet 전역 + 업무 메뉴 그대로.
- 2026-10-06 Brand ownership: `Dashboard Layout`·`Third Level` 1280·1024 brand = GNB 1개(LNBBrand 없음, LNB 메뉴 top 98 = GNB 70 + 28), 1023·390 GNB 모바일 바 1개. `LNB Only` 1280·1024 LNBBrand 1개(상단 바 없음), 1023·390 Local 모바일 바 1개. 서비스명은 모든 폭에서 1개. 수치는 `lnb.md` §8.
- 기존 Pattern(GNB 70/12px 띠, LNB 272, Data Table, Filter Bar) Story 오류·overflow 없음.
- `npm run typecheck`, `npm run build` 통과.
#### 14. 관련 파일
```
packages/ui/src/patterns/dashboard-layout/{dashboard-layout.tsx,index.ts}
packages/ui/src/patterns/page-header/{page-header.tsx,index.ts}
packages/ui/src/theme/{dashboard-layout.css,page-header.css}
src/patterns/dashboard-layout.stories.tsx
```
</details>
@@ -0,0 +1,94 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>HM Dashboard Status Contract - Static HTML</title>
<link rel="stylesheet" href="../../packages/ui/dist/theme.css" />
<link rel="stylesheet" href="../../src/patterns/dashboard-status-contract.css" />
<style>
@font-face { font-family: "Noto Sans KR"; src: url("../../public/fonts/NotoSansKR-variable.ttf") format("truetype"); font-weight: 100 900; }
:root {
--dashboard-work-progress: var(--hm-dashboard-status-active);
--dashboard-work-hold: var(--hm-dashboard-status-hold);
--dashboard-work-issue: var(--hm-dashboard-status-issue);
--dashboard-work-surface: var(--hm-sem-palette-green-dark-2);
}
body { margin: 0; min-width: 960px; background: var(--hm-sem-bg-content); color: var(--hm-sem-fg-primary); font-family: "Noto Sans KR", sans-serif; }
main { max-width: 1120px; margin: 0 auto; padding: 32px; }
.panel { padding: 24px; border-radius: var(--hm-radius-container); background: var(--hm-sem-bg-surface); box-shadow: var(--hm-card-shadow); }
.toolbar, .filters, .badges { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.toolbar { justify-content: space-between; margin-bottom: 20px; }
.filters { padding: 10px; border-radius: var(--hm-radius-container); background: var(--dashboard-work-surface); }
button { min-height: 32px; padding: 0 12px; border: 1px solid color-mix(in srgb, var(--hm-sem-palette-black) 50%, transparent); border-radius: var(--hm-radius-input); background: color-mix(in srgb, var(--hm-sem-palette-black) 35%, transparent); color: var(--hm-sem-palette-white); font: inherit; cursor: pointer; }
button:hover { background: color-mix(in srgb, var(--hm-sem-palette-black) 55%, transparent); }
button:focus-visible { outline: var(--hm-dashboard-nav-focus-outline); }
button[aria-pressed="true"] { background: color-mix(in srgb, var(--hm-sem-palette-green-dark-2) 90%, transparent); border-color: var(--hm-sem-palette-green-light-4); }
button[data-filter="issue"] { background: color-mix(in srgb, var(--hm-sem-action-destructive) 25%, transparent); }
[data-slot="badge"] { background: color-mix(in srgb, var(--hm-sem-palette-green-dark-2) 90%, transparent); }
[data-slot="badge"][data-status] { color: var(--dashboard-status-color); border-color: color-mix(in srgb, var(--hm-sem-palette-black) 50%, transparent); }
[data-status="active"] { --dashboard-status-color: var(--dashboard-work-progress); }
[data-status="hold"] { --dashboard-status-color: var(--dashboard-work-hold); }
[data-status="done"] { --dashboard-status-color: var(--hm-dashboard-status-done); }
[data-status="issue"][data-issue="true"] { --dashboard-status-color: var(--dashboard-work-issue); }
.count { margin-left: auto; color: var(--hm-sem-fg-secondary); }
.result { margin-top: 16px; }
[hidden] { display: none !important; }
</style>
</head>
<body>
<main>
<section class="panel" aria-labelledby="title">
<div class="toolbar">
<div><h1 id="title">Dashboard 업무 상태</h1><p id="summary" role="status">전체 업무 0건</p></div>
<div class="filters" role="group" aria-label="업무 상태 필터" data-status-filter-group></div>
</div>
<div class="result" aria-live="polite"><div class="badges" id="results" aria-label="필터 결과"></div><p id="empty" hidden>조건에 맞는 업무가 없습니다.</p></div>
</section>
</main>
<script>
const contract = {
filters: ["all", "active", "hold", "done", "issue"],
labels: { all: "전체", active: "진행", hold: "보류", done: "완료", issue: "이슈" },
records: [
{ name: "대시보드 화면 검토", status: "active" },
{ name: "업무 자료 정리", status: "hold", issue: true },
{ name: "현장 점검 보고", status: "done" },
],
};
const state = { selected: "all" };
const matches = (record, filter) => filter === "all" || (filter === "issue" ? record.issue === true : record.status === filter);
const count = (filter) => contract.records.filter((record) => matches(record, filter)).length;
const group = document.querySelector("[data-status-filter-group]");
const results = document.querySelector("#results");
const summary = document.querySelector("#summary");
const empty = document.querySelector("#empty");
function render() {
group.replaceChildren(...contract.filters.map((filter) => {
const button = document.createElement("button");
button.type = "button";
button.dataset.filter = filter;
button.dataset.status = filter;
if (filter === "issue") button.dataset.issue = "true";
button.setAttribute("aria-pressed", String(state.selected === filter));
button.setAttribute("aria-controls", "results");
button.textContent = `${contract.labels[filter]} ${count(filter)}건`;
button.addEventListener("click", () => { state.selected = filter; render(); });
return button;
}));
const filtered = contract.records.filter((record) => matches(record, state.selected));
results.replaceChildren(...filtered.map((record) => {
const item = document.createElement("span");
item.dataset.slot = "badge";
item.dataset.status = record.status;
if (record.issue) item.dataset.issue = "true";
item.textContent = `${record.name} · ${contract.labels[record.status]}${record.issue ? " · 이슈" : ""}`;
return item;
}));
summary.textContent = `${contract.labels[state.selected]} 업무 ${filtered.length}건`;
empty.hidden = filtered.length !== 0;
}
render();
</script>
</body>
</html>
+103
View File
@@ -0,0 +1,103 @@
# Data Table (Pattern)
현재 HM 구현의 기준 문서. 공통 원칙은 `docs/guidelines/프로젝트-지침.md`(§6, §7, Pattern 절)를 따른다. 이 문서는 Data Table Pattern 고유의 API·상태·결정만 기록한다.
## 1. 상태
| 항목 | 내용 |
|---|---|
| 구현 상태 | 구현 완료 (현재 HM 규격) |
| 기준일 | 2026-10-03 |
| 기반 | `@tanstack/react-table` 9.2.4 + HM Table primitive |
| 대표 Story | `Patterns/Data/Data Table` → `Data Table` (`With Filter Bar`는 숨김 verification) |
| 보류 | §12 |
## 2. 목적 / 사용 범위
업무 목록 화면의 표. 기존 `Table`(presentation primitive)에 정렬·행 선택·페이지·열 표시·로딩/빈 상태·행 액션을 조합한다. `Table`에는 어떤 로직도 넣지 않았다. 서버 데이터 호출·서버 사이드 페이지/정렬·가상 스크롤은 범위 밖이다.
## 3. 구성 요소
`DataTable`, `useDataTable`, `DataTableColumnHeader`, `DataTableViewOptions`, `DataTablePagination`, `dataTableSelectColumn`. (`DataTable`이 `DataTablePagination`을 기본으로 포함한다.)
## 4. 구조
```
Table primitive (정적 표시, 변경 없음)
↑ 조합
DataTable pattern ── useDataTable (TanStack useTable + 기능 등록)
├ DataTableColumnHeader 정렬 버튼
├ dataTableSelectColumn HM Checkbox 선택 열
├ DataTableViewOptions HM Dropdown Menu + Button
└ DataTablePagination HM Pagination + Select(페이지 크기) + 선택 수 요약
```
## 5. Public API
```tsx
const table = useDataTable({ columns, data, getRowId, initialState }) // TanStack TableOptions에서 features만 뺀 것
<DataTableViewOptions table={table} labels={{ name: "이름" }} />
<DataTable table={table} loading emptyMessage="..." pagination pageSizeOptions={[10, 20, 50]} />
```
- `columns`: `DataTableColumnDef<TData>[]`(TanStack `ColumnDef`, 등록된 기능 포함). 정렬 header는 `({ column }) => <DataTableColumnHeader column={column} title="이름" />`.
- `DataTable`: `table`, `loading`, `emptyMessage`, `pagination`, `pageSizeOptions`, `className`. 그 외 TanStack 옵션은 `useDataTable`에 그대로 전달한다(복제 prop 없음).
- `data`와 `columns`는 참조가 안정적이어야 한다(모듈 상수, `useMemo`, state).
- 타입: `DataTableColumnDef`, `DataTableInstance`, `DataTableOptions`, `DataTableProps`.
## 6. 상태 관리
TanStack Table v9의 state model 그대로. v9는 기능을 명시 등록하는 방식(`tableFeatures`)이라 v8 예제(`useReactTable`, `getCoreRowModel`)와 다르다. 등록한 기능: column filtering, global filtering, row sorting, row selection, row pagination, column visibility와 각 row model(`filteredRowModel`, `sortedRowModel`, `paginatedRowModel`), `includesString`·`equalsString` filter, `alphanumeric`·`basic`·`datetime` sort.
- 상태는 TanStack 내부가 소유한다(필요하면 `useDataTable`에 `state` / `atoms`로 제어). 검색은 `table.setGlobalFilter`, 열 필터는 `table.getColumn(id)?.setFilterValue`, 초기화는 `resetGlobalFilter(true)` / `resetColumnFilters(true)`로 한다. 필터 변경 시 TanStack이 page index를 0으로 되돌린다.
- error는 Data Table이 소유하지 않는다(소비자가 Alert / Toast로 처리).
조회 버튼을 사용하는 목록은 [FilterBar의 draft / applied 소비 예제](filter-bar.md#61-조회-버튼으로-적용하는-소비-예제)를 따른다. 입력 중에는 draft만 바꾸고 조회 시 table 필터 또는 `useMemo`로 만든 `data`를 갱신한다. 초기화는 입력값과 적용값, 사용 중인 table 필터와 페이지를 소비자가 함께 처리한다. 대표 Data Table Story는 표 기능의 운영 Reference이며, `WithFilterBarStory`(`patterns-data-data-table--with-filter-bar-story`)는 **입력 즉시 적용**을 확인하는 숨김 verification이다. 조회 버튼으로 조건을 확정하는 공개 API나 서버 호출 기능은 제공하지 않는다.
## 7. 조합 컴포넌트
Table, Checkbox(header mixed), Pagination, Select(페이지 크기), Dropdown Menu(열 표시), Button(열 표시 trigger), Skeleton(loading). 행 액션은 소비자가 열 정의에서 Dropdown Menu(또는 Context Menu)로 구성한다. Story는 행 액션 결과를 Sonner `toast`로 보이지만 Pattern은 Sonner에 의존하지 않는다.
## 8. Styling / Token
Header·cell·row·selected 면은 `packages/ui/src/theme/table.css`(`--hm-table-*`)가 소유하고 Pattern은 다시 정의하지 않는다. 컬러 역할(2026-10-06): 선택 행 면 = `--accent` 45%(hover·selected+hover와 같은 Soft Surface, Select Highlight / Selected와 동일한 Accent 45% 정책), 선택 Checkbox = `--hm-sem-state-active`(Light 2). 면은 hover와 같으므로 선택 여부는 Checkbox와 선택 수 요약으로 구분한다. Pattern 전용: `packages/ui/src/theme/data-table.css`의 `--hm-data-table-*`(footer gap·padding, summary 색·크기, 정렬 버튼 간격·아이콘 크기·활성 색, empty padding·색, skeleton 높이). 신규 Primitive·Semantic 없음.
- Loading: header 유지, body는 Skeleton 행(최대 5행), `aria-busy`.
- Sort Direction Accent: `--hm-data-table-sort-active-color`는 현재 `--hm-sem-palette-green-light-3`(Green Light 3 palette)를 참조한다. 별도 State Semantic으로 승격하지 않았으며 Primary/Active/Accent 역할에 자동 연결되지 않는다. Token 이름의 `active`는 Control Active Semantic 계약을 뜻하지 않는다.
- Empty: 한 행을 열 수만큼 `colSpan`, 문구는 `emptyMessage`(원본 0건과 필터 결과 0건 동일 UI).
## 9. Storybook
- `Patterns/Data/Data Table` → `Data Table`(대표: 선택·정렬·열 표시·행 액션·페이지 크기·페이지), `With Filter Bar`(숨김 Filter Bar 연동 검증). verification: `WithFilterBar`, `Loading`, `Empty`.
- 샘플 데이터는 `src/patterns/sample-users.ts`(47행, 네트워크 없음).
## 10. Accessibility
semantic `<table>` 유지. 정렬 열 `<th aria-sort>`(ascending / descending), 정렬 버튼 `aria-label`("이름 정렬, 오름차순"), 버튼이라 Enter / Space 동작. 선택 Checkbox는 `aria-label`("현재 페이지 전체 선택", "행 선택"), header는 `aria-checked="mixed"`. 선택 수 요약은 `role="status"`. Pagination은 기존 `nav` + `aria-current` / `aria-disabled`.
## 11. Responsive
Table primitive의 container가 가로 스크롤한다. Pattern은 `[data-slot="data-table"] { min-width: 0 }`로 grid / flex 부모 안에서 페이지를 밀지 않게 한다. 390px에서 페이지 가로 overflow 없음(표만 520px 안에서 스크롤), footer는 요약 / 페이지 크기 / 페이지 이동이 줄바꿈된다. 카드형 변환은 없다.
## 12. 보류 / 비범위
- column resizing·pinning, grouping, tree, virtualization, infinite scroll, drag reorder, 서버 사이드 페이지/정렬, 편집 가능한 표.
- 선택 action toolbar(선택 수 요약만 제공), 행 클릭 이동, 다중 정렬 UI.
- 정렬 header와 열 표시 메뉴의 열 이름: header가 컴포넌트일 수 있어 메뉴 문구는 `labels`로 받는다.
- 확인하지 못한 것: 스크린 리더 낭독, 키보드 Tab 순서 전체, 페이지 크기 Select 조작, 행 선택 + 페이지 이동 후 선택 유지.
- `With Filter Bar` verification Story의 DatePicker / Combobox는 `controlled={true}`를 사용한다. 초기화는 부모의 검색·상태·날짜·부서 state와 `table.resetGlobalFilter(true)` / `table.resetColumnFilters(true)`를 함께 비운다. 날짜·부서의 undefined 초기화는 선택값을 비우며, 해당 목적의 `key` remount를 제거했다. 외부 값 변경은 자식 콜백을 호출하지 않으므로 테이블 필터는 소비자가 명시적으로 초기화해야 한다. 팝업 닫기·검색어·달력 월 초기화는 별도 계약이며, 이 Story는 일반 운영 Reference로 승격하지 않는다.
- **Legacy 호환 범위**: `controlled`를 생략한 DatePicker / Combobox는 기존 `value !== undefined` 판정을 유지한다. 외부 undefined 초기화 시 내부 값이 다시 표시될 수 있으므로, 과거 `key` remount 우회 설명은 이 Legacy 호출에 한정한다. 신규 연동은 명시적 Controlled 모드를 사용한다.
## 13. 관련 파일 / 검증
- 2026-10-08: `With Filter Bar` verification Story 소스를 격리 Headless Edge 환경에서 실행했다. 부서(검색/global filter) + 상태/등록일(column filter)로 1행 조회 후 초기화하면 전체 47개와 기존 표 표시를 복원하고 날짜·부서 Trigger DOM을 유지했다. 영향 범위 타입 검사 통과. 공용 dist / Storybook 빌드 및 전체 시각 회귀는 수행하지 않았다.
```
packages/ui/src/patterns/data-table/{data-table.tsx,index.ts}
packages/ui/src/theme/data-table.css
packages/ui/src/index.ts, theme.css, tailwind.css(@source patterns)
src/patterns/data-table.stories.tsx, sample-users.ts
```
- `npm run typecheck`, `npm run build` 통과(2026-10-03).
- Browser 확인: 정렬 none → asc → desc → none과 `aria-sort`, header mixed / 전체 / 해제와 선택 수 요약·`data-state="selected"`, 페이지 이동, 열 숨김(메뉴 유지), 행 액션이 행 데이터를 받음(toast), Loading(skeleton 30개, `aria-busy`), Empty(`colSpan`), 390px overflow 없음. 연동은 `filter-bar.md` §13.
+149
View File
@@ -0,0 +1,149 @@
# Filter Bar (Pattern)
현재 HM 구현의 기준 문서. 공통 원칙은 `docs/guidelines/프로젝트-지침.md`(§6, §7, Pattern 절)를 따른다.
## 1. 상태
| 항목 | 내용 |
|---|---|
| 구현 상태 | 구현 완료 (현재 HM 규격) |
| 기준일 | 2026-10-03 |
| 기반 | HM primitives 조합 (의존성 추가 없음) |
| 대표 Story | `Patterns/Data/Filter Bar` → `Filter Bar`, 연동 검증은 `Patterns/Data/Data Table` → `With Filter Bar` (숨김 verification) |
| 보류 | §12 |
## 2. 목적 / 사용 범위
목록·업무 화면의 검색 조건 행. Data Table 없이도 쓸 수 있고, 값은 소비자가 소유한다. ui-viz의 Search Field(선택값 칩 + 후보)는 별개의 복합 컨트롤이라 이 Pattern에 넣지 않았다.
## 3. 구성 요소
`FilterBar`(flex wrap 레이아웃), `FilterBarSearch`(검색 아이콘 + HM Input + 지우기), `FilterBarActions`(행 끝 정렬: 초기화·조회 등). 나머지는 HM Select / Combobox / Date Picker / Button을 children으로 직접 둔다. schema 기반 필터 생성은 없다.
## 4. 구조
```tsx
<FilterBar>
<FilterBarSearch value={q} onValueChange={setQ} placeholder="이름·부서 검색" />
<Select>…</Select> <Combobox … /> <DatePicker … />
<FilterBarActions><Button variant="cancel" appearance="outline">초기화</Button></FilterBarActions>
</FilterBar>
```
## 5. Public API
- `FilterBar`, `FilterBarActions`: `div` props. wrapper에 role을 두지 않는다.
- `FilterBarSearch`: `value`, `onValueChange`(필수), `size`(Input size, 기본 `medium`), `placeholder`(기본 "검색"), 나머지 `input` props. `aria-label` 기본값은 placeholder. 값이 있으면 지우기 버튼(`aria-label="검색어 지우기"`).
- Reset은 소비자가 Button과 callback으로 구성한다(Pattern에 `onReset` prop 없음).
- Dashboard의 빠른 상태 전환(`all / active / hold / done / issue`)은 `FilterBar`가 소유하지 않는다. `FilterBar`는 검색·Select·Combobox·DatePicker 등의 조회 조건 layout으로 유지하고, 상태 필터는 Dashboard status contract를 소비하는 별도 조합으로 배치한다.
## 6. 상태 관리
Pattern은 상태가 없다. Data Table과 연결할 때는 검색 → `table.setGlobalFilter`, 열 선택 → `column.setFilterValue`로 소비자가 잇는다(`data-table.md` §6).
FilterBar에는 `onSearch / onSubmit / onReset` 공개 API나 조회 시점 강제가 없다. 검색어 지우기도 `onValueChange("")`만 호출한다. 즉시 적용할지 조회 버튼으로 적용할지, 초기화 후 바로 전체 목록을 복원할지는 소비자가 결정한다. [대표 Story](../../src/patterns/filter-bar.stories.tsx)의 조회 버튼에는 실행 callback이 없고 조건 수는 입력값으로 계산한다. [DataTable 연동 verification](../../src/patterns/data-table.stories.tsx)의 `WithFilterBarStory`는 입력 즉시 table 필터를 변경하며 조회 버튼은 없다. 이 둘을 조회 버튼 적용 계약으로 해석하지 않는다.
DatePicker / Combobox는 대표 Story에서 `controlled={true}`로 사용한다. 부모가 각 `value`를 소유하고 초기화 시 `setDate(undefined)` / `setCategory(undefined)`로 비우므로, 선택값 초기화 목적의 `key` remount는 필요 없다. 검색·상태 등 나머지 조회 조건도 부모가 함께 초기화한다. FilterBar 공개 API와 초기화 / 조회 Outline 버튼 세트는 유지한다. 외부 값 초기화는 자식 `onValueChange`를 호출하지 않으며, 팝업 닫기·검색어·달력 월 초기화를 보장하지 않는다.
### 6.1 조회 버튼으로 적용하는 소비 예제
`draft`는 입력·선택 중인 조건, `applied`는 실제 목록에 적용한 조건이다. 아래 예는 조회 클릭에만 `draft`를 복사하고, 초기화 클릭에는 두 상태와 페이지를 함께 초기화해 전체 목록을 즉시 복원한다. Pattern의 강제 동작이 아닌 소비 정책이다. Theme/Tailwind CSS는 [React 연결 순서](../guidelines/runtime-portability.md#2-실제-hm-계약과-파일-경로)에 따라 앱 entry에서 로드한다.
```tsx
import { useMemo, useState } from "react"
import {
Button, DataTable, FilterBar, FilterBarActions, FilterBarSearch,
Select, SelectContent, SelectItem, SelectTrigger, SelectValue, useDataTable,
type DataTableColumnDef,
} from "@hm-design/ui"
type User = { id: string; name: string; status: "active" | "inactive" }
type Conditions = { query: string; status: string }
const empty: Conditions = { query: "", status: "all" }
const rows: User[] = [
{ id: "U-1", name: "김한맥", status: "active" },
{ id: "U-2", name: "이한맥", status: "inactive" },
]
const columns: DataTableColumnDef<User>[] = [
{ accessorKey: "name", header: "이름" },
{ accessorKey: "status", header: "상태", cell: ({ row }) => row.original.status === "active" ? "활성" : "비활성" },
]
export function QueryList() {
const [draft, setDraft] = useState<Conditions>({ ...empty })
const [applied, setApplied] = useState<Conditions>({ ...empty })
const data = useMemo(() => rows.filter(row =>
row.name.toLocaleLowerCase().includes(applied.query.trim().toLocaleLowerCase()) &&
(applied.status === "all" || row.status === applied.status)
), [applied])
const table = useDataTable({ columns, data, getRowId: row => row.id })
const apply = () => { setApplied({ ...draft }); table.setPageIndex(0) }
const reset = () => {
setDraft({ ...empty })
setApplied({ ...empty })
table.setPageIndex(0)
}
return <>
<FilterBar>
<FilterBarSearch value={draft.query} onValueChange={query => setDraft(s => ({ ...s, query }))} placeholder="이름 검색" />
<Select value={draft.status} onValueChange={status => setDraft(s => ({ ...s, status }))}>
<SelectTrigger size="medium" aria-label="사용자 상태"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">전체 상태</SelectItem>
<SelectItem value="active">활성</SelectItem>
<SelectItem value="inactive">비활성</SelectItem>
</SelectContent>
</Select>
<FilterBarActions>
<Button type="button" variant="cancel" appearance="outline" size="default" onClick={reset}>초기화</Button>
<Button type="button" variant="primary" appearance="outline" size="default" onClick={apply}>조회</Button>
</FilterBarActions>
</FilterBar>
<p role="status">조회 결과 {data.length}건</p>
<DataTable table={table} emptyMessage="조건에 맞는 사용자가 없습니다." />
</>
}
```
이 예의 사용자 상태는 Dashboard 업무 상태와 다른 도메인이다. `data`/`columns` 참조를 안정적으로 유지하고 적용 조건으로 만든 결과만 DataTable에 전달한다. table 내부 global/column 필터를 사용하는 방식이면 조회 callback에서 해당 필터를 갱신하고 초기화 callback에서 `resetGlobalFilter(true) / resetColumnFilters(true)`도 호출한다. 서버 조회라면 `applied`를 요청 조건으로 사용하고 결과·loading·error는 소비자가 관리한다. 코드·Props는 공개 소스와 대조했으며 이 문서 작업에서 실행 검증은 하지 않았다.
## 7. 조합 컴포넌트
Input, Select, Combobox, Date Picker, Button(그 외 Popover 등 자유). 각 컨트롤의 모양·상태·접근성은 해당 컴포넌트가 소유한다.
## 8. Styling / Token
레이아웃만: `packages/ui/src/theme/filter-bar.css`의 `--hm-filter-bar-*`(gap 8px, control 폭 180px, search 폭 280px, 아이콘 크기·위치·색). 검색 텍스트 시작점은 아이콘 inset 12px + 크기 16px + gap 8px이며, Input의 Focus / Error / Readonly 상태에서도 같은 위치를 유지한다(Input 자체 CSS는 변경하지 않음). 직접 자식인 Select / Combobox / Date Picker trigger와 Input에 컨트롤 폭을 준다. 640px 이하: search는 한 줄 전체, 컨트롤은 한 줄에 2개씩, actions는 한 줄에 우측 정렬. spacing 전역 scale이 없어 값은 Pattern 내부 값이다.
## 9. Storybook
`Patterns/Data/Filter Bar` → `Filter Bar`: 검색, 상태 Select, 분류 Combobox, 날짜 Date Picker, 초기화 / 조회, 적용된 조건 수.
대표 Story의 초기화 / 조회는 목록의 반복 조회·보조 작업 세트다. [Button 사용 위치와 버튼 세트 규칙](../components/button.md#5-사용-위치와-버튼-세트)에 따라 둘 다 `appearance="outline"`, `size="default"`를 사용하고 역할만 `cancel` / `primary`로 구분한다. FilterBar는 Button appearance를 강제하지 않으며 소비자가 같은 세트의 appearance와 size를 맞춘다. 대표 Story의 조회는 실행 callback이 없는 조합 예시이고, 적용된 조건 수는 입력 상태에 즉시 반영된다.
## 10. Accessibility
검색은 `type="search"` + `aria-label`, 아이콘은 `aria-hidden`. 컨트롤마다 `aria-label`을 준다(Story 기준). Reset·Clear는 `button`. wrapper는 시맨틱 변경 없음. 브라우저 기본 검색 지우기 버튼은 숨기고 자체 버튼을 쓴다.
## 11. Responsive
390px: search 전체 폭, 상태 / 분류 한 줄, 날짜 한 줄, actions 우측 정렬. 페이지 가로 overflow 없음. 모바일 Filter Drawer는 만들지 않았다.
## 12. 보류 / 비범위
active filter chip·필터 수 배지 시스템, schema-driven 필터, URL / localStorage 연동, 고급 필터 Popover, 날짜 범위.
**Legacy 호환 범위**: DatePicker / Combobox에서 `controlled`를 생략하면 기존처럼 `value={undefined}`를 Uncontrolled로 취급하므로, 외부 undefined 초기화 시 내부 값이 다시 표시될 수 있다. 기존 `key` remount 우회는 이 Legacy 방식에서 사용할 수 있으나, 대표 Story는 명시적 Controlled 모드로 전환하고 해당 우회를 제거했다. Combobox의 `""`는 유효한 옵션 값이므로 일반적인 선택 없음 계약으로 대체하지 않는다.
## 13. 관련 파일 / 검증
- 2026-10-08: 대표 Story 소스를 격리 Headless Edge 환경에서 실행해 검색·분류·날짜 초기화, 적용 조건 수 0, 두 Trigger DOM 동일성 유지를 확인했다. 선택값 초기화 목적의 remount가 없다. 영향 범위 타입 검사 통과. 공용 dist / Storybook 빌드 및 전체 시각 회귀는 수행하지 않았다.
```
packages/ui/src/patterns/filter-bar/{filter-bar.tsx,index.ts}
packages/ui/src/theme/filter-bar.css
src/patterns/filter-bar.stories.tsx
```
- `npm run typecheck`, `npm run build` 통과(2026-10-03).
- Browser 확인(`With Filter Bar`, 47행): 검색 "설계" → 10행, + 상태 "대기" → 2행, + 날짜 10/11 → 1행, 검색 "zzzz" → 빈 결과(문구, `colSpan`), 초기화 → 47행 복원. 390px에서 위 배치, overflow 없음.
+283
View File
@@ -0,0 +1,283 @@
# GNB (Pattern)
> **SINGLE NAVIGATION MODE.** 독립 GNB / LNB / Dashboard Mode는 함께 사용하지 않는다. 2026-10-08 확정: GNB ONLY는 기존 LNB 부품을 재사용하는 내부 Sidebar를 포함할 수 있다(§15). [공통 정책](../guidelines/navigation-single-mode.md)이 이전 조합 기록보다 우선한다.
현재 HM 구현의 기준 문서. 공통 원칙은 `docs/guidelines/프로젝트-지침.md`(§6.10 Pattern 계층, §6.11 Layout 단계 기준)를 따른다.
```text
Layout visual reference: 외부 ui_viz Reference (ZIP 미포함) (Patterns/GNB, Variant B Top-header)
Implementation: current @hm-design/ui primitives / patterns
```
## 1. 상태
| 항목 | 내용 |
|---|---|
| 구현 상태 | 구현 완료 (2026-10-03 ui-viz 시각 재정합) |
| 기반 | HM primitives 조합 (새 dependency 없음) |
| 대표 Story | `Patterns/Layout/GNB` → `GNB` (verification: Default, Navigation Menu Open, User Menu Open, Long Labels, With Logo, Utilities, Mobile, Mobile Menu Open) |
| 이력 | 첫 구현은 밝은 surface 64px였다. Layout은 ui-viz 시각 우선이라는 결정(지침 §6.11)으로 green 70px로 재정합했다 |
| 보류 | §13 |
## 2. 목적 / 사용 범위
서비스 전역 상단 바(브랜드, 주 메뉴, 사용자). 새 Navigation/Menu primitive가 아니라 기존 primitive의 조합이다. 메뉴 데이터는 소비자가 children으로 그린다.
| | 역할 |
|---|---|
| GNB | 전역 영역·서비스 이동 |
| LNB | 현재 영역 내부의 세부 navigation (`lnb.md`) |
| Dashboard | 별도의 Dashboard Navigation Mode. GNB와 결합하지 않는다 |
ui-viz에서 GNB(Variant B, top-header)와 LNB(Variant A, sidebar)는 서로 대체하는 별개 Layout이다. 독립 Navigation Mode의 혼합은 금지한다. GNB ONLY의 내부 Sidebar에서 LNB 구현 부품을 재사용하는 것은 별도 LNB Mode 추가가 아니다(§15).
## 3. 구성 요소
| export | 역할 |
|---|---|
| `GNB` | `<header>` 바. `sticky` prop |
| `GNBBrand` | 브랜드 링크(`<a>`, `asChild` 가능). 로고(`img`/`svg`, 있을 때만) + `<span>`(`<small>` eyebrow, `<strong>` 이름) |
| `GNBNav` | 주 메뉴 영역(desktop 전용). 안에 `NavigationMenu viewport={false}` |
| `GNBUtilities` | 오른쪽 영역 |
| `GNBUser` | 사용자 trigger(Avatar + 이름/역할, 글자는 mobile에서 숨김). `DropdownMenuTrigger asChild`로 사용 |
| `GNBMobileMenu` / `GNBMobileLink` / `GNBMobileGroup` | mobile 햄버거 + Sheet + 링크/그룹 |
검색·알림은 컴포넌트가 아니다. ghost Button + Tooltip + Badge + Popover(+Command) 조합을 `GNBUtilities`에 둔다(Story `Utilities`).
## 4. 구조
```tsx
<GNB sticky>
<GNBMobileMenu title="메뉴">…<GNBMobileLink href active>…</GNBMobileLink></GNBMobileMenu>
<GNBBrand href="/"><span><small>한맥 업무 시스템</small><strong>프로젝트 대시보드</strong></span></GNBBrand>
<GNBNav>
<NavigationMenu viewport={false} aria-label="주 메뉴">
<NavigationMenuList>
<NavigationMenuItem><NavigationMenuLink href active>대시보드</NavigationMenuLink></NavigationMenuItem>
<NavigationMenuItem value="projects">
<NavigationMenuTrigger aria-current="page">프로젝트</NavigationMenuTrigger>
<NavigationMenuContent><ul><li><NavigationMenuLink href>…</NavigationMenuLink></li></ul></NavigationMenuContent>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
</GNBNav>
<GNBUtilities>
<DropdownMenu><DropdownMenuTrigger asChild><GNBUser name description><Avatar/></GNBUser></DropdownMenuTrigger>…</DropdownMenu>
</GNBUtilities>
</GNB>
```
`viewport={false}`가 필수다: 2Depth 패널은 각 trigger 아래에서 content-width(`max-content`)로 열리며 짧은 패널도 trigger 폭 이상을 유지한다. trigger 중심에 정렬하고 viewport 가장자리에서는 clamp한다. 긴 라벨은 한 줄을 유지하며 최대 가용 폭을 넘으면 패널 안에서 가로 스크롤한다(공유 viewport는 메뉴 전체 아래에 중앙 정렬된다).
## 5. ui-viz 비교 (2026-10-03, 원본: `dashboard-composition.md` §19, `top-header-specimen.css/jsx`)
> HISTORICAL_RECORD: 아래 표는 2026-10-03 비교 기록이다. 현재 패널 폭·정렬은 §4, Current/Open 우선순위는 §7을 따른다.
| 항목 | ui-viz | 기존 HM(첫 구현) | 최종 HM | 분류 |
|---|---|---|---|---|
| 높이 | Header 70 (위 4 + 메뉴 면 66) | 64 | 70 (`--hm-gnb-height`) | MATCHED |
| 배경 | 세로 4-stop `#1F8265/#165C47/#114838/#08241C` 0/30/65/100%, 높이 82px(70+12), 반복 없음 | 흰 surface | 같은 stop(`--hm-green-tone-1~4`), 82px 단일 gradient | MATCHED |
| 콘텐츠 접점 | 12px, 콘텐츠 상단 radius 12, 뒤로 gradient 연속 | 없음 | `::after` 12px strip(`--hm-gnb-join`). 콘텐츠가 positioned·뒤에 오고 radius를 가져야 한다 | IMPLEMENTATION_CHANGE |
| 로고 | 있을 때만, 가로(로고 왼쪽·이름 오른쪽), 없으면 영역·간격 없음 | 이름만 | 같음. eyebrow(11/700, `#BDDFD4`) + 이름(16/700), 1px 글자 테두리 | MATCHED |
| 메뉴 위치 | grid 좌우 같은 폭 → header 전체 가운데 | brand 옆 왼쪽 | `minmax(0,1fr) auto minmax(0,1fr)` grid, 중앙 오차 0 | MATCHED |
| 메뉴 항목 | 위 4·높이 66, 최소 112, padding 0 16, radius 4 4 0 0, White 700 16, 아이콘 없음, 글자 테두리 없음 | 34px text button | 같음 (Navigation Menu를 GNB 범위 안에서만 재색칠) | MATCHED |
| hover | Black 10% + White | `--accent` | Black 10% + White | MATCHED |
| current / selected shape | Beige(`bg.content`) + `#123328`, 위 radius 4, 아래 좌우 12px 오목 곡선, hover가 덮지 않음 | 연한 초록 면 | 같음 (`data-active`/`aria-current` + `::before/::after` radial) | MATCHED |
| 열림(open) | White 면 + `#123328`, 곡선 숨김 | `--accent` | 같음 (`data-state=open`). current와 구분 | MATCHED |
| 2뎁스 패널 | White, 부모 폭, 항목 56/16/16·24·700 가운데, hover·현재 Beige, 하단 radius 8, shadow `0 6px 16px .1` | 320px floating | 같음 | MATCHED |
| utility | 원형 버튼·avatar 제거(2026-10-01 결정). 이름·역할만 | 검색·알림·Avatar | 기본은 이름·역할만. 검색·알림은 선택 조합(흰 ghost Button) | MATCHED / 선택 조합 |
| 사용자 영역 | 이름 16/700 + 역할 12 `#BDDFD4`, 오른쪽 끝 (ui-viz는 avatar 없음) | Avatar + 글자 | HM Avatar(default 32px) + 글자 + Dropdown Menu. mobile은 Avatar만 | INTENTIONAL_CHANGE (2026-10-03 사용자 지시로 Avatar 추가) |
| desktop 구조 | Brand / Menu / Profile | 같음 | 같음 | MATCHED |
| mobile 구조 | 없음(≤1100px/48px은 OUT_OF_SCOPE) | 햄버거 + Sheet | 유지. breakpoint 1024px | INTENTIONAL_CHANGE (ui-viz에 기준 없음) |
| 2뎁스 열기 | hover/click/ArrowDown, Escape + focus 복귀 | Radix | Radix Navigation Menu(hover delay, 키보드 model) | IMPLEMENTATION_CHANGE |
## 6. Public API
- `GNB`: `header` props + `sticky`(기본 false). `data-slot="gnb"`, sticky면 `data-sticky`.
- `GNBUser`: `button` props + `name`(필수), `description`, children = Avatar(default size, 이미지 실패 시 `AvatarFallback`). `aria-label` 기본 `"<name> 사용자 메뉴"`. `data-slot`은 spread 뒤에 둬서 `DropdownMenuTrigger asChild`가 덮지 않는다.
- `GNBMobileMenu`: 햄버거(`lg:hidden`)와 Sheet. `title`, `side`, `open`/`defaultOpen`/`onOpenChange`. `GNBMobileLink`는 router `preventDefault`가 있어도 닫히도록 context로 직접 닫는다(Radix Close는 prevented click을 건너뛴다).
## 7. Navigation 원칙
- 주 메뉴는 HM Navigation Menu다. `<nav>` landmark는 Navigation Menu가 렌더하므로 `GNBNav`는 role을 만들지 않는다. Menubar는 쓰지 않는다.
- 현재 위치: Link는 `active`(`data-active` + `aria-current="page"`), 하위가 있는 Trigger는 소비자가 `aria-current="page"`를 준다. 하위 링크의 `active`는 패널 안 Beige.
- 상태 구분: Hover = Black 10%, Current = Beige + Primary, Open = White + Primary(패널과 이어짐). Current + Hover는 Current, Open + Hover는 Open을 유지하며 Current + Open에서는 Open visual이 우선한다. Focus는 GNB의 contextual navigation outline을 유지한다. Radix의 `data-state`, `data-active`, `aria-current`로 시각만 맞춘다.
- Navigation Menu primitive는 수정하지 않았다. 모든 재색칠은 `[data-slot="gnb"] …` 안에서만 일어나며 Navigation Menu Story는 변하지 않는다.
## 8. Desktop / Mobile
**AI 구현 필수**: `GNBNav`는 모바일에서 숨겨질 뿐 모바일 메뉴를 자동 생성하지 않는다. 최초 구현부터 Desktop `GNBNav`와 Mobile `GNBMobileMenu`를 함께 넣고 같은 메뉴 데이터·현재 경로를 사용한다. `GNBMobileMenu`가 햄버거와 Sheet를 모두 제공하므로 별도 메뉴 버튼이나 Sheet를 추가하지 않는다. 기본 GNB는 `GNBMobileLink / GNBMobileGroup`을 사용한다. 링크 선택은 router의 `preventDefault()` 여부와 관계없이 Sheet를 닫으며, 닫기 버튼·Escape·focus trap·메뉴 버튼으로의 포커스 복귀는 기존 Sheet/Radix 동작을 유지한다.
Reference는 [대표 Story 소스](../../src/patterns/gnb.stories.tsx)의 `All`(`Patterns/Layout/GNB → GNB`, `patterns-layout-gnb--all`) 안 Overview·Usage · GNB Sidebar를 좁은 폭에서 확인한다. `Mobile / Mobile Menu Open`은 숨김 verification이며 동작 검사에만 사용한다. Sidebar를 포함하면 [§15의 공개 API 소비 예제](#15-gnb-only-sidebar-composition-2026-10-08)를 따른다. LNB 모바일 링크 재사용 시 자동 닫기 Context가 다르므로 controlled Sheet를 route handler에서 닫고 Desktop 복귀 시에도 닫는다. 이는 GNB ONLY 내부 Composition이며 별도 LNB ONLY 모드가 아니다.
- Desktop(1024px 이상): 위 표. 높이 70, 좌우 28, column gap 24.
- Mobile(1023px 이하): 높이 56, padding 16. 주 메뉴·eyebrow·사용자 글자를 숨기고 햄버거(흰 ghost Button, `aria-label`)를 맨 앞에, 사용자는 같은 Avatar 하나만 보인다(중복 없음). 햄버거 → HM Sheet(left): 제목, 링크(44px), 그룹 제목. Radix가 focus trap·Escape·focus 복귀를 맡는다. breakpoint를 768에서 1024로 올린 이유: 메뉴 4개(항목 최소 112) 가운데 정렬은 그 이하에서 brand/profile이 너무 좁아진다. ui-viz에는 반응형 기준이 없다.
- 중첩 그룹(2026-10-06): `GNBMobileGroup` 안에 `GNBMobileGroup`을 두면 한 단계 들여쓴다(`--hm-gnb-mobile-nested-indent` 12 = 링크 padding, 위 `--hm-gnb-mobile-nested-gap` 8). GNB 자체의 깊은 메뉴를 Sheet에 같은 트리로 보이기 위한 CSS만 추가했고 API·동작은 그대로다. 접기/펼치기와 상위 그룹 current 표시는 없다(모든 단계가 펼쳐진 목록, 현재 위치는 링크의 `aria-current`). 글자는 GNB Sheet 계약 14/20/500(current 700) 그대로다. LNB 모바일 표현은 LNB 전용 API를 따른다.
## 9. Styling / Token
`packages/ui/src/theme/gnb.css`의 `--hm-gnb-*`.
| Token | 값 |
|---|---|
| `--hm-gnb-height` | 70px (mobile 56px). Layout이 top offset으로 읽는다 |
| `--hm-gnb-join` | 12px. 콘텐츠 접점(바 아래로 gradient가 이어지는 길이) |
| `--hm-gnb-background` | 세로 4-stop gradient(`--hm-green-tone-1~4`) |
| `--hm-gnb-padding-x` / `--hm-gnb-column-gap` | 28 / 24 (mobile 16) |
| `--hm-gnb-z-index` | 40 (sticky일 때만; overlay는 portal z-50) |
| menu | inset-top 4, join 12, min-width 112, padding-x 16, 16/700 |
| current / open / panel | Beige(`--hm-sem-bg-content`)+`--hm-sem-action-primary` / White / White, 하단 radius 8, shadow |
새 Primitive/Semantic은 GNB에서 추가하지 않았다. 4-stop은 공개 `--hm-sem-action-surface-stop-*`, 글자 보조색은 `--hm-sem-palette-green-tint`를 참조한다. 하위 패널 radius 8·shadow, 글자 테두리는 ui-viz가 검토값으로 둔 값을 그대로 가져왔다.
## 10. Position / z-index / Layout 연결
- 기본 relative(일반 흐름), `sticky` 옵션, fixed 없음. GNB Mode 페이지가 sticky를 쓸지 결정한다.
- 접점은 일반/sticky 모두 같은 12px gradient를 콘텐츠의 둥근 윗모서리 바깥에만 mask로 그린다. sticky stacking context에서도 콘텐츠 중앙을 초록색 띠로 덮지 않는다. 높이·gradient·메뉴·프로필·색상은 공통이고 sticky selector는 position/top/z-index만 바꾼다.
- `Sticky` verification Story와 대표 Story의 Sticky 예시는 같은 스크롤 영역을 사용한다. 테두리와 스크롤바 폭을 제거해 일반 예시와 폭을 맞추며, 휠/터치와 포커스 후 PageDown/End/Home으로 스크롤할 수 있다.
- z-index: GNB 40 < portal overlay 50. 하위 패널은 GNB 안(z 2~3)에서 열려 콘텐츠 위에 그려진다. 체계는 바꾸지 않았다.
- GNB Mode 콘텐츠 계약: 바 다음 콘텐츠 면은 `position: relative`, DOM상 뒤, 윗모서리 `border-radius: var(--hm-gnb-join) var(--hm-gnb-join) 0 0`, 시작 y = `var(--hm-gnb-height)`(접점 strip과 겹침, 밀어내지 않음). 높이는 `--hm-gnb-height`로 읽는다.
## 11. Accessibility / 검증
- `<header>` + Navigation Menu `<nav aria-label>`, mobile `<nav aria-label>`, 그룹 `role="group"`. 아이콘 버튼 `aria-label`, Badge `aria-hidden`.
- Focus: 흰 outline(초록 위), current/open은 초록 outline.
- 확인(2026-10-03, Storybook): 1280 데스크톱 높이 70, 메뉴 항목 위 4·높이 66·최소 112, 메뉴 중앙 오차 0, current Beige `#F5F0E5`/`#123328`/700, 열림 White 패널·긴 하위 이름 줄바꿈. 1024/1023 전환, 390 햄버거→Sheet 열림, Escape·링크 클릭 모두 닫힘 + 햄버거로 focus 복귀(녹색 재정합 후 재확인), 가로 overflow 없음(1024, 1023, 390, long labels). `npm run typecheck`, `npm run build` 통과. Navigation Menu Story computed style 변화 없음.
## 12. 변경하지 않은 것
Navigation Menu, Dropdown Menu, Sheet, Avatar, Button, Tooltip, Badge, Popover, Command 코드·theme CSS는 수정하지 않았다.
## 13. 남은 차이 / 보류
| 항목 | 분류 |
|---|---|
| ui-viz 후보 1뎁스 18/28·폭 160·padding 24 (ui-viz도 FOLLOW_UP) | FOLLOW_UP. 현재 2Depth 패널은 고정 160px이 아니라 §4의 content-width/center/clamp |
| 하위 패널 radius 8·shadow, 글자 테두리 (ui-viz REVIEW VALUE) | 값 그대로 적용 |
| 독립 GNB + LNB 또는 GNB + Dashboard | 사용 금지. GNB ONLY 내부 Sidebar 부품 재사용은 §15 범위에서 허용 |
| sticky일 때 접점 strip 처리, 콘텐츠 radius 소유 | 접점 mask 정합 완료(2026-10-06). 콘텐츠 radius는 소비 Layout 소유 |
| 긴 메뉴명이 공간을 넘으면 메뉴 영역에서 잘림 | 한계 |
| 로고 asset 없음(테스트 이미지만), 로고 크기·간격은 ui-viz D-070 미확정 | MISSING |
| 실기기 터치, 1100~1280 사이 시각 | 미확인 |
| GNB Sheet 그룹 접기·상위 current 표시 | 미지원. 현재 링크 목록 방식 유지 |
| Notification Center, 전역 검색 결과, Organization/App Switcher | 후속 |
## 14. 관련 파일
```
packages/ui/src/patterns/gnb/{gnb.tsx,index.ts}
packages/ui/src/theme/gnb.css
src/patterns/gnb.stories.tsx
```
## 15. GNB ONLY Sidebar Composition (2026-10-08)
대표 `Patterns/Layout/GNB → GNB`의 **Usage · GNB Sidebar**에서 확인한다. 직접 동작 검증은 숨김 `GNB Sidebar` Story(`patterns-layout-gnb--sidebar`)를 사용한다. 기존 `LNB → VERIFICATION ONLY · GNB + LNB`는 운영 기준으로 승격하지 않는다.
구조는 **GNB Header → Sidebar + Main Content**다. Sidebar는 공개 `LNB / LNBNav / LNBGroup / LNBItem`을 그대로 사용한다. 기존 DOM/data-slot을 유지하고 브랜드·사용자 영역은 GNB만 담당한다. `LNBBrand / LNBProfile`은 기본 조합에서 생략한다. 새로운 GNBSidebar 공개 컴포넌트·메뉴 로직은 없다.
공용 `@hm-design/ui/theme.css`의 opt-in HTML attribute 계약:
- 프레임: `data-hm-gnb-layout="sidebar"`. 소비자가 높이를 지정하며 Grid가 GNB의 자연 높이와 남은 본문 높이를 배분한다.
- 본문: 프레임의 직계 `data-hm-gnb-body`. Desktop은 기존 `--hm-lnb-width` 열 + 가용 콘텐츠 열, Mobile은 콘텐츠 한 열이다.
- Sidebar: 본문의 직계 `<LNB data-hm-gnb-sidebar>`. 기존 `data-slot="lnb"`는 유지한다. 이 범위의 `--hm-lnb-background`만 `var(--hm-sem-palette-green-light-2)`로 덮어쓴다. 기본 Green Light 2는 `#266651`이며 Runtime Theme override도 그대로 따른다. 측면 접점 배경은 LNB의 기존 `--hm-lnb-background-end`를 유지한다.
- Main: 본문의 직계 `<main data-hm-gnb-main>`. 공용 CSS가 면·접점·스크롤을 제공하며 콘텐츠 padding은 소비자가 정한다. GNB의 상단 접점과 LNB의 측면 접점은 기존 값을 재사용한다.
메뉴 폭·폰트·높이·padding·gap·아이콘·Depth·Hover·선택·연결선·스크롤은 LNB 그대로다. 반투명 Hover/Open 면의 합성 결과만 배경 변경에 따라 달라질 수 있으며 별도 보정하지 않는다. 그룹은 기존 Collapsible의 `open / onOpenChange`를 사용하고 GNB NavigationMenu 열림과 독립적으로 관리한다. 선택 시 새 자동 펼침을 추가하지 않는다.
Desktop은 ≥1024px, Mobile은 ≤1023px이다. Mobile에서 Sidebar는 기존 LNB CSS로 숨기고 **GNBMobileMenu 하나**에 `LNBMobileGroup / LNBMobileLink` 목록을 넣는다. `LNBMobileHeader`나 `LNBMobileMenu`를 함께 렌더하지 않는다. 모바일 Sheet 기본 면, 44px 링크, 14/20/500(current 700), 중첩 들여쓰기, Escape·포커스 복귀를 유지한다.
`LNBMobileLink`의 자동 닫기 Context는 LNBMobileMenu 전용이다. GNBMobileMenu 안에서 재사용할 때는 아래처럼 Sheet를 controlled로 연결하고 동일 route handler에서 닫는다. router가 `preventDefault()`를 호출해도 닫혀야 한다. Desktop 복귀 시 Sheet 상태를 닫는 처리도 소비자 Composition에 둔다. 모바일 그룹은 기존 펼쳐진 링크 목록이며 데스크톱 Collapsible을 복제하지 않는다.
### 소비자 사용 예제
아래는 실제 공개 API와 공용 Theme만 사용하는 React 예제다. 메뉴 데이터·현재 경로·그룹 상태는 소비자 소유이며 Story 파일을 import할 필요가 없다. 실제 앱에서는 `go`의 상태 갱신을 router 이동과 현재 경로 읽기로 연결한다.
```tsx
import { useEffect, useState, type MouseEvent, type ReactNode } from "react"
import {
GNB, GNBBrand, GNBNav, GNBUtilities, GNBUser, GNBMobileMenu,
LNB, LNBNav, LNBGroup, LNBItem, LNBMobileGroup, LNBMobileLink,
NavigationMenu, NavigationMenuList, NavigationMenuItem,
NavigationMenuTrigger, NavigationMenuContent, NavigationMenuLink,
Avatar, AvatarFallback, DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem,
} from "@hm-design/ui"
import "@hm-design/ui/theme.css"
type Node = { id: string; label: string; children?: Node[] }
const tree: Node[] = [
{ id: "overview", label: "대시보드" },
{ id: "projects", label: "프로젝트 관리", children: [
{ id: "projects/design", label: "디자인 관리", children: [
{ id: "projects/design/tokens", label: "토큰 관리" },
{ id: "projects/design/components", label: "컴포넌트 관리" },
] },
{ id: "projects/tasks", label: "업무 목록" },
] },
]
export function Example() {
const [path, setPath] = useState("projects/design/tokens")
const [groups, setGroups] = useState<Record<string, boolean>>({ projects: true, "projects/design": true })
const [sheetOpen, setSheetOpen] = useState(false)
useEffect(() => {
const desktop = window.matchMedia("(min-width: 1024px)")
const sync = () => { if (desktop.matches) setSheetOpen(false) }
desktop.addEventListener("change", sync)
return () => desktop.removeEventListener("change", sync)
}, [])
const onPath = (id: string) => path === id || path.startsWith(`${id}/`)
const go = (id: string) => (event: MouseEvent) => {
event.preventDefault()
setPath(id)
setSheetOpen(false)
}
const desktopMenu = (nodes: Node[]): ReactNode => nodes.map(node => node.children ? (
<LNBGroup key={node.id} label={node.label} current={onPath(node.id)}
open={groups[node.id] ?? false} onOpenChange={open => setGroups(s => ({ ...s, [node.id]: open }))}>
{desktopMenu(node.children)}
</LNBGroup>
) : <LNBItem key={node.id} href={`#${node.id}`} active={path === node.id} onClick={go(node.id)}>{node.label}</LNBItem>)
const mobileMenu = (nodes: Node[]): ReactNode => nodes.map(node => node.children ? (
<LNBMobileGroup key={node.id} label={node.label}>{mobileMenu(node.children)}</LNBMobileGroup>
) : <LNBMobileLink key={node.id} href={`#${node.id}`} active={path === node.id} onClick={go(node.id)}>{node.label}</LNBMobileLink>)
return <div data-hm-gnb-layout="sidebar" style={{ height: "100dvh" }}>
<GNB>
<GNBMobileMenu title="메뉴" open={sheetOpen} onOpenChange={setSheetOpen}>
<div data-slot="lnb-mobile-nav">{mobileMenu(tree)}</div>
</GNBMobileMenu>
<GNBBrand href="#overview" onClick={go("overview")}><span><strong>프로젝트 관리</strong></span></GNBBrand>
<GNBNav>
<NavigationMenu viewport={false} aria-label="주 메뉴">
<NavigationMenuList>
<NavigationMenuItem><NavigationMenuLink href="#overview" active={path === "overview"} onClick={go("overview")}>대시보드</NavigationMenuLink></NavigationMenuItem>
<NavigationMenuItem value="projects">
<NavigationMenuTrigger aria-current={onPath("projects") ? "page" : undefined}>프로젝트</NavigationMenuTrigger>
<NavigationMenuContent><ul><li>
<NavigationMenuLink href="#projects/tasks" active={path === "projects/tasks"} onClick={go("projects/tasks")}>업무 목록</NavigationMenuLink>
</li></ul></NavigationMenuContent>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
</GNBNav>
<GNBUtilities>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<GNBUser name="김한맥" description="프로젝트 관리자"><Avatar><AvatarFallback>김</AvatarFallback></Avatar></GNBUser>
</DropdownMenuTrigger>
<DropdownMenuContent align="end"><DropdownMenuItem>프로필</DropdownMenuItem></DropdownMenuContent>
</DropdownMenu>
</GNBUtilities>
</GNB>
<div data-hm-gnb-body>
<LNB data-hm-gnb-sidebar><LNBNav aria-label="서비스 메뉴">{desktopMenu(tree)}</LNBNav></LNB>
<main data-hm-gnb-main><h1>프로젝트 업무</h1><p>현재 경로: {path}</p></main>
</div>
</div>
}
```
사용자 메뉴와 콘텐츠 padding은 기존 GNB 사용 예제에 따라 소비자가 추가한다. Theme Source, LNB 원본, Runtime 계약, 공개 API는 변경하지 않는다. 공통 요약은 [AGENTS.md](../../AGENTS.md), 탐색 경로는 [Reference Index](../guidelines/component-pattern-reference.md)를 따른다.
검증(2026-10-08, 기존 Storybook 6024): 1440px Sidebar 272px / Green Light 2, 2·3Depth 선택·그룹 Enter/Space·Hover·선택 경로·메뉴 스크롤 확인. 768px·375px에서 GNB 56px / Sidebar 숨김 / Main 전체 폭 / 흰 Sheet 1개 / 링크 44px·14px / 선택 시 닫기·Escape·포커스 복귀 확인. 열린 Sheet에서 Desktop 복귀 시 닫힘과 선택 경로 보존 확인. 기존 GNB Overview·LNB 대표 메뉴·Dashboard Header computed style 비교 동일, 검사한 화면의 Console error 없음. 앱 TypeScript 검사 통과. 전체 프로젝트 회귀 테스트와 실기기 터치 검증은 수행하지 않았다.
+238
View File
@@ -0,0 +1,238 @@
# LNB (Pattern)
> **2026-10-07 확정: SINGLE NAVIGATION MODE.** GNB / LNB / Dashboard는 함께 사용하지 않는다. AI 코드 생성·제안, 신규/기존 페이지, 프로토타입, Storybook, Production 전체에 적용한다. [공통 정책](../guidelines/navigation-single-mode.md)이 이전 조합 기록보다 우선한다.
현재 HM 구현의 기준 문서. 공통 원칙은 `docs/guidelines/프로젝트-지침.md`(§6.10, §6.11)를 따른다.
```text
Layout visual reference: 외부 ui_viz Reference (ZIP 미포함) (Patterns/LNB, Variant A Sidebar)
Implementation: current @hm-design/ui primitives / patterns (HM Collapsible + plain elements)
```
## 1. 상태
| 항목 | 내용 |
|---|---|
| 구현 상태 | 구현 완료 (2026-10-03), 3뎁스 추가 (2026-10-06) |
| 기반 | HM Collapsible + 일반 요소 (새 dependency 없음) |
| 대표 Story | `Patterns/Layout/LNB` → `LNB` (verification: Third Level, Third Level Long Labels, Default, Selected, Expanded, Selected Child, Collapsed With Selection, With Logo, Long Labels, Scroll, Third Level Scroll, VERIFICATION ONLY · GNB + LNB, LNB Standalone Desktop, LNB Standalone Mobile, LNB Standalone Mobile Menu Open) |
| 보류 | §11 |
## 2. 목적 / 역할
LNB Mode 페이지의 독립 Navigation 체계. Desktop 열과 LNB Mobile Header는 같은 메뉴·선택 상태의 Responsive 표현이다. GNB 또는 Dashboard와 조합하지 않는다.
## 3. 구성 요소와 API
| export | 역할 |
|---|---|
| `LNB` | `<aside>` 열. 폭 `--hm-lnb-width` |
| `LNBBrand` | 선택적. LNB Mode의 서비스 브랜드를 담당한다(§3c). 세로형(로고 위·서비스명 아래, 같은 시작선). 로고는 `img`/`svg`가 있을 때만. `<span>`에 `<small>` eyebrow + `<strong>` 이름 |
| `LNBNav` | 메뉴 영역 `<nav>`(`aria-label` 지정). 길면 이 영역만 스크롤 |
| `LNBItem` | 링크(`<a>`, `asChild`). `active` → `aria-current="page"`. 앞의 `svg` 자식 = 아이콘(lucide) |
| `LNBGroup` | 2뎁스 그룹: HM `Collapsible`(`open`/`defaultOpen`/`onOpenChange`), `label`, `current`(하위 현재 → 이름·화살표 노랑). 그룹 안에 `LNBGroup`을 중첩하면 3뎁스(§3a) |
| `LNBProfile` | 하단 사용자 메뉴 trigger `<button>`: Avatar(children, default size) + 이름·역할 글자. `DropdownMenuTrigger asChild`로 사용 |
```tsx
<LNB>
{/* LNB Mode의 서비스 브랜드 */}
<LNBBrand><span><small>한맥 업무 시스템</small><strong>프로젝트 대시보드</strong></span></LNBBrand>
<LNBNav aria-label="서비스 메뉴">
<LNBItem href="#a" active><LayoutGridIcon />메뉴 1</LNBItem>
<LNBGroup label="메뉴 2" current><LNBItem href="#b" active>메뉴 2-1</LNBItem></LNBGroup>
</LNBNav>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<LNBProfile name="김한맥" description="프로젝트 관리자"><Avatar>…</Avatar></LNBProfile>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-(--radix-dropdown-menu-trigger-width)">
<DropdownMenuLabel>김한맥</DropdownMenuLabel>
<DropdownMenuItem>프로필 설정</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">로그아웃</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</LNB>
```
Collapsible의 trigger/content는 `asChild`로 자체 `button`/`div`를 써서 Collapsible의 기본 text-trigger 스타일을 쓰지 않는다(Radix `aria-expanded`/`aria-controls`/Enter/Space는 그대로).
## 3a. 3뎁스 (2026-10-06)
현재 interaction은 2Depth에서 들여쓴 링크 면, 3Depth에서 connector 끝부터 시작하는 링크 면에 한정한다. 3Depth connector는 상태와 무관하게 Green Tint 40%이며 장식 행은 pointer interaction을 소유하지 않는다. 아래 비교표는 도입 시점 기록이며 최신 Mapping은 §5를 따른다.
기준: 3Depth 도입 당시의 디자이너 공유 자료(외부 Reference, ZIP 미포함). 현재 구현 기준과 사용법은 이 문서, `packages/ui/src/patterns/lnb/`와 `src/patterns/lnb.stories.tsx`의 대표 Story를 따른다. ui-viz에는 3뎁스가 없다.
사용: 기존 `LNBGroup`을 그룹 안에 중첩한다. 새 컴포넌트·`depth` prop·state 없음. 실제 선택 링크 = `active`, 그 경로의 모든 그룹 = `current`(소비자 경로 상태에서 계산). 닫힌 그룹은 내용을 unmount하므로, 바깥 그룹을 접었다 펼 때 안쪽 그룹 열림을 유지하려면 각 그룹 `open`/`onOpenChange`를 소비 화면이 가진다.
```tsx
<LNBGroup label="프로젝트 관리" current open={open.projects} onOpenChange={…}>
<LNBGroup label="디자인 관리" current open={open.design} onOpenChange={…}>
<LNBItem href="#tokens" active>컬러 토큰</LNBItem>
<LNBItem href="#components">컴포넌트 스타일</LNBItem>
</LNBGroup>
<LNBItem href="#tasks">업무 목록</LNBItem>
</LNBGroup>
```
| 항목 | 명세 | 구현 | 분류 |
|---|---|---|---|
| 글자 | 1·2뎁스 18/28/700, 3뎁스 18/24/500, 선택 700 | 같음 | MATCHED |
| 3뎁스 행 | 최소 48, 상하 12, 링크 padding-left 88(2뎁스 68 + 20) | 같음 (1·2뎁스 56 유지) | MATCHED |
| 3뎁스 글자 장식 | 외곽선·그림자 없음 | 같음 | MATCHED |
| 긴 이름 | 줄바꿈, 행 높이 증가 | 48 / 72 / 96 (1·2·3줄), ellipsis 없음, 가로 overflow 0 | MATCHED |
| 선택 | Beige + `--hm-sem-action-primary`, 오른쪽 곡선 | 기존 current 규칙 그대로(폭 +1, 위·아래 오목 곡선) | MATCHED |
| 상위 경로 | 1·2뎁스 노랑, 접혀도 유지 | `current` → `--hm-yellow-accent` | MATCHED |
| 연결선 | 3뎁스만, 1px, 세로선 x 68, 가로 12, 글자까지 8, 행 세로 중앙, 마지막 행 중앙에서 끝 | 행마다 background 2겹(세로 `1px × 100%`, 마지막 행 `1px × 50%`, 가로 `12 × 1` at `calc(50% - 0.5px)`). DOM·tab stop·aria 없음 | MATCHED / IMPLEMENTATION_CHANGE(명세의 "배경 레이어" 방식) |
| 연결선 색 | 기본·hover `--hm-green-tint` 40%, 선택 `--hm-green-light-2` 40% | 같음. 선택 색은 선택된 행의 선에만 적용(나머지 행은 기본색) | MATCHED |
| 접힘 | 하위 메뉴·연결선 함께 숨김 | Radix가 내용을 unmount → 선도 같이 사라짐 | MATCHED |
| 중첩 그룹 | 배경·테두리·padding 중복, 폭 감소, 오른쪽 틈 금지 | 안쪽 그룹은 면·선·padding-bottom 없음, 폭 100%(바깥 그룹과 같은 252), 선택 항목 오른쪽 273(1px 겹침) | MATCHED |
| 안쪽 그룹 이름 위치 | 명세 수치 없음, 이미지상 2뎁스 글자와 같은 시작선 | trigger padding-left = 68 − 화살표 18 − gap 12 = 38 → 글자 x 68 | INTENTIONAL_CHANGE(이미지 기준) |
모바일(<1024): LNB ONLY는 Desktop 열을 숨기고 같은 메뉴를 독립 `LNBMobileHeader / LNBMobileMenu`로 표시한다(§3b). GNB ONLY 내부 Sidebar는 [GNB §15](gnb.md#15-gnb-only-sidebar-composition-2026-10-08)의 GNB 단일 Sheet를 사용하며 독립 LNB Mobile Header/Menu를 추가하지 않는다. DASHBOARD ONLY 운영 화면에는 Sidebar가 없다([Dashboard 현재 계약](dashboard-layout.md#1-현재-계약)).
<details>
<summary>HISTORICAL RECORD — 이전 GNB Sheet 혼합 안내</summary>
아래 안내는 이전 구성의 기록이며 현재 운영·신규 화면 Reference가 아니다.
모바일(<1024): LNB는 그대로 숨긴다(icon-only collapsed 없음, 새 breakpoint 없음). 같은 지역 메뉴 트리를 GNB 모바일 Sheet에 중첩 `GNBMobileGroup`으로 조합한다(`Patterns/Layout/Dashboard Layout` → `Third Level`, `gnb.md` §8). Sheet는 GNB의 시각 언어(14px 링크, Beige current)를 쓰고 LNB의 gradient·곡선·연결선은 가져가지 않는다. 화면에 GNB가 없을 때의 모바일 탐색은 §3b.
</details>
## 3b. LNB Mobile Header (2026-10-07)
| viewport | Desktop LNB | LNB Mobile Header |
|---|---|---|
| ≥1024px | VISIBLE | HIDDEN |
| ≤1023px | HIDDEN | VISIBLE |
모바일 Header와 Sheet는 LNB의 Responsive 표현이며 GNB가 아니다. 기존 GNB 모바일 부품 조합을 LNB 전용 공개 API로 대체했다. HM Sheet가 focus trap, Escape, focus 복귀를 맡고 링크 선택 시 router가 preventDefault를 사용해도 Sheet를 닫는다.
| export | 역할 |
|---|---|
| `LNBMobileHeader` | 모바일 전용 Header, 높이 56px / padding 16px. ≥1024px에서 자체 숨김 |
| `LNBMobileBrand` | 서비스명 및 선택적 로고, 링크 또는 `asChild` |
| `LNBMobileMenu` | 햄버거 + Sheet. `title`, `side`, `open/defaultOpen/onOpenChange` |
| `LNBMobileLink` | Sheet 링크, `active`, `asChild`, 선택 시 닫기 |
| `LNBMobileGroup` | 중첩 지역 메뉴 그룹 |
```tsx
<div className="grid grid-cols-1 lg:grid-cols-[var(--hm-lnb-width)_minmax(0,1fr)]">
<LNB>
<LNBBrand>…</LNBBrand>
<LNBNav aria-label="서비스 메뉴">{desktopMenu(tree)}</LNBNav>
<LNBProfile name={profile.name}>…</LNBProfile>
</LNB>
<div>
<LNBMobileHeader>
<LNBMobileMenu title="서비스 메뉴">
{mobileMenu(tree)}
<LNBProfile name={profile.name}>…</LNBProfile>
</LNBMobileMenu>
<LNBMobileBrand href="/">서비스명</LNBMobileBrand>
</LNBMobileHeader>
<main>…</main>
</div>
</div>
```
Desktop/Mobile은 메뉴 트리, 현재 위치, 그룹 상태, Profile 데이터 원본을 공유한다. 모바일 Sheet 링크는 44px 높이, 14/20/500(current 700)을 유지한다. 중첩 그룹은 12px 들여쓴다. Header/Sheet 스타일은 `--hm-lnb-mobile-*`와 공통 Semantic을 사용하며 GNB/Dashboard 변수에 의존하지 않는다.
## 3c. 브랜드·프로필 소유
LNB Mode가 Desktop Brand/Profile과 Mobile Header/Sheet Profile을 담당한다. 다른 Navigation에 소유권을 넘기지 않는다. `LNBBrand`는 선택적이며 생략하면 빈 자리 없이 메뉴가 올라간다. 공통 LNB 메뉴 스크롤·geometry는 그대로 유지한다.
기존 `WithGNB` Story는 `verification` 태그 및 화면과 이름에 **VERIFICATION ONLY**를 표시한 기술 검증 전용이다. 운영·신규 페이지·AI 생성 예제로 사용하지 않는다.
## 4. ui-viz 비교 (원본: `dashboard-composition.md` §6, `dashboard-variant-specimens.css/jsx`)
| 항목 | ui-viz | 최종 HM | 분류 |
|---|---|---|---|
| 폭 | track 272, visual 290(오른쪽 18 bleed) | 열 272(`--hm-lnb-width`), 뒤로 18(`--hm-lnb-join`) 같은 색 연장 | MATCHED (연장은 `::after`) |
| 배경 | 90° 5-stop `#123328/#205241/#266651/#249473/#266651` 0/30/50/90/100%, 트랙 폭, 트랙 밖 `#266651` | 같음 (`--hm-green-*`) | MATCHED |
| 상단 | 위 28, 브랜드 padding 0 10 22, 메뉴 위 20 | 위 28, 브랜드 inset 30(=20+10), 아래 22, 메뉴 위 20 | MATCHED |
| 브랜드 | 로고 조건부, 위 로고·아래 서비스명, eyebrow 11/700, 이름 17 | 같음 | MATCHED |
| 항목 | 18/28/700, 최소 56, padding 14 12 14 18, White, 아이콘 20 opacity .5, 글자 테두리 1.5px + 그림자 `1px 2px 1px` | 같음 | MATCHED |
| hover | Black 10%, 콘텐츠 경계까지, 시작 쪽 radius 4 | 같음 | MATCHED |
| current | Beige + `#123328`, 왼쪽 radius 8, 오른쪽 20+1 연장, 위·아래 18 오목 곡선, 안쪽 깊이 그림자(마지막 25% fade), 글자 효과 없음 | 같음. 깊이는 span 대신 background gradient 3겹으로 근사 | MATCHED / IMPLEMENTATION_CHANGE |
| nested(2뎁스) | 상위 = disclosure 버튼, 화살표 접힘 → 펼침 90°, 펼친 그룹 Black 20% + 1px `#266651` 선 + 왼쪽 radius 8, 하위 padding-left 68, 하단 8, 하위 선택 시 상위 노랑 `#FFC600` | 같음. HM Collapsible(Radix) 사용 | MATCHED / IMPLEMENTATION_CHANGE |
| divider | Brand/Profile 구분선 없음, 그룹 구분선은 그룹이 둘 이상일 때만(현재 없음) | 없음 | MATCHED |
| profile | 이름 16 + 역할 12, avatar 없음, 아래 22 | 위치·글자 같음 + Avatar 32px(gap 12, 세로 중앙) | INTENTIONAL_CHANGE |
| 외곽 brand 테두리(`.sidebar::after`) | D-093 예시에 없음, FOLLOW_UP | 그리지 않음 | FOLLOW_UP |
| collapsed(열 자체 접힘) | ui-viz에 없음 | 만들지 않음 (`--hm-lnb-collapsed-width` 없음). ui-viz의 "Collapsed"는 그룹 접힘이다 | — |
| mobile | ui-viz에 없음 | 열 숨김, `--hm-lnb-width: 0px`. LNB Mobile Header + LNB Sheet로 같은 목적지 표시(§3b) | INTENTIONAL_CHANGE |
| 스크롤 | ui-viz는 `overflow:hidden`(넘치면 잘림) | 메뉴 영역만 세로 스크롤(brand/profile 고정) | INTENTIONAL_CHANGE |
| 아이콘 | 4종 자체 SVG | lucide 아이콘을 소비자가 자식으로 둠(새 icon system 없음) | IMPLEMENTATION_CHANGE |
## 5. Token / CSS Variables
`packages/ui/src/theme/lnb.css`의 `--hm-lnb-*`.
| Token | 값 |
|---|---|
| `--hm-lnb-width` | 272px (1023px 이하에서 0px) |
| `--hm-lnb-join` | 18px. 콘텐츠 둥근 모서리·bleed |
| `--hm-lnb-background` / `-background-end` | 5-stop gradient / `#266651` |
| item | min-height 56, 18/28/700, gap 12, 아이콘 20, 글자 테두리 1.5px |
| current | `--hm-sem-bg-content` + `--hm-sem-action-primary`, radius 8 0 0 8 |
| group | open face Black 20%, 선 1px `--hm-sem-palette-green-light-2`, 하위 padding-start 68, current 글자 `--hm-sem-palette-yellow-accent` |
| `--hm-lnb-third-*` | min-height 48, padding-block 12, text padding-start 88(메뉴 행 기준), line-height 24, weight 500 / current 700, connector x `= --hm-lnb-subitem-padding-start`(68)·length 12·width 1, 모든 상태에서 `--hm-sem-palette-green-tint` 40%. 실제 link는 connector 끝부터 시작 |
새 Primitive: `--hm-yellow-accent: #ffc600`(`primitives.css`). ui-viz `color.action.accent-on-green`이며 HM 팔레트에 없어 추가했다. 그 외 새 Semantic은 없다.
## 6. LNB Mode 콘텐츠 연결
- 열 옆 콘텐츠 면: `position: relative`, DOM상 뒤, 왼쪽 모서리 `border-radius: var(--hm-lnb-join) 0 0 var(--hm-lnb-join)`, grid 첫 열 = `var(--hm-lnb-width)`. 열의 초록은 콘텐츠 뒤로 `--hm-lnb-join`만큼 이어진다.
- current 항목은 열 오른쪽 가장자리를 넘어 콘텐츠와 1px 겹쳐 이음새 없이 이어진다(열은 overflow를 자르지 않는다).
- 높이는 소비자가 정한다(열은 `min-height: 100%`).
- GNB 또는 Dashboard와 조합하지 않는다. 콘텐츠 계산은 LNB 자체 변수만 사용한다.
## 7. Accessibility / 상호작용
`<aside>` + `<nav aria-label>`, 현재 `aria-current="page"`, 그룹 trigger는 Radix `aria-expanded`/`aria-controls`. `LNBProfile`은 native button + 기본 accessible name `"<name> 사용자 메뉴"`이며 Avatar·이름·역할의 inner content 폭에 interaction을 한정한다(`fit-content` + 안쪽 hit padding). 이 버튼에 pointer cursor와 LNB focus-visible outline을 쓴다. `DropdownMenuTrigger asChild`로 합성하면 Enter/Space 열기, Arrow 이동, Escape 닫기와 trigger focus 복귀는 Radix가 담당한다. 접힌 그룹의 하위는 DOM에서 빠져 focus되지 않는다. 아이콘은 장식(`aria-hidden`, 소비자가 지정).
## 8. 검증 (2026-10-03, Storybook)
> HISTORICAL_RECORD: 아래 날짜별 geometry·상태 측정은 당시 기록이다. 현재 링크 interaction 범위·connector 색은 §3a/§5, Profile interaction은 §7을 따른다.
- 1280: current 항목 x 20→273, 높이 56, 18/28/700, Beige `#F5F0E5`/`#123328`, radius `8 0 0 8`, 그룹·하위 x 20→272. 하위 선택 시 상위 `rgb(255,198,0)`, 접힌 채여도 유지. 긴 이름은 줄바꿈(112px), 스크롤 영역은 메뉴만 스크롤하고 profile은 아래 22px 고정. 그룹 trigger Enter로 펼침.
- 1000px: 열 `display:none`, 가로 overflow 없음. 390px은 GNB 모바일 메뉴로 대체.
- `npm run typecheck`, `npm run build` 통과. 기존 primitive Story 변화 없음.
- 3뎁스(2026-10-06, Storybook): 위 §3a 수치를 computed style로 확인(1280 열 272, 3뎁스 x 20 → 272/선택 273, 그룹 2개 모두 x 20·폭 252, 안쪽 그룹 배경 투명·`::before` 없음). 키보드: Enter로 바깥 그룹 접힘(노랑 유지, 3뎁스·선 사라짐), Space로 다시 펼침(안쪽 그룹 열림 유지), Tab → 안쪽 그룹 Enter로 접힘. trigger는 `button` + `aria-expanded`/`aria-controls`, 선택 링크 `aria-current="page"`, focus-visible 흰 outline. 기존 Selected Child 수치 변화 없음(2뎁스 68/56/700, 그룹 하단 8, 선 1px).
- 스크롤 소유(2026-10-06, Storybook): 열 = flex column, brand·profile `flex: none`, `LNBNav`만 `flex: 1 1 auto; min-height: 0; overflow-y: auto`(열 overflow 없음, 높이는 Story shell / Dashboard Layout이 결정). 메뉴 위 20을 nav margin 2 + padding 18에서 nav padding 20(`--hm-lnb-nav-padding-top`)으로 옮겨 스크롤 영역이 brand 바로 아래에서 시작(첫 항목 y 그대로). 측정: Scroll(460) nav 93.7→370 = brand bottom → profile top, client 276 / scroll 830, 최하단 마지막 항목 bottom 351.7(= nav bottom − 18); Third Level Scroll(460) client 276 / scroll 990, 3뎁스 선택 행·곡선·연결선 함께 스크롤; Dashboard Third Level 1280·1024×600 nav 163.7→510, client 346 / scroll 526, 최하단 491.7. 1023px 열 `display:none`, 가로 overflow 없음.
- 왼쪽 scrollbar(2026-10-06, Storybook): 오른쪽 native scrollbar(263→273)가 content box를 10px 줄여 스크롤 시 메뉴 면 262·선택 263·곡선 262로 콘텐츠와 떨어졌다. `LNBNav`에 `direction: rtl`(native scrollbar만 왼쪽), 직계 자식 `direction: ltr` + `margin-left: calc(100% + inset-start − width − 1px)`(= −scrollbar 폭, 없으면 0) + `width: auto`(stretch)로 scrollbar가 왼쪽 inset 20 안(0→10)에 들어가고 행은 x 20→272(선택 273) 유지. 스크롤 없음/있음 모두 메뉴 면 272·선택 273·곡선 272·join 272, 아이콘 38·글자 70·chevron 38·2뎁스 글자 88·3뎁스 글자 108·connector 88. LNB Scroll·Third Level Scroll, Dashboard Third Level 1280·1024×600 같은 값. Tab/Enter/Space, `aria-expanded`, `aria-current`, focus-visible 유지.
- Brand ownership(2026-10-06, Storybook, 높이 700, 1280·1024·1023·390): GNB + LNB(Dashboard `Dashboard Layout`·`Third Level`, LNB `With GNB`) 데스크톱 보이는 brand = `gnb-brand` 1개, LNBBrand 없음, 1023·390 GNB 모바일 바 1개. LNB Only(Dashboard `LNB Only`, `LNB Standalone Desktop`) 데스크톱 `lnb-brand` 1개·상단 바 0, 1023·390 Local 모바일 바 1개(`gnb-brand` 1개). 서비스명 "프로젝트 대시보드"는 모든 경우 화면에 1개, 가로 overflow 0. 메뉴 geometry(GNB + LNB, 1280): Brand 제거 전 nav top 163.7(GNB 70 + 열 padding 28 + Brand 65.7) → 후 98, 첫 항목 118(= 98 + 20), nav bottom = profile top 610(높이 700) 그대로. Dashboard Third Level 1280×600: nav 98→510, client 346 → 412 / scroll 526, 최하단 마지막 항목 492(= nav bottom − 18), profile top 510 고정. 3뎁스 행 48·padding 88·500(current 700, Beige, x 20→273), 상위 그룹 노랑, 행 x 20→272, 곡선 18 — 이전 값과 같음.
## 9. 변경하지 않은 것
2026-10-07 단일 모드 검증: 1280·1232·1024·1023·960·959·390·320px에서 LNB Mode에 GNB/Dashboard가 없고 Desktop 열과 Mobile Header 중 하나만 보임을 확인했다. 390px Sheet Escape·포커스 복귀·preventDefault 링크 닫기, 1024px에서 Sheet 숨김, Desktop/Mobile 선택 상태 공유 통과.
Collapsible, Navigation Menu 등 기존 primitive 코드·theme CSS는 수정하지 않았다. `primitives.css`에 `--hm-yellow-accent` 한 줄만 추가했다.
## 10. 남은 차이
| 항목 | 분류 |
|---|---|
| 외곽 brand 테두리, 그룹 구분선(그룹이 둘 이상일 때) | FOLLOW_UP / MISSING (ui-viz도 미확정·현재 사용 안 함) |
| 로고 asset(테스트 이미지만), 크기·간격 D-070 | MISSING |
| 선택 시 하위 그룹 자동 펼침 | 서비스별 TBD(ui-viz도 TBD) |
| 하위 hover/selected 시작 위치 | 현재 2Depth는 들여쓴 링크 면, 3Depth는 connector 끝부터 시작하는 링크 면 사용(§3a) |
| 실기기, 1024~1280 사이 시각 | 미확인 |
| 4뎁스 이상 | 명세 없음. 3뎁스 규칙만 정의(더 깊이 중첩하면 3뎁스 모양이 반복된다) |
| 선택 경로 전체 연결선을 선택색으로 칠할지 | 현재 모든 connector는 Green Tint 40%를 유지한다(§3a/§5). 선택은 Beige 면 + Primary 글자 + 700 weight로 표현하며, 경로 전체 선의 선택색은 별도 결정 전까지 적용하지 않는다 |
| LNB 모바일 Sheet 그룹 접기·상위 current 표시 | 현재 링크 목록 방식. Desktop 18px / Mobile 14px 유지 |
| LNB Mobile Header 공개 API | 구현 완료. GNB/Dashboard 의존성 없음 |
## 11. 관련 파일
```
packages/ui/src/patterns/lnb/{lnb.tsx,lnb-mobile.tsx,index.ts}
packages/ui/src/theme/lnb.css
packages/ui/src/theme/primitives.css (--hm-yellow-accent)
src/patterns/lnb.stories.tsx
```
+90
View File
@@ -0,0 +1,90 @@
# Loading Screen (Pattern)
현재 HM 구현의 기준 문서. 공통 원칙은 `docs/guidelines/프로젝트-지침.md`(§6.10, §6.11)를 따른다.
```text
Layout visual reference: 외부 ui_viz Reference (ZIP 미포함) (dashboard-composition.md §18, platform-mapping/dashboard-loading-web.md)
Implementation: 의존하는 primitive 없음. 24개 막대 스피너 + 텍스트, CSS animation만 사용
```
## 1. 상태
| 항목 | 내용 |
|---|---|
| 구현 상태 | 구현 완료 (2026-10-06) |
| 대표 Story | `Patterns/Layout/Loading Screen` → `All` (Inline, Custom label, Narrow parent, Fullscreen) |
| 경위 | 요청은 "Loading Bar"였으나 ui_viz에 Loading Bar는 없었다. 실제 있는 Dashboard LoadingScreen(스피너)을 사용자가 지정해 구현했다 |
## 2. 역할 구분
| 컴포넌트 | 상황 |
|---|---|
| Progress | 진행률을 안다 (0~100%) |
| Skeleton | 콘텐츠 모양을 안다 |
| Sonner `loading` | 작업 중임을 알리는 toast |
| **Loading Screen** | 진행률도 모양도 모르는 채 영역/화면 전체가 준비 중 |
Progress는 Indeterminate 표현을 여전히 갖지 않는다(`progress.md` §11 유지). 버튼 안 inline spinner는 만들지 않았다(ui_viz §18.1과 동일).
## 3. 구성 / API
| export | 역할 |
|---|---|
| `LoadingScreen` | 초록 면 + 스피너 + 텍스트. 기본 inline, `fullscreen`이면 viewport 전체 |
```tsx
<LoadingScreen /> // inline, 부모 폭
<LoadingScreen>데이터를 불러오는 중</LoadingScreen> // 라벨 교체("..."는 자동 추가)
<LoadingScreen fullscreen /> // position: fixed, inset 0
```
- 추가 prop은 `fullscreen`뿐이다. `speed`, `size` 같은 prop은 만들지 않았다. 값은 `--hm-loading-*` Token으로 바꾼다.
- 닫기 버튼, 취소, async lifecycle, 표시/숨김 fade(0.3s)는 두지 않았다. 소비자가 로딩 동안 mount하고 끝나면 제거한다.
- Fullscreen은 Portal을 쓰지 않는다. transform이 있는 조상 안에 두면 `fixed`가 어긋난다.
## 4. 규격 (`packages/ui/src/theme/loading-screen.css`)
| 항목 | ui_viz | HM | 판정 |
|---|---|---|---|
| 스피너 | 44×44, 24 bars, 막대 2.5×10, 15° 간격, 첫 막대 12시 | 동일 (실측 44×44, 막대 2.5×10, origin 1.25px 22px) | MATCHED |
| 모션 | 막대 opacity 1→0.08, 1.2s linear infinite, 지연 i×0.05s, 회전 없음 | 동일 | MATCHED |
| 텍스트 | 14/500/-0.02em, white 60%, 끝에 정적 "..." | 동일, 폰트는 `--hm-font-family-base` | MATCHED |
| Inline | padding 36/24, radius 6, inset shadow 24px .45, gap 12, 가운데 정렬 | 동일 (실측 높이 149, ui_viz smoke 148은 폰트 line box 차이) | MATCHED |
| Fullscreen | fixed inset 0, assembly는 block·gap 0, 스피너는 묶음 leading edge(텍스트가 44px보다 넓으면 두 중심축이 어긋남), z-index 9999 | fixed inset 0, inline과 같은 column·gap 12로 묶음 전체를 viewport 중앙(스피너·텍스트 중심축 일치) | INTENTIONAL_CHANGE (2026-10-06 사용자 지시: 스피너가 왼쪽으로 치우쳐 보임) |
| 면 색 | `#0a3322` (Dashboard-local) | `--hm-sem-action-primary` (Default `#123328`) | INTENTIONAL_CHANGE (HM Primitive 재사용, 새 색 만들지 않음) |
| 폰트 | Inter → Noto Sans KR | HM 기본 폰트 | INTENTIONAL_CHANGE |
| 막대 끝 | radius 1.5 → 렌더에서 pill | `9999px` | MATCHED |
| 숨김/표시 fade 0.3s | 있음 | 없음 | FOLLOW_UP (소비자 lifecycle) |
| 닫기 버튼 + ESC | demo 전용(REFERENCE_ONLY) | 없음 (Story에 로컬 닫기 Button만) | OLD_TEST_ONLY |
| focus trap, background 차단, task lifecycle | TBD | 만들지 않음 | FOLLOW_UP |
## 5. Token
`--hm-loading-screen-{background, foreground, inline-padding-block, inline-padding-inline, inline-radius, inline-shadow, gap, z-index}`, `--hm-loading-spinner-{size, bar-width, bar-height, bar-count, bar-color, bar-min-opacity, duration, stagger}`, `--hm-loading-text-{font-size, font-weight, letter-spacing, color}`. 면/전경은 `--hm-sem-action-primary` / `--hm-sem-on-action-primary`, 그림자는 `--hm-sem-palette-black`을 참조한다. 신규 Primitive 없음.
## 6. Accessibility / Reduced Motion (INTENTIONAL_CHANGE)
ui_viz는 스피너 semantics와 reduced motion을 TBD로 남겼다. HM은 최소한만 정했다.
- 루트 `role="status"`(polite live region)가 텍스트를 알린다. 막대 컨테이너는 `aria-hidden`.
- `prefers-reduced-motion: reduce`: 애니메이션을 끄고 막대 opacity를 시계 방향으로 줄여 정적 형태로 보여준다.
- 진행률이 없으므로 `aria-valuenow` 등은 쓰지 않는다. focus 이동, 배경 입력 차단, `aria-busy`는 소비자 책임.
## 7. 검증 (2026-10-06)
- `npm run typecheck`, `npm run build`, `npm run build-storybook` 통과. 새 warning 없음(기존 chunk size 안내는 이전부터).
- 실측: inline 면 `rgb(18,51,40)`, padding 36/24, radius 6, shadow inset 24px .45, 스피너 44×44, 막대 24개, animation `hm-loading-fade` 1.2s, 지연 0.05s, spinner→text gap 12.
- Fullscreen(2026-10-06 수정 후): spinner centerX = label centerX = viewport centerX, 묶음 centerY = viewport centerY, gap 12. 1920/768/390 모두 오차 0, 가로 overflow 없음. reduced-motion에서도 위치 동일(animation만 none). inline은 높이 149, gap 12로 변화 없음.
- 1920 / 768 / 390, 일반 / `prefers-reduced-motion: reduce` 모두 Playwright(headless Chromium)로 실측했다.
- 기존 Progress / Skeleton / Sonner / Data Table / Dashboard Layout 코드는 수정하지 않았다.
## 8. 관련 파일
```
packages/ui/src/patterns/loading-screen/{loading-screen.tsx,index.ts}
packages/ui/src/theme/loading-screen.css
packages/ui/src/theme.css (import)
packages/ui/src/index.ts (export)
src/patterns/loading-screen.stories.tsx
.storybook/preview.tsx (Patterns/Layout 정렬)
```
+110
View File
@@ -0,0 +1,110 @@
# Page Header (Pattern)
현재 HM 구현의 기준 문서. 공통 원칙은 [프로젝트 지침](../guidelines/프로젝트-지침.md), [Button 규칙](../components/button.md), [단일 Navigation 정책](../guidelines/navigation-single-mode.md)을 따른다.
```text
Layout visual reference: 외부 ui_viz Reference (ZIP 미포함) (dashboard-composition.md §8 Page Heading)
Implementation: HM Breadcrumb / Button / Dropdown Menu composed as children
```
## 1. 상태
| 항목 | 내용 |
|---|---|
| 구현 상태 | 구현 완료 (2026-10-03) |
| 대표 Story | [Patterns/Layout/Page Header → Page Header](../../src/patterns/page-header.stories.tsx) (`patterns-layout-page-header--all`) |
| Dashboard 사용 예 | [Patterns/Layout/Dashboard → Dashboard](../../src/patterns/dashboard-layout.stories.tsx). 기존 대표 Story와 Dashboard 내부 조합 유지 |
| 분리 이유 | 모든 화면이 Breadcrumb + h1 + 액션을 반복하고, 하단 20px이 Layout contract |
## 2. 구성 / API
| export | 역할 |
|---|---|
| `PageHeader` | 행(flex, space-between, 하단 정렬, 줄바꿈) |
| `PageHeaderContent` | 시작 쪽 세로 묶음 |
| `PageHeaderTitle` | `h1`(화면의 유일한 h1) |
| `PageHeaderDescription` | 설명/메타 `p` |
| `PageHeaderActions` | 끝 쪽 액션 행(Button, Dropdown Menu) |
```tsx
import {
Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList,
BreadcrumbPage, BreadcrumbSeparator, Button,
PageHeader, PageHeaderActions, PageHeaderContent,
PageHeaderDescription, PageHeaderTitle,
} from "@hm-design/ui"
<PageHeader>
<PageHeaderContent>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem><BreadcrumbLink href="#home">홈</BreadcrumbLink></BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem><BreadcrumbLink href="#projects">프로젝트</BreadcrumbLink></BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem><BreadcrumbPage>업무 현황</BreadcrumbPage></BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<PageHeaderTitle>업무 현황</PageHeaderTitle>
<PageHeaderDescription>프로젝트별 업무 진행 상황을 확인하고 조회합니다.</PageHeaderDescription>
</PageHeaderContent>
<PageHeaderActions>
<Button type="button" variant="primary" appearance="brand" size="default">신규 등록</Button>
</PageHeaderActions>
</PageHeader>
```
Breadcrumb와 액션 API는 정의하지 않는다(composition). 구분선은 없다(ui-viz에 없음).
대표 예제는 지원되는 `primary × brand`, `size="default"`(Medium 34px) Action 1개를 사용한다. 화면당 Brand는 최대 1개이며 `appearance`를 명시한다. 복수 Action의 PageHeader 전용 세트 규칙은 아직 확정하지 않았으므로 기존 Dashboard fixture를 공통 세트 규칙으로 일반화하지 않는다. 보조 Action이 필요하면 [Button의 사용 위치·세트 규칙과 허용 조합](../components/button.md)을 확인하고, 불명확한 조합은 사용자 결정 후 적용한다.
Story의 Breadcrumb hash 링크와 등록 버튼은 조합 예제다. 실제 화면은 해당 Runtime의 라우팅과 등록 처리 callback을 연결한다. React 소비 화면은 entry에서 `@hm-design/ui/theme.css`를 로드한다. Storybook은 기존 전역 Theme 연결을 재사용한다.
## 3. Token (`packages/ui/src/theme/page-header.css`)
| Token | 값 | ui-viz |
|---|---|---|
| `--hm-page-header-gap` | 24px | MATCHED (gap 24) |
| `--hm-page-header-margin-bottom` | 20px | MATCHED (D-043, 첫 카드까지 20) |
| `--hm-page-header-breadcrumb-gap` | 10px | MATCHED |
| title | 28/36/700, 아래→설명 8px | MATCHED (`h2` → `h1`은 INTENTIONAL_CHANGE) |
| description | 14/20, `--hm-sem-fg-secondary` | MATCHED (색은 ui-viz 임시값 대신 Semantic) |
| actions gap | 8px | 정의 없음(Button 간격) |
Breadcrumb는 HM Breadcrumb 그대로(14px, 링크). ui-viz는 12px 정적 텍스트였다(INTENTIONAL_CHANGE).
## 4. Responsive / Accessibility
`PageHeaderContent`는 `flex: 1 1 320px; min-width: 0`, 액션은 줄바꿈 후 오른쪽 정렬(390px에서 제목 아래 줄). 긴 제목은 `overflow-wrap: anywhere`. 화면당 `h1`은 하나만 둔다. wrapper에 role을 두지 않는다.
PageHeader는 콘텐츠의 제목 영역이며 Navigation Mode를 정의하지 않는다. 독립 대표 Story는 Navigation을 추가하지 않은 조합 예제다. 실제 페이지는 `GNB | LNB | DASHBOARD` 중 하나만 선택하고 반응형에서도 유지한다. Dashboard에서는 Dashboard 내부 Header/Menu/Sidebar와 합성하며 외부 GNB/LNB를 함께 넣지 않는다.
Dashboard Compact 최소 폭 960px(Sidebar 포함 1232px)과 내부 가로 스크롤은 Dashboard Layout 계약이다. PageHeader 단독 사용처에 이 최소 폭을 자동 적용하지 않는다. PageHeader 자체에는 고정 최소 폭이나 별도 breakpoint가 없으며 기존 flex wrapping과 Theme 규격을 유지한다. 외부 콘텐츠 padding은 소비 Layout이 소유한다.
## 5. 검증 / 관련 파일
### 현재 대표 Reference
- [독립 대표 Story 소스](../../src/patterns/page-header.stories.tsx): Breadcrumb + Title + Description + Actions. 운영 조합에 별도 Story CSS나 Dashboard 최소 폭을 추가하지 않는다.
- Storybook 경로: `Patterns/Layout/Page Header → Page Header`, ID: `patterns-layout-page-header--all`.
- 기존 Dashboard 대표 Story는 Dashboard 전체 Layout의 Reference로 유지한다. Verification Story는 운영 Reference로 사용하지 않는다.
### 2026-10-08 독립 대표 Story 검증
- 기존 6024 Storybook에서 독립 메뉴와 대표 Story 렌더링 확인. 1440×900 / 390×844에서 네 구성 요소가 표시되고 문서·body의 가로 넘침 없음. 모바일 Action은 아래 줄 오른쪽으로 배치된다.
- Computed style: Title 28/36/700, Breadcrumb 14/20, Description 14/20와 Secondary 전경, PageHeader gap 24px·하단 margin 20px·Actions gap 8px 유지. 기존 Noto Sans KR와 Theme Semantic 연결 확인.
- `primary × brand`, `size="default"` 버튼 1개·높이 34px 확인. Breadcrumb 링크 → Action의 Tab 순서와 focus-visible 링 유지. 콘텐츠의 보이는 `h1`은 1개다.
- 기존 Dashboard 대표 Story 정상 렌더. Header 48px·Variant C Typography·상태 필터 유지, 이슈 5건 조회 및 전체 47건 복원 확인. 390px에서 Dashboard 최소 폭 960px과 내부 가로 스크롤 유지.
- PageHeader / Dashboard Console 오류·경고 없음. Storybook 관리자에는 기존 `PopoverProvider ariaLabel`의 Storybook 11 예정 변경 경고 1건이 있다.
- 새 Story만 대상으로 한 최소 TypeScript 검사와 변경 파일 diff check 통과. 앱 범위 검사는 다른 작업의 Controlled Props와 패키지 선언 불일치 오류 6건으로 실패했으며 해당 파일은 수정하지 않았다. 전체 Component 회귀·build는 이번 범위에서 수행하지 않았다.
### 과거 검증 기록 (기존 문서)
당시 Dashboard Layout Story(1280, 390, 긴 제목)에서 page-header 상단이 Main padding 34 아래, 하단 20px 뒤에 첫 콘텐츠인 것을 확인했다. 이는 당시 Dashboard 조합의 검증 기록이며, 현재 Dashboard 최소 폭·내부 가로 스크롤 정책이나 모든 PageHeader 소비 화면의 padding 규격을 대신하지 않는다.
```
packages/ui/src/patterns/page-header/{page-header.tsx,index.ts}
packages/ui/src/theme/page-header.css
src/patterns/page-header.stories.tsx
src/patterns/dashboard-layout.stories.tsx
```