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 { STORY_KICKER, 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('TEST LOGO') const photo = "data:image/svg+xml;utf8," + encodeURIComponent('') // 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 ( {profileData.fallback} ) } 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 ( {trigger} {profileData.name} {profileActions.map((action) => ( {action.destructive ? : null} {action.label} ))} ) } 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(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 (
{brand !== null ? ( {logo ? : null} {eyebrow ? {eyebrow} : null} {brand} ) : null} {children.map((child) => ( {child.label} ))} {extra.map((item) => ( ))} {profile ? ( {profileData.fallback} } /> ) : null}

연결된 콘텐츠

선택: {current ?? "없음"}

) } // 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) => ( ))} } />

연결된 콘텐츠

선택: {current ?? "없음"}

) } 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 logo?: string eyebrow?: string brand?: string longLabels?: boolean height?: number }) { const [current, setCurrent] = useState(initial) const [open, setOpen] = useState>(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 ? ( setOpen((state) => ({ ...state, [node.id]: value }))} > {lnbMenu(node.children)} ) : ( {node.icon ? ) ) const sheetMenu = (items: LocalNode[]): ReactNode => items.map((node) => node.children ? ( {isOpen(node.id) ? sheetMenu(node.children) : null} ) : ( {node.label} ) ) const brandContent = ( <> {logo ? : null} {eyebrow ? {eyebrow} : null} {brand} ) const profileTrigger = ( {profileData.fallback} ) return (
{brandContent} {lnbMenu(nodes)}
{sheetMenu(nodes)}
{profileData.fallback} } />
{brandContent}
{/* Content paints above the navigation join strip, within the same LNB Mode. */}

연결된 콘텐츠

선택: {current ?? "없음"}

) } // 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>({}) 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 ? ( setOpen((state) => ({ ...state, [node.id]: value }))} > {lnbMenu(node.children)} ) : ( {node.icon ? ) ) const sheetMenu = (nodes: LocalNode[]): ReactNode => nodes.map((node) => node.children ? ( {sheetMenu(node.children)} ) : ( {node.label} ) ) const brand = ( 한맥 업무 시스템 프로젝트 대시보드 ) return ( // LNB Mode owns its height independently of Dashboard.
{brand} {lnbMenu(standaloneNav)} } />
{sheetMenu(standaloneNav)}
} />
{brand}

연결된 콘텐츠

선택: {current}

) } // 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