NavMenu
먼저 보고, 그다음 계약을 읽습니다
TopNav 우측에 얹는 통일된 드롭다운 메뉴 1종 (entity-selector · user-menu).
Component intent의도와 경계 읽기
"버튼 → 팝오버 → 선택 항목 + (선택) 헤더/푸터 액션" 이라는 동일 구조를 데이터(props)로만 분기하는 단일 컴포넌트다. items 에 active 를 주면 role=menuitemradio 로 렌더되어 entity-selector(작업 대상 전환)가 되고, footer 에 tone="danger" 액션을 두면 user-menu(로그아웃)가 된다. Radix 대신 자체 구현이라 a11y(aria-haspopup/expanded/checked·키보드·focus 복귀)와 click-outside 를 직접 처리한다. 서브메뉴/타입어헤드 같은 무거운 사용처는 DropdownMenu(Radix)를 쓸 것.
예제
SSOT에 등록된 실제 API 기준 snippet입니다. standalone 배지만 독립 실행 단위이며, fragment는 주변 state·handler 문맥을 생략합니다.
import { NavMenu } from "@axe/ui";<NavMenu label="AXE Ventures" ariaLabel="작업 대상 조직 선택" accent items={[ { key: "axev", label: "AXE Ventures", active: true }, { key: "axec", label: "AXE Capital", active: false, onSelect: () => switchOrg("axec") }, ]}/>import { NavMenu } from "@axe/ui";<NavMenu label="강수훈" ariaLabel="계정 메뉴" header={{ label: "강수훈 · admin" }} items={[ { key: "settings", label: "설정", href: "/settings" }, ]} footer={[ { key: "logout", label: "로그아웃", tone: "danger", onSelect: signOut }, ]}/><div class="axe-topnav__menu"> <button class="axe-topnav__menu-trigger" aria-haspopup="menu" aria-expanded="true"> <span class="axe-topnav__menu-trigger-label">AXE Ventures</span> </button> <div class="axe-topnav__menu-popover" role="menu" aria-label="조직 선택" data-align="right"> <button class="axe-topnav__menu-item axe-topnav__menu-item--active" role="menuitemradio" aria-checked="true"> <span class="axe-topnav__menu-item-label">AXE Ventures</span> </button> <button class="axe-topnav__menu-item" role="menuitemradio" aria-checked="false"> <span class="axe-topnav__menu-item-label">AXE Capital</span> </button> </div></div>Props
TSX 소스가 진실입니다. 주요 export 컴포넌트의 public API만 노출합니다.
| 이름 | 타입 | 필수 | 기본값 | 설명 |
|---|---|---|---|---|
label | React.ReactNode | 필수 | — | 트리거 버튼 라벨 (현재 선택 entity / 사용자 이름 등). |
items | NavMenuItem[] | 필수 | — | 선택 항목 배열. NavMenuItem = { key: string; label: React.ReactNode; meta?: React.ReactNode; active?: boolean; href?: string; onSelect?: () => void }. active 가 정의되면 role=menuitemradio + aria-checked 로 렌더, undefined 면 일반 menuitem. href 면 <a>, 아니면 <button>. |
header | { label: React.ReactNode; items?: NavMenuFooterItem[] } | — | — | items 위 헤더 영역 — 라벨(현재 계정 이름 등) + (선택) 액션, 아래 구분선. 3-그룹 레이아웃(헤더·items·footer)용. |
footer | NavMenuFooterItem[] | — | — | 구분선 아래 액션 영역. NavMenuFooterItem = { key: string; label: React.ReactNode; href?: string; onSelect?: () => void; tone?: "default" | "danger" }. tone="danger" = 파괴적 액션(로그아웃·계정 삭제). |
align | "left" | "right" | — | "right" | 팝오버 정렬 — trigger 기준. |
ariaLabel | string | 필수 | — | 스크린리더용 메뉴 라벨 (role=menu 의 aria-label). 필수. |
accent | boolean | — | false | accent 톤 트리거 (강조가 필요한 entity-selector 등). |
emptyLabel | React.ReactNode | — | "항목 없음" | items 가 비었을 때 표시할 문구. |
data-testid | string | — | — | 루트 wrapper 에 부착할 testid. |
menuTestid | string | — | — | 팝오버 노드에 부착할 testid. |
.axe-* 클래스 계약
React 밖에서도 같은 표면을 그리는 공개 계약입니다. stable은 minor 버전 안에서 이름을 바꾸지 않습니다.
| 클래스 | 안정성 | 용도 |
|---|---|---|
.axe-topnav__menu | stable | wrapper (trigger + popover 위치 기준 root). |
.axe-topnav__menu-trigger | stable | 트리거 버튼 (data-state open/closed, data-accent). |
.axe-topnav__menu-trigger-label | internal | 트리거 라벨 span. |
.axe-topnav__menu-chevron | internal | chevron svg (open 시 CSS 로 회전). |
.axe-topnav__menu-popover | stable | 드롭다운 팝오버 (role=menu, data-align left/right). |
.axe-topnav__menu-item | stable | 항목/액션 (a 또는 button). |
.axe-topnav__menu-item--active | stable | 선택된 항목. |
.axe-topnav__menu-item--danger | stable | 파괴적 액션 (tone="danger"). |
.axe-topnav__menu-item-label | internal | 항목 라벨 span. |
.axe-topnav__menu-item-meta | internal | 우측 muted 태그 span (역할·count). |
.axe-topnav__menu-empty | internal | items 빈 상태 문구. |
.axe-topnav__menu-header | stable | 헤더 그룹 컨테이너 (라벨 + 액션, 아래 구분선). |
.axe-topnav__menu-header-label | internal | 헤더 라벨. |
.axe-topnav__menu-footer | stable | 푸터 그룹 컨테이너 (구분선 위 액션 묶음). |
접근성
키보드, ARIA, 구현 노트를 함께 검토합니다.
- ↑/↓ 로 메뉴 항목 순환 (양끝에서 wrap)
- Enter/Space 로 항목 실행 (네이티브 button/a)
- Esc 로 닫고 트리거로 focus 복원
- 열리면 첫 항목으로 자동 focus
트리거 = aria-haspopup="menu" + aria-expanded + aria-controls(열림 시 popover id). 팝오버 = role="menu" + aria-label(=ariaLabel prop). 항목: active 정의 시 role="menuitemradio" + aria-checked, 아니면 role="menuitem"(href 항목은 active 시 aria-current="true"). header/footer 액션 = role="menuitem", 빈 상태 = role="presentation".
click-outside(pointerdown capture)로 닫힘. 항목 실행 후 자동 닫힘 + 트리거 focus 복원. reduced-motion 시 팝오버 등장 애니 없음. Radix 미사용 자체 구현, SSR-safe(초기 렌더는 닫힘).