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('')
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 (
)
}
// 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, args }: { story: string; label: string; width: number; height: number; args?: string }) {
return (
{label} · {width}px
)
}
// `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: "third-selected", label: "3뎁스 · Selected", children: },
]}
/>
},
{ id: "states-third-selected", label: "3뎁스 · Selected", children: },
]}
/>
},
{ id: "long", label: "Long Labels", children: },
]}
/>
},
{ id: "third-scroll", label: "3뎁스 · Scroll", 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.
// `dividers` is a Story arg (default true: the Story itself keeps its group dividers). The representative board's Desktop preview
// opens this Story with `&args=dividers:!false` (see ViewportPreview) to show it without them.
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: () => ,
}