Files
HMDesign/src/patterns/lnb.stories.tsx
T
shkim4 4c29894e56 refactor(storybook): simplify component and preview examples
Drop page / section descriptions, state notes and repeated captions from
the representative Stories of 44 components, the Patterns, Center
Graphic and OrgChart pages. Value readouts, spec lists and captions hide
only on the representative page; hidden verification Stories keep them.
Tabs Default API and the Filter Bar draft / applied counts move to the
hidden DefaultApi and DraftApplied Stories; Center Overview is hidden
with its direct URL kept. Story IDs are unchanged.

Align the component, pattern and OrgChart docs with the pages, point the
quality reference check at the DraftApplied Story, and make the P8
styles check expect no section header on the preview-only Expanded page.
2026-10-23 00:28:05 +09:00

745 lines
32 KiB
TypeScript

import { Fragment, useState, type CSSProperties, type MouseEvent, type ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite"
// LNBProfile's default accessible name includes the visible name and description; explicit labels must preserve them.
import { ChartColumnIcon, LayoutGridIcon, ListChecksIcon, SettingsIcon, type LucideIcon } from "lucide-react"
import {
GNB,
GNBBrand,
GNBMobileMenu,
GNBNav,
GNBUser,
GNBUtilities,
Avatar,
AvatarFallback,
AvatarImage,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
LNB,
LNBBrand,
LNBGroup,
LNBItem,
LNBNav,
LNBProfile,
LNBMobileHeader,
LNBMobileBrand,
LNBMobileGroup,
LNBMobileLink,
LNBMobileMenu,
} from "@hm-design/ui"
import { StateBoard } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// LNB is a composition pattern. Layout visual reference is the ui-viz LNB (272px green column, 18/700 items, Beige current item joined
// to the content, Black 10% hover, Black 8% open group). The menu data and the current location are local sample data for this Story
// (no router). The content next to the column is a positioned surface with `--hm-lnb-join` rounded left corners: the LNB Mode page
// owns it, here it only shows the connection. The column is desktop only (hidden below 1024px).
const testLogo =
"data:image/svg+xml;utf8," +
encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="120" height="32"><rect width="120" height="32" rx="4" fill="#fff" fill-opacity=".9"/><text x="60" y="21" font-size="13" font-weight="700" text-anchor="middle" fill="#123328">TEST LOGO</text></svg>')
const photo =
"data:image/svg+xml;utf8," +
encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80"><rect width="80" height="80" fill="#266651"/><circle cx="40" cy="31" r="14" fill="#bddfd4"/><rect x="14" y="50" width="52" height="40" rx="22" fill="#bddfd4"/></svg>')
// An address that cannot load, so the Radix fallback shows.
const brokenPhoto = "data:image/png;base64,broken"
const profileData = {
name: "김한맥",
description: "프로젝트 관리자",
photo,
fallback: "김",
}
const profileActions: { label: string; destructive?: boolean }[] = [
{ label: "프로필 설정" },
{ label: "로그아웃", destructive: true },
]
function ProfileAvatar() {
return (
<Avatar>
<AvatarImage src={profileData.photo} alt="" />
<AvatarFallback>{profileData.fallback}</AvatarFallback>
</Avatar>
)
}
function ProfileMenu({ trigger, side = "bottom", align = "start", modal, matchTrigger = false, defaultOpen = false }: { trigger: ReactNode; side?: "top" | "right" | "bottom" | "left"; align?: "start" | "center" | "end"; modal?: boolean; matchTrigger?: boolean; defaultOpen?: boolean }) {
return (
<DropdownMenu modal={modal} defaultOpen={defaultOpen}>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
<DropdownMenuContent side={side} align={align} className={matchTrigger ? "min-w-(--radix-dropdown-menu-trigger-width)" : undefined}>
<DropdownMenuLabel>{profileData.name}</DropdownMenuLabel>
{profileActions.map((action) => (
<Fragment key={action.label}>
{action.destructive ? <DropdownMenuSeparator /> : null}
<DropdownMenuItem variant={action.destructive ? "destructive" : "default"}>{action.label}</DropdownMenuItem>
</Fragment>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
const shellStyle: CSSProperties = {
display: "grid",
gridTemplateColumns: "var(--hm-lnb-width) minmax(0, 1fr)",
minHeight: 460,
background: "var(--hm-lnb-background-end)",
}
const contentStyle: CSSProperties = {
position: "relative",
// Keep connected content in the second track when the first-track LNB becomes display:none at <=1023px.
// Without explicit placement, Grid auto-placement moves it into the now-empty 0px LNB track.
gridColumn: 2,
minWidth: 0,
padding: 34,
borderRadius: "var(--hm-lnb-join) 0 0 var(--hm-lnb-join)",
background: "var(--hm-sem-bg-content)",
}
type Child = { id: string; label: string }
type LocalNode = { id: string; label: string; icon?: LucideIcon; children?: LocalNode[] }
function Menu({
initial = "overview",
groupOpen = true,
logo,
eyebrow = "한맥 업무 시스템",
brand = "프로젝트 대시보드",
longLabels,
many,
height,
profile = true,
}: {
initial?: string | null
groupOpen?: boolean
logo?: string
eyebrow?: string
/** `null`: no LNBBrand (a GNB above carries the service identity). */
brand?: string | null
longLabels?: boolean
many?: boolean
height?: number
/** Story composition only: GNB + LNB gives profile ownership to the GNB. */
profile?: boolean
}) {
const [current, setCurrent] = useState<string | null>(initial)
const [open, setOpen] = useState(groupOpen)
const go = (id: string) => (event: MouseEvent) => {
event.preventDefault()
setCurrent(id)
}
const children: Child[] = longLabels
? [
{ id: "tasks/list", label: "전사 프로젝트 포트폴리오의 진행 현황 목록" },
{ id: "tasks/board", label: "업무 보드" },
]
: [
{ id: "tasks/list", label: "메뉴 2-1" },
{ id: "tasks/board", label: "메뉴 2-2" },
]
const extra = many ? Array.from({ length: 8 }, (_, i) => ({ id: `extra-${i}`, label: `추가 메뉴 ${i + 1}` })) : []
return (
<div style={{ ...shellStyle, height }}>
<LNB>
{brand !== null ? (
<LNBBrand>
{logo ? <img src={logo} alt="" /> : null}
<span>
{eyebrow ? <small>{eyebrow}</small> : null}
<strong>{brand}</strong>
</span>
</LNBBrand>
) : null}
<LNBNav aria-label="서비스 메뉴">
<LNBItem href="#overview" active={current === "overview"} onClick={go("overview")}>
<LayoutGridIcon aria-hidden="true" />
{longLabels ? "프로젝트 현황과 전체 일정을 한눈에 보는 개요" : "메뉴 1"}
</LNBItem>
<LNBGroup label={longLabels ? "업무 관리와 진행 상황 점검" : "메뉴 2"} current={Boolean(current?.startsWith("tasks/"))} open={open} onOpenChange={setOpen}>
{children.map((child) => (
<LNBItem key={child.id} href={`#${child.id}`} active={current === child.id} onClick={go(child.id)}>
{child.label}
</LNBItem>
))}
</LNBGroup>
<LNBItem href="#reports" active={current === "reports"} onClick={go("reports")}>
<ChartColumnIcon aria-hidden="true" />
메뉴 3
</LNBItem>
<LNBItem href="#settings" active={current === "settings"} onClick={go("settings")}>
<SettingsIcon aria-hidden="true" />
메뉴 4
</LNBItem>
{extra.map((item) => (
<LNBItem key={item.id} href={`#${item.id}`} active={current === item.id} onClick={go(item.id)}>
<ListChecksIcon aria-hidden="true" />
{item.label}
</LNBItem>
))}
</LNBNav>
{profile ? (
<ProfileMenu
side="right"
align="end"
matchTrigger
trigger={
<LNBProfile name={longLabels ? "김한맥 (한맥기술 인프라사업본부 스마트시설팀)" : profileData.name} description={longLabels ? "통합 시스템 관리자 · 인프라사업본부" : profileData.description}>
<Avatar>
<AvatarImage src={longLabels ? brokenPhoto : profileData.photo} alt="" />
<AvatarFallback>{profileData.fallback}</AvatarFallback>
</Avatar>
</LNBProfile>
}
/>
) : null}
</LNB>
<div style={contentStyle}>
<h2 className="text-lg font-bold">연결된 콘텐츠</h2>
<p className="text-sm text-muted-foreground" data-current-menu>
선택: {current ?? "없음"}
</p>
</div>
</div>
)
}
// 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<string | null>(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 (
<div style={{ ...shellStyle, height }}>
<LNB>
<LNBBrand>
<span>
<small>한맥 업무 시스템</small>
<strong>프로젝트 대시보드</strong>
</span>
</LNBBrand>
<LNBNav aria-label="서비스 메뉴">
<LNBItem href="#overview" active={current === "overview"} onClick={go("overview")}>
<LayoutGridIcon aria-hidden="true" />
대시보드
</LNBItem>
<LNBGroup label="프로젝트 관리" current={onPath("projects")} open={open.projects} onOpenChange={(value) => setOpen((state) => ({ ...state, projects: value }))}>
<LNBGroup label="디자인 관리" current={onPath("projects/design")} open={open.design} onOpenChange={(value) => setOpen((state) => ({ ...state, design: value }))}>
{thirdLevelChildren(longLabels).map((child) => (
<LNBItem key={child.id} href={`#${child.id}`} active={current === child.id} onClick={go(child.id)}>
{child.label}
</LNBItem>
))}
</LNBGroup>
<LNBItem href="#projects/tasks" active={current === "projects/tasks"} onClick={go("projects/tasks")}>
업무 목록
</LNBItem>
<LNBItem href="#projects/schedule" active={current === "projects/schedule"} onClick={go("projects/schedule")}>
일정 관리
</LNBItem>
</LNBGroup>
<LNBItem href="#reports" active={current === "reports"} onClick={go("reports")}>
<ChartColumnIcon aria-hidden="true" />
현황 보고
</LNBItem>
<LNBItem href="#settings" active={current === "settings"} onClick={go("settings")}>
<SettingsIcon aria-hidden="true" />
설정
</LNBItem>
{extra.map((item) => (
<LNBItem key={item.id} href={`#${item.id}`} active={current === item.id} onClick={go(item.id)}>
<ListChecksIcon aria-hidden="true" />
{item.label}
</LNBItem>
))}
</LNBNav>
<ProfileMenu side="right" align="end" matchTrigger trigger={<LNBProfile name={profileData.name} description={profileData.description}><ProfileAvatar /></LNBProfile>} />
</LNB>
<div style={contentStyle}>
<h2 className="text-lg font-bold">연결된 콘텐츠</h2>
<p className="text-sm text-muted-foreground" data-current-menu>
선택: {current ?? "없음"}
</p>
</div>
</div>
)
}
function localProfile(longLabels?: boolean) {
return {
name: longLabels ? "김한맥 (한맥기술 인프라사업본부 스마트시설팀)" : profileData.name,
description: longLabels ? "통합 시스템 관리자 · 인프라사업본부" : profileData.description,
photo: longLabels ? brokenPhoto : profileData.photo,
}
}
function simpleNav(longLabels?: boolean, many?: boolean): LocalNode[] {
const children: LocalNode[] = longLabels
? [
{ id: "tasks/list", label: "전사 프로젝트 포트폴리오의 진행 현황 목록" },
{ id: "tasks/board", label: "업무 보드" },
]
: [
{ id: "tasks/list", label: "메뉴 2-1" },
{ id: "tasks/board", label: "메뉴 2-2" },
]
const extra: LocalNode[] = many ? Array.from({ length: 8 }, (_, i) => ({ id: `extra-${i}`, label: `추가 메뉴 ${i + 1}`, icon: ListChecksIcon })) : []
return [
{ id: "overview", label: longLabels ? "프로젝트 현황과 전체 일정을 한눈에 보는 개요" : "메뉴 1", icon: LayoutGridIcon },
{ id: "tasks", label: longLabels ? "업무 관리와 진행 상황 점검" : "메뉴 2", children },
{ id: "reports", label: "메뉴 3", icon: ChartColumnIcon },
{ id: "settings", label: "메뉴 4", icon: SettingsIcon },
...extra,
]
}
function thirdLevelNav(longLabels?: boolean, many?: boolean): LocalNode[] {
const extra: LocalNode[] = many ? Array.from({ length: 6 }, (_, i) => ({ id: `extra-${i}`, label: `추가 메뉴 ${i + 1}`, icon: ListChecksIcon })) : []
return [
{ id: "overview", label: "대시보드", icon: LayoutGridIcon },
{
id: "projects",
label: "프로젝트 관리",
children: [
{ id: "projects/design", label: "디자인 관리", children: thirdLevelChildren(longLabels) },
{ id: "projects/tasks", label: "업무 목록" },
{ id: "projects/schedule", label: "일정 관리" },
],
},
{ id: "reports", label: "현황 보고", icon: ChartColumnIcon },
{ id: "settings", label: "설정", icon: SettingsIcon },
...extra,
]
}
// Story-only responsive composition. Desktop LNB and mobile Sheet are two views over one tree and one state source.
function ResponsiveSpecimen({
nodes,
initial,
initialOpen,
logo,
eyebrow = "한맥 업무 시스템",
brand = "프로젝트 대시보드",
longLabels,
height,
}: {
nodes: LocalNode[]
initial: string | null
initialOpen: Record<string, boolean>
logo?: string
eyebrow?: string
brand?: string
longLabels?: boolean
height?: number
}) {
const [current, setCurrent] = useState<string | null>(initial)
const [open, setOpen] = useState<Record<string, boolean>>(initialOpen)
const profile = localProfile(longLabels)
const go = (id: string) => (event: MouseEvent) => {
event.preventDefault()
setCurrent(id)
}
const onPath = (id: string) => Boolean(current?.startsWith(`${id}/`))
const isOpen = (id: string) => open[id] ?? true
const lnbMenu = (items: LocalNode[]): ReactNode =>
items.map((node) =>
node.children ? (
<LNBGroup
key={node.id}
label={node.label}
current={onPath(node.id)}
open={isOpen(node.id)}
onOpenChange={(value) => setOpen((state) => ({ ...state, [node.id]: value }))}
>
{lnbMenu(node.children)}
</LNBGroup>
) : (
<LNBItem key={node.id} href={`#${node.id}`} active={current === node.id} onClick={go(node.id)}>
{node.icon ? <node.icon aria-hidden="true" /> : null}
{node.label}
</LNBItem>
)
)
const sheetMenu = (items: LocalNode[]): ReactNode =>
items.map((node) =>
node.children ? (
<LNBMobileGroup key={node.id} label={node.label} data-open={isOpen(node.id) ? "" : undefined}>
{isOpen(node.id) ? sheetMenu(node.children) : null}
</LNBMobileGroup>
) : (
<LNBMobileLink key={node.id} href={`#${node.id}`} active={current === node.id} onClick={go(node.id)}>
{node.label}
</LNBMobileLink>
)
)
const brandContent = (
<>
{logo ? <img src={logo} alt="" /> : null}
<span>
{eyebrow ? <small>{eyebrow}</small> : null}
<strong>{brand}</strong>
</span>
</>
)
const profileTrigger = (
<LNBProfile name={profile.name} description={profile.description}>
<Avatar>
<AvatarImage src={profile.photo} alt="" />
<AvatarFallback>{profileData.fallback}</AvatarFallback>
</Avatar>
</LNBProfile>
)
return (
<div
className="grid min-w-0 grid-cols-1 bg-[var(--hm-lnb-background-end)] lg:grid-cols-[var(--hm-lnb-width)_minmax(0,1fr)]"
style={{ minHeight: height ?? 460, height }}
data-story-slot="responsive-lnb-specimen"
>
<LNB>
<LNBBrand>{brandContent}</LNBBrand>
<LNBNav aria-label="서비스 메뉴">{lnbMenu(nodes)}</LNBNav>
<ProfileMenu side="right" align="end" matchTrigger trigger={profileTrigger} />
</LNB>
<div className="flex min-w-0 flex-col lg:col-start-2" data-story-slot="lnb-content-stack">
<div className="lg:hidden" data-story-slot="local-mobile-menu">
<LNBMobileHeader>
<LNBMobileMenu title="서비스 메뉴">
{sheetMenu(nodes)}
<div className="mt-4 border-t border-border pt-4">
<ProfileMenu
modal={false}
trigger={
<LNBProfile
name={profile.name}
description={profile.description}
>
<Avatar>
<AvatarImage src={profile.photo} alt="" />
<AvatarFallback>{profileData.fallback}</AvatarFallback>
</Avatar>
</LNBProfile>
}
/>
</div>
</LNBMobileMenu>
<LNBMobileBrand asChild>
<div>{brandContent}</div>
</LNBMobileBrand>
</LNBMobileHeader>
</div>
{/* Content paints above the navigation join strip, within the same LNB Mode. */}
<div className="relative min-w-0 flex-1 max-lg:rounded-t-[var(--hm-lnb-mobile-join)] bg-[var(--hm-sem-bg-content)] p-[34px] lg:rounded-l-[var(--hm-lnb-join)]" data-story-slot="connected-content">
<h2 className="text-lg font-bold">연결된 콘텐츠</h2>
</div>
</div>
</div>
)
}
// LNB Mode: desktop column and LNB Mobile Header/Sheet share one local tree and state.
// This responsive change stays within LNB Mode.
const standaloneNav: LocalNode[] = [
{ id: "overview", label: "대시보드", icon: LayoutGridIcon },
{
id: "projects",
label: "프로젝트 관리",
children: [
{
id: "projects/design",
label: "디자인 관리",
children: [
{ id: "projects/design/tokens", label: "컬러 토큰" },
{ id: "projects/design/components", label: "컴포넌트 스타일" },
{ id: "projects/design/layout", label: "레이아웃 규칙" },
],
},
{ id: "projects/tasks", label: "업무 목록" },
{ id: "projects/schedule", label: "일정 관리" },
],
},
{ id: "reports", label: "현황 보고", icon: ChartColumnIcon },
{ id: "settings", label: "설정", icon: SettingsIcon },
]
function Standalone({ mobileOpen = false, profileOpen = false }: { mobileOpen?: boolean; profileOpen?: boolean }) {
const [current, setCurrent] = useState("projects/design/tokens")
const [open, setOpen] = useState<Record<string, boolean>>({})
const go = (id: string) => (event: MouseEvent) => {
event.preventDefault()
setCurrent(id)
}
const onPath = (id: string) => current.startsWith(`${id}/`)
const lnbMenu = (nodes: LocalNode[]): ReactNode =>
nodes.map((node) =>
node.children ? (
<LNBGroup
key={node.id}
label={node.label}
current={onPath(node.id)}
open={open[node.id] ?? true}
onOpenChange={(value) => setOpen((state) => ({ ...state, [node.id]: value }))}
>
{lnbMenu(node.children)}
</LNBGroup>
) : (
<LNBItem key={node.id} href={`#${node.id}`} active={current === node.id} onClick={go(node.id)}>
{node.icon ? <node.icon aria-hidden="true" /> : null}
{node.label}
</LNBItem>
)
)
const sheetMenu = (nodes: LocalNode[]): ReactNode =>
nodes.map((node) =>
node.children ? (
<LNBMobileGroup key={node.id} label={node.label}>
{sheetMenu(node.children)}
</LNBMobileGroup>
) : (
<LNBMobileLink key={node.id} href={`#${node.id}`} active={current === node.id} onClick={go(node.id)}>
{node.label}
</LNBMobileLink>
)
)
const brand = (
<span>
<small>한맥 업무 시스템</small>
<strong>프로젝트 대시보드</strong>
</span>
)
return (
// LNB Mode owns its height independently of Dashboard.
<div
className="grid min-h-0 min-w-0 grid-cols-1 bg-[var(--hm-lnb-background-end)] lg:grid-cols-[var(--hm-lnb-width)_minmax(0,1fr)]"
style={{ height: "100dvh" }}
data-story-slot="responsive-lnb-specimen"
>
<LNB>
<LNBBrand>{brand}</LNBBrand>
<LNBNav aria-label="서비스 메뉴">{lnbMenu(standaloneNav)}</LNBNav>
<ProfileMenu side="right" align="end" defaultOpen={profileOpen} matchTrigger trigger={<LNBProfile name={profileData.name} description={profileData.description}><ProfileAvatar /></LNBProfile>} />
</LNB>
<div className="flex min-h-0 min-w-0 flex-1 flex-col" data-story-slot="lnb-content-stack">
<div className="lg:hidden" data-story-slot="local-mobile-menu">
<LNBMobileHeader>
<LNBMobileMenu title="서비스 메뉴" defaultOpen={mobileOpen}>
{sheetMenu(standaloneNav)}
<div className="mt-4 border-t border-border pt-4">
<ProfileMenu
modal={false}
trigger={
<LNBProfile
name={profileData.name}
description={profileData.description}
>
<ProfileAvatar />
</LNBProfile>
}
/>
</div>
</LNBMobileMenu>
<LNBMobileBrand asChild>
<div>{brand}</div>
</LNBMobileBrand>
</LNBMobileHeader>
</div>
<main className="relative min-h-0 min-w-0 flex-1 overflow-auto max-lg:rounded-t-[var(--hm-lnb-mobile-join)] bg-[var(--hm-sem-bg-content)] p-[34px] lg:rounded-l-[var(--hm-lnb-join)]" data-story-slot="connected-content">
<h2 className="text-lg font-bold">연결된 콘텐츠</h2>
</main>
</div>
</div>
)
}
// 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 (
<figure className="grid min-w-0 content-start gap-2">
<figcaption className="text-sm text-muted-foreground">
{label} · {width}px
</figcaption>
<div className="max-w-full overflow-x-auto">
<iframe
title={`${label} ${width}px`}
src={`iframe.html?id=patterns-layout-lnb--${story}&viewMode=story`}
width={width}
height={height}
className="box-content block border border-black/10"
/>
</div>
</figure>
)
}
const meta = { title: "Patterns/Layout/LNB", component: LNB, parameters: { layout: "fullscreen", controls: { disable: true } } } satisfies Meta<typeof LNB>
export default meta
type Story = StoryObj<typeof meta>
// 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: () => (
<StoryPage>
<StoryPageHeader title="LNB" />
<StorySection title="Overview">
<StateBoard
min={560}
states={[
{ id: "selected", label: "Selected", children: <ResponsiveSpecimen nodes={simpleNav()} initial="overview" initialOpen={{ tasks: true }} /> },
{ id: "third-selected", label: "3뎁스 · Selected", children: <ResponsiveSpecimen nodes={thirdLevelNav()} initial="projects/design/tokens" initialOpen={{ projects: true, "projects/design": true }} /> },
]}
/>
</StorySection>
<StorySection title="States">
<StateBoard
min={560}
states={[
{ id: "default", label: "Default", children: <ResponsiveSpecimen nodes={simpleNav()} initial={null} initialOpen={{ tasks: false }} /> },
{ id: "expanded", label: "Expanded", children: <ResponsiveSpecimen nodes={simpleNav()} initial={null} initialOpen={{ tasks: true }} /> },
{ id: "selected-child", label: "Selected Child", children: <ResponsiveSpecimen nodes={simpleNav()} initial="tasks/list" initialOpen={{ tasks: true }} /> },
{ id: "collapsed-with-selection", label: "Collapsed With Selection", children: <ResponsiveSpecimen nodes={simpleNav()} initial="tasks/list" initialOpen={{ tasks: false }} /> },
{ id: "third-second-collapsed", label: "3뎁스 · 2뎁스 접힘", children: <ResponsiveSpecimen nodes={thirdLevelNav()} initial="projects/design/tokens" initialOpen={{ projects: true, "projects/design": false }} /> },
{ id: "third-first-collapsed", label: "3뎁스 · 1뎁스 접힘", children: <ResponsiveSpecimen nodes={thirdLevelNav()} initial="projects/design/tokens" initialOpen={{ projects: false, "projects/design": true }} /> },
]}
/>
</StorySection>
<StorySection title="Variants">
<StateBoard
min={560}
states={[
{ id: "with-logo", label: "With Logo", children: <ResponsiveSpecimen nodes={simpleNav()} initial="overview" initialOpen={{ tasks: true }} logo={testLogo} /> },
{ id: "long", label: "Long Labels", children: <ResponsiveSpecimen nodes={simpleNav(true)} initial="tasks/list" initialOpen={{ tasks: true }} longLabels /> },
]}
/>
</StorySection>
<StorySection title="Usage · Scroll">
<StateBoard
min={560}
states={[
{ id: "scroll", label: "Scroll", children: <ResponsiveSpecimen nodes={simpleNav(false, true)} initial="overview" initialOpen={{ tasks: true }} height={460} /> },
{ id: "third-scroll", label: "3뎁스 · Scroll", children: <ResponsiveSpecimen nodes={thirdLevelNav(true, true)} initial="projects/design/layout" initialOpen={{ projects: true, "projects/design": true }} longLabels height={460} /> },
]}
/>
</StorySection>
<StorySection title="Usage · Standalone 반응형">
<div className="flex flex-wrap items-start gap-6">
<ViewportPreview story="standalone-desktop" label="Desktop" width={1024} height={640} />
<ViewportPreview story="standalone-mobile" label="Mobile" width={390} height={640} />
</div>
</StorySection>
</StoryPage>
),
}
export const Default: Story = { tags: ["verification"], render: () => <Menu initial={null} groupOpen={false} /> }
export const Selected: Story = { tags: ["verification"], render: () => <Menu /> }
export const Expanded: Story = { tags: ["verification"], render: () => <Menu initial={null} /> }
export const SelectedChild: Story = { tags: ["verification"], name: "Selected Child", render: () => <Menu initial="tasks/list" /> }
export const CollapsedWithSelection: Story = { tags: ["verification"], name: "Collapsed With Selection", render: () => <Menu initial="tasks/list" groupOpen={false} /> }
export const WithLogo: Story = { tags: ["verification"], name: "With Logo", render: () => <Menu logo={testLogo} /> }
export const LongLabels: Story = { tags: ["verification"], name: "Long Labels", render: () => <Menu initial="tasks/list" longLabels /> }
export const Scroll: Story = { tags: ["verification"], render: () => <Menu many height={460} /> }
export const ThirdLevel: Story = { tags: ["verification"], name: "Third Level", render: () => <ThirdLevelMenu /> }
export const ThirdLevelLongLabels: Story = { tags: ["verification"], name: "Third Level Long Labels", render: () => <ThirdLevelMenu initial="projects/design/components" longLabels /> }
export const ThirdLevelScroll: Story = { tags: ["verification"], name: "Third Level Scroll", render: () => <ThirdLevelMenu initial="projects/design/layout" longLabels many height={460} /> }
// 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: () => (
<div>
<p role="note">VERIFICATION ONLY — 기존 혼합 구조 기술 검증. 운영 사용 금지.</p>
<GNB>
<GNBMobileMenu title="메뉴">
<div className="mt-4 border-t border-border pt-4">
<ProfileMenu
modal={false}
trigger={
<GNBUser
name={profileData.name}
description={profileData.description}
className="w-full justify-start !text-foreground [&_[data-slot=gnb-user-description]]:!text-muted-foreground [&_[data-slot=gnb-user-text]]:!grid"
>
<ProfileAvatar />
</GNBUser>
}
/>
</div>
</GNBMobileMenu>
<GNBBrand href="#overview">
<span>
<small>한맥 업무 시스템</small>
<strong>프로젝트 대시보드</strong>
</span>
</GNBBrand>
<GNBNav />
<GNBUtilities className="hidden lg:flex">
<ProfileMenu align="end" trigger={<GNBUser name={profileData.name} description={profileData.description}><ProfileAvatar /></GNBUser>} />
</GNBUtilities>
</GNB>
<Menu brand={null} profile={false} />
</div>
),
}
// 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: () => <Standalone /> }
export const LNBProfileMenuOpen: Story = { tags: ["verification"], name: "LNB Profile Menu Open", render: () => <Standalone profileOpen /> }
export const StandaloneMobile: Story = { tags: ["verification"], name: "LNB Standalone Mobile", ...mobileViewport, render: () => <Standalone /> }
export const StandaloneMobileMenuOpen: Story = {
tags: ["verification"],
name: "LNB Standalone Mobile Menu Open",
...mobileViewport,
render: () => <Standalone mobileOpen />,
}