)
}
// 3-depth: an `LNBGroup` nested in a group (no depth prop). The selected link is `active`, every group on its path is `current`, and
// each group's `open` is kept here so the nested group stays open after the outer one is closed and reopened.
const thirdLevelChildren = (long?: boolean): Child[] =>
long
? [
{ id: "projects/design/tokens", label: "컬러 토큰" },
{ id: "projects/design/components", label: "컴포넌트 스타일 규칙" },
{ id: "projects/design/layout", label: "레이아웃 규칙과 화면 폭별 여백 그리드 기준" },
]
: [
{ id: "projects/design/tokens", label: "컬러 토큰" },
{ id: "projects/design/components", label: "컴포넌트 스타일" },
{ id: "projects/design/layout", label: "레이아웃 규칙" },
]
function ThirdLevelMenu({
initial = "projects/design/tokens",
projectOpen = true,
designOpen = true,
longLabels,
many,
height,
}: {
initial?: string | null
projectOpen?: boolean
designOpen?: boolean
longLabels?: boolean
many?: boolean
height?: number
}) {
const [current, setCurrent] = useState(initial)
const extra = many ? Array.from({ length: 6 }, (_, i) => ({ id: `extra-${i}`, label: `추가 메뉴 ${i + 1}` })) : []
const [open, setOpen] = useState({ projects: projectOpen, design: designOpen })
const go = (id: string) => (event: MouseEvent) => {
event.preventDefault()
setCurrent(id)
}
const onPath = (id: string) => Boolean(current?.startsWith(`${id}/`))
return (
한맥 업무 시스템
프로젝트 대시보드
대시보드
setOpen((state) => ({ ...state, projects: value }))}>
setOpen((state) => ({ ...state, design: value }))}>
{thirdLevelChildren(longLabels).map((child) => (
{child.label}
))}
업무 목록
일정 관리
현황 보고
설정
{extra.map((item) => (
{item.label}
))}
} />
)
}
// A 390px viewport for the mobile Stories (the switch is a viewport media query, so a narrow container cannot show it).
const mobileViewport = {
parameters: { viewport: { options: { hmMobile: { name: "Mobile 390", styles: { width: "390px", height: "844px" }, type: "mobile" } } } },
globals: { viewport: { value: "hmMobile", isRotated: false } },
}
// Representative-board preview of a Story at a fixed viewport. The LNB/bar switch is a viewport media query, so the board embeds the
// verification Story (the same `Standalone` composition) in an iframe of that width instead of rebuilding it in a narrow container.
// Each iframe is its own document, so the closed Sheets do not share a portal or focus with the board.
function ViewportPreview({ story, label, width, height }: { story: string; label: string; width: number; height: number }) {
return (
{label} · {width}px
)
}
const meta = { title: "Patterns/Layout/LNB", component: LNB, parameters: { layout: "fullscreen", controls: { disable: true } } } satisfies Meta
export default meta
type Story = StoryObj
// Representative Story: the common Story page with the column in every state side by side. Hover and click work on the real items.
// The specimens are bounded LNB columns (their own frame, not the viewport width), so they stay on the page inner.
export const All: Story = {
name: "LNB",
render: () => (
},
{ id: "third-selected", label: "3뎁스 메뉴 · 선택", note: "3뎁스는 20px 들여쓰기와 1px 그린 연결선으로 구분하며, 선은 마지막 메뉴에서 끝납니다. 선택은 베이지, 상위 경로는 노란색입니다.", children: },
]}
/>
},
{ id: "expanded", label: "Expanded · 2뎁스 펼침", note: "하위 선택 없이 펼친 그룹: Black 8% 면 + 1px 초록 선.", children: },
{ id: "selected-child", label: "Selected Child · 2뎁스 선택", note: "하위 선택: 상위 이름·화살표가 노란색, 하위는 Beige.", children: },
{ id: "collapsed-with-selection", label: "Collapsed With Selection · 접힘", note: "하위가 선택된 채 접힌 그룹도 상위가 노란색을 유지합니다.", children: },
{ id: "third-second-collapsed", label: "3뎁스 메뉴 · 2뎁스 접힘", note: "디자인 관리를 접어도 선택된 경로의 1·2뎁스 표시를 유지합니다. 하위 메뉴와 연결선이 함께 숨겨집니다.", children: },
{ id: "third-first-collapsed", label: "3뎁스 메뉴 · 1뎁스 접힘", note: "프로젝트 관리를 접어도 노란색을 유지합니다. 다시 펼치면 디자인 관리의 열림 상태가 그대로입니다(open은 화면이 관리).", children: },
]}
/>
},
{ id: "long", label: "Long labels", note: "긴 이름은 줄바꿈되며 항목 높이가 늘어납니다.", children: },
]}
/>
},
{ id: "third-scroll", label: "3뎁스 메뉴 · Scroll", note: "3뎁스·연결선·긴 이름도 메뉴 영역 안에서 함께 스크롤됩니다. 스크롤 영역은 브랜드 바로 아래에서 시작해 프로필 바로 위에서 끝납니다.", children: },
]}
/>
),
}
export const Default: Story = { tags: ["verification"], render: () => }
export const Selected: Story = { tags: ["verification"], render: () => }
export const Expanded: Story = { tags: ["verification"], render: () => }
export const SelectedChild: Story = { tags: ["verification"], name: "Selected Child", render: () => }
export const CollapsedWithSelection: Story = { tags: ["verification"], name: "Collapsed With Selection", render: () => }
export const WithLogo: Story = { tags: ["verification"], name: "With Logo", render: () => }
export const LongLabels: Story = { tags: ["verification"], name: "Long Labels", render: () => }
export const Scroll: Story = { tags: ["verification"], render: () => }
export const ThirdLevel: Story = { tags: ["verification"], name: "Third Level", render: () => }
export const ThirdLevelLongLabels: Story = { tags: ["verification"], name: "Third Level Long Labels", render: () => }
export const ThirdLevelScroll: Story = { tags: ["verification"], name: "Third Level Scroll", render: () => }
// VERIFICATION ONLY: legacy GNB + LNB technical check, forbidden in operating pages. The GNB owns the brand,
// so the LNB below has no LNBBrand and the menu starts at the column top.
export const WithGNB: Story = {
tags: ["verification"],
name: "VERIFICATION ONLY · GNB + LNB",
render: () => (
VERIFICATION ONLY — 기존 혼합 구조 기술 검증. 운영 사용 금지.
}
/>
한맥 업무 시스템
프로젝트 대시보드} />
),
}
// LNB without a GNB, responsive. Desktop (>= 1024px): the column. Mobile (<= 1023px): the column is hidden and the connected-content
// column becomes a full-width stack whose first row opens a Sheet with the same tree, selected item and every depth.
export const StandaloneDesktop: Story = { tags: ["verification"], name: "LNB Standalone Desktop", render: () => }
export const LNBProfileMenuOpen: Story = { tags: ["verification"], name: "LNB Profile Menu Open", render: () => }
export const StandaloneMobile: Story = { tags: ["verification"], name: "LNB Standalone Mobile", ...mobileViewport, render: () => }
export const StandaloneMobileMenuOpen: Story = {
tags: ["verification"],
name: "LNB Standalone Mobile Menu Open",
...mobileViewport,
render: () => ,
}