284 lines
23 KiB
Markdown
284 lines
23 KiB
Markdown
# 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 검사 통과. 전체 프로젝트 회귀 테스트와 실기기 터치 검증은 수행하지 않았다.
|