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" import "./lnb.stories.css" // LNB is a composition pattern. Layout visual reference is the ui-viz LNB (the shared package default: 272px green column, 18/700 items, // Beige current item joined to the content, Black 10% hover, Black 8% open group). This Story overrides only the display values (240px column, // 212px min width, 14px items, 48/42px rows) in lnb.stories.css; the package defaults are unchanged. 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[] } // Line between large menu groups (a top-level group and its neighbours). A direct child of LNBNav, so it follows the menu rows' // x start; the look is in lnb.stories.css (same line as the profile divider). Desktop column only: the mobile Sheet is unchanged. function GroupDivider() { return
} function Menu({ initial = "overview", groupOpen = true, logo, eyebrow = "한맥 업무 시스템", brand = "프로젝트 대시보드", longLabels, many, height, profile = true, dividers = 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 /** Group divider lines around the menu group. `false` hides them (the profile divider above the profile stays). */ dividers?: 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} {dividers ? : null} {children.map((child) => ( {child.label} ))} {dividers ? : null} {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, dividers = true, }: { nodes: LocalNode[] initial: string | null initialOpen: Record logo?: string eyebrow?: string brand?: string longLabels?: boolean height?: number /** Group divider lines in the desktop menu, like `Menu`'s `dividers`. `false` hides them (the profile divider stays). */ dividers?: boolean }) { 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[], top = true): ReactNode => items.map((node, index) => ( {dividers && top && index > 0 && (node.children || items[index - 1].children) ? : null} {node.children ? ( setOpen((state) => ({ ...state, [node.id]: value }))} > {lnbMenu(node.children, false)} ) : ( {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. */}

연결된 콘텐츠

) } // 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, dividers = true }: { mobileOpen?: boolean; profileOpen?: boolean; dividers?: 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[], top = true): ReactNode => nodes.map((node, index) => ( {dividers && top && index > 0 && (node.children || nodes[index - 1].children) ? : null} {node.children ? ( setOpen((state) => ({ ...state, [node.id]: value }))} > {lnbMenu(node.children, false)} ) : ( {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}

연결된 콘텐츠

) } // 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 } }, } // `hm-lnb-story` (display: contents, see lnb.stories.css) scopes the 212px min width and 14px menu font to this Story only. const meta = { title: "Patterns/Layout/LNB", component: LNB, decorators: [(Story) =>
], 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: "expanded", label: "Expanded", children: }, { id: "states-third-selected", label: "3뎁스 · Selected", children: }, ]} /> ), } // Usage examples live in their own verification Stories: Scroll, Third Level Scroll, LNB Standalone Desktop / Mobile (and Mobile Menu Open). 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. // `dividers` is a Story arg (default true: the Story itself keeps its group dividers). export const StandaloneDesktop: StoryObj<{ dividers: boolean }> = { tags: ["verification"], name: "LNB Standalone Desktop", args: { dividers: true }, render: (args) => , } 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: () => , }