import { useEffect, useState, type CSSProperties, type MouseEvent, type ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite"
import { BellIcon, ChartColumnIcon, SearchIcon, SettingsIcon, type LucideIcon } from "lucide-react"
import {
Avatar,
AvatarFallback,
AvatarImage,
Badge,
Button,
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
GNB,
GNBBrand,
GNBMobileGroup,
GNBMobileLink,
GNBMobileMenu,
GNBNav,
GNBUser,
GNBUtilities,
LNB,
LNBGroup,
LNBItem,
LNBMobileGroup,
LNBMobileLink,
LNBNav,
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
Popover,
PopoverContent,
PopoverTrigger,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@hm-design/ui"
import { StateBoard } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// GNB is a composition pattern. Layout visual reference is the ui-viz GNB (green 70px bar, centered menu, Beige current item joined to
// the content, White 2-depth panel, text-only profile); every part inside is an HM component. The menu data, the user and the current
// location below are local sample data for this Story only (no network, no auth, no router). The desktop / mobile switch follows the
// viewport (1024px): narrow the Story to 390px to see the hamburger and the Sheet menu.
// GNBUser's default accessible name includes both the visible name and description; explicit labels must preserve them.
type Destination = { id: string; label: string }
type NavItem = { id: string; label: string; children?: Destination[] }
const navItems: NavItem[] = [
{ id: "dashboard", label: "대시보드" },
{
id: "projects",
label: "프로젝트",
children: [
{ id: "projects/active", label: "진행 중 프로젝트" },
{ id: "projects/done", label: "완료 프로젝트" },
{ id: "projects/templates", label: "프로젝트 템플릿 관리와 기준 설정을 위한 긴 하위 메뉴 이름" },
],
},
{ id: "reports", label: "리포트" },
{
id: "admin",
label: "관리",
children: [
{ id: "admin/users", label: "사용자 관리" },
{ id: "admin/org", label: "조직 관리" },
],
},
]
const longNavItems: NavItem[] = [
{ id: "dashboard", label: "통합 대시보드와 현황 요약" },
{ id: "projects", label: "전사 프로젝트 포트폴리오 관리", children: [{ id: "projects/active", label: "진행 중 프로젝트" }] },
{ id: "reports", label: "정기 점검 결과 리포트" },
]
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 user = { image: photo, name: "김한맥", description: "프로젝트 관리자" }
const longUser = { image: brokenPhoto, name: "김한맥 (한맥기술 인프라사업본부 스마트시설팀)", description: "통합 시스템 관리자 · 인프라사업본부" }
// A test logo (not a Hanmac asset), only to check the logo layout.
const testLogo =
"data:image/svg+xml;utf8," +
encodeURIComponent('')
// The content that follows the bar: positioned, later in the DOM, top corners rounded by the join, so the bar's green continues behind it.
const contentStyle: CSSProperties = {
position: "relative",
minHeight: 220,
padding: 34,
borderRadius: "var(--hm-gnb-join) var(--hm-gnb-join) 0 0",
background: "var(--hm-sem-bg-content)",
}
function isCurrent(current: string, id: string) {
return current === id || current.startsWith(`${id}/`)
}
function SearchAction() {
return (
검색결과가 없습니다.진행 중 프로젝트월간 리포트사용자 관리
)
}
function NotificationAction({ count = 3 }: { count?: number }) {
return (
알림
) : (
{item.label}
)
)}
{utilities ? (
<>
>
) : null}
)
}
// The bar with the content that follows it (the GNB Mode page owns this part; here it only shows the join).
// `compact` is for the representative page only: it trims the connected area's height and padding (the 12px join and rounded corners stay).
const compactContentStyle: CSSProperties = { ...contentStyle, minHeight: 0, padding: 16 }
function Page({ children, label, compact = false }: { children: ReactNode; label?: string; compact?: boolean }) {
return (
{children}
{label === "" ? null :
{label ?? "연결된 콘텐츠 영역: GNB는 자기 높이와 12px 접점까지만 차지합니다."}
}
)
}
function StickyPage() {
return (
{Array.from({ length: 24 }, (_, i) => (
스크롤 본문 {i + 1}
))}
)
}
// Consumer-owned data and route state; every menu face/disclosure is an existing public LNB part.
type SidebarNode = { id: string; label: string; icon?: LucideIcon; children?: SidebarNode[] }
const sidebarNodes: SidebarNode[] = [
{ id: "dashboard", label: "대시보드" },
{
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: "일정 관리" },
...Array.from({ length: 8 }, (_, i) => ({ id: `projects/extra-${i + 1}`, label: `프로젝트 점검 메뉴 ${i + 1}` })),
],
},
{ id: "reports", label: "현황 보고", icon: ChartColumnIcon },
{ id: "admin", label: "관리", children: [
{ id: "admin/users", label: "사용자 관리" },
{ id: "admin/org", label: "조직 관리" },
] },
{ id: "settings", label: "설정", icon: SettingsIcon },
]
const sidebarGnbItems: NavItem[] = navItems.map(item => item.id === "projects" ? {
...item, children: [
{ id: "projects/design/tokens", label: "토큰 관리" },
{ id: "projects/tasks", label: "업무 목록" },
],
} : item)
// `showCurrent={false}` keeps the representative page UI-only; the verification Story keeps the raw route readout.
function SidebarPage({ fullHeight = false, color = "green", showCurrent = true }: { fullHeight?: boolean; color?: "green" | "brown"; showCurrent?: boolean }) {
const [current, setCurrent] = useState("projects/design/tokens")
const [groups, setGroups] = useState>({ projects: true, "projects/design": true })
const [mobileOpen, setMobileOpen] = useState(false)
// Same 1024/1023 contract as LNBMobileMenu; the sole Sheet belongs to the GNB header.
useEffect(() => {
const desktop = window.matchMedia("(min-width: 1024px)")
const closeOnDesktop = () => { if (desktop.matches) setMobileOpen(false) }
desktop.addEventListener("change", closeOnDesktop)
return () => desktop.removeEventListener("change", closeOnDesktop)
}, [])
const go = (id: string) => (event: MouseEvent) => {
event.preventDefault()
setCurrent(id)
// LNBMobileLink has its own context: close this GNB-owned Sheet in the shared route handler.
setMobileOpen(false)
}
const desktopMenu = (nodes: SidebarNode[]): ReactNode => nodes.map(node => node.children ? (
setGroups(state => ({ ...state, [node.id]: open }))}>
{desktopMenu(node.children)}
) : (
{node.icon ? : null}{node.label}
))
// LNB's existing mobile view is an expanded link list, not a new disclosure menu.
const mobileMenu = (nodes: SidebarNode[]): ReactNode => nodes.map(node => node.children ? (
{mobileMenu(node.children)}
) : (
{node.label}
))
return (
{mobileMenu(sidebarNodes)}
} />
{desktopMenu(sidebarNodes)}
프로젝트 업무 화면
{showCurrent ?
선택: {current}
: null}
선택한 메뉴의 콘텐츠를 표시합니다.
)
}
const meta = { title: "Patterns/Layout/GNB", component: GNB, parameters: { layout: "fullscreen", controls: { disable: true } } } satisfies Meta
export default meta
type Story = StoryObj
// Representative Story: Variants (Default, Long Labels) and States (Navigation Menu Open). Menus and the user menu open for real.
// Narrow the page to 390px for the mobile bar (hamburger → Sheet). Each GNB fills its specimen parent inside the same Inner as the
// page and section headings. The connected-area note shows once (Default); the verification Stories keep the default 12px join note.
// With Logo, Utilities, Sticky, GNB Sidebar and Brown Sidebar live in their own verification Stories.
export const All: Story = {
name: "GNB",
render: () => (
),
},
{
id: "long-labels",
label: "Long Labels",
wide: true,
children: (
),
},
]}
/>
{/* The open 2-depth panel ends 238px below the bar's top; the wrapper reserves that room so it never overlaps the page below. */}
),
},
]}
/>
),
}
export const Default: Story = { tags: ["verification"], render: () => }
export const Sticky: Story = { tags: ["verification"], render: () => }
export const Sidebar: Story = { tags: ["verification"], name: "GNB Sidebar", render: () => }
// Brown content LNB: same condition the representative page used to show (560px frame, no route readout).
export const BrownSidebar: Story = { tags: ["verification"], name: "Brown Sidebar", render: () => }
export const NavigationMenuOpen: Story = {
tags: ["verification"],
name: "Navigation Menu Open",
render: () => (
),
}
export const ShortDepthOpen: Story = {
tags: ["verification"],
render: () => ,
}
export const RightEdgeLongDepth: Story = {
tags: ["verification"],
render: () => item.id === "admin" ? { ...item, children: navItems[1].children } : item)} />,
}
// First / middle / last parents, each with the same short / long 2Depth labels.
// Position and width remain Production GNB's; Stories only supply menu data.
const depthAlignmentItems = (long: boolean): NavItem[] => ["first", "middle", "last"].map((id, index) => ({
id,
label: ["첫 메뉴", "중간 메뉴", "마지막 메뉴"][index],
children: long ? navItems[1].children : [{ id: `${id}/short`, label: "짧은 메뉴" }],
}))
const depthAlignmentRender = (openMenu: string, long: boolean) => () =>
export const FirstDepthShort: Story = { tags: ["verification"], render: depthAlignmentRender("first", false) }
export const FirstDepthLong: Story = { tags: ["verification"], render: depthAlignmentRender("first", true) }
export const MiddleDepthShort: Story = { tags: ["verification"], render: depthAlignmentRender("middle", false) }
export const MiddleDepthLong: Story = { tags: ["verification"], render: depthAlignmentRender("middle", true) }
export const LastDepthShort: Story = { tags: ["verification"], render: depthAlignmentRender("last", false) }
export const LastDepthLong: Story = { tags: ["verification"], render: depthAlignmentRender("last", true) }
export const EdgeFirstLong: Story = {
tags: ["verification"],
render: () =>