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 { 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('TEST LOGO') // 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 ( 알림

새 알림

1호선 구간 점검 결과가 등록되었습니다.

월간 리포트 승인이 요청되었습니다.

) } function UserMenu({ person, defaultOpen }: { person: { name: string; description: string; image?: string }; defaultOpen?: boolean }) { return ( {person.image ? : null} {person.name.slice(0, 1)} {person.name} 프로필 설정 로그아웃 ) } function Bar({ items = navItems, person = user, eyebrow = "한맥 업무 시스템", brand = "프로젝트 대시보드", logo, initial = "dashboard", openMenu, userOpen, mobileOpen, sticky, utilities, onCurrent, current: currentProp, mobileChildren, mobileMenuOpen, onMobileMenuOpenChange, }: { items?: NavItem[] person?: { name: string; description: string } eyebrow?: string brand?: string logo?: string initial?: string openMenu?: string userOpen?: boolean mobileOpen?: boolean sticky?: boolean utilities?: boolean onCurrent?: (id: string) => void current?: string mobileChildren?: ReactNode mobileMenuOpen?: boolean onMobileMenuOpenChange?: (open: boolean) => void }) { const [localCurrent, setCurrent] = useState(initial) const current = currentProp ?? localCurrent const go = (id: string) => (event: MouseEvent) => { event.preventDefault() if (currentProp === undefined) setCurrent(id) onCurrent?.(id) } return ( {mobileChildren ?? items.map((item) => item.children ? ( {item.children.map((child) => ( {child.label} ))} ) : ( {item.label} ) )} {logo ? : null} {eyebrow ? {eyebrow} : null} {brand} {items.map((item) => item.children ? ( {item.label}
    {item.children.map((child) => (
  • {child.label}
  • ))}
) : ( {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). function Page({ children, label }: { children: ReactNode; label?: string }) { return (
{children}

{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) function SidebarPage({ fullHeight = false, color = "green" }: { fullHeight?: boolean; color?: "green" | "brown" }) { 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 ? )) // 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)}

프로젝트 업무 화면

선택: {current}

선택한 메뉴의 콘텐츠를 표시합니다.

) } const meta = { title: "Patterns/Layout/GNB", component: GNB, parameters: { layout: "fullscreen", controls: { disable: true } } } satisfies Meta export default meta type Story = StoryObj // Representative Story: the green bar with a current item, an open 2-depth panel, long labels, a logo, the optional utility icons and // sticky. 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. export const All: Story = { name: "GNB", render: () => (
), } export const Default: Story = { tags: ["verification"], render: () => } export const Sticky: Story = { tags: ["verification"], render: () => } export const Sidebar: Story = { tags: ["verification"], name: "GNB 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: () =>
, } export const EdgeLastLong: Story = { tags: ["verification"], render: () =>
, } export const UserMenuOpen: Story = { tags: ["verification"], name: "User Menu Open", render: () => } export const LongLabels: Story = { tags: ["verification"], name: "Long Labels", render: () => ( ), } export const WithLogo: Story = { tags: ["verification"], name: "With Logo", render: () => } export const Utilities: Story = { tags: ["verification"], render: () => } // Mobile verification: use a 390px viewport (the switch is a viewport media query, so it cannot be shown inside a narrow container). export const Mobile: Story = { tags: ["verification"], globals: { viewport: { value: "hmMobile390", isRotated: false } }, render: () => } export const MobileMenuOpen: Story = { tags: ["verification"], name: "Mobile Menu Open", globals: { viewport: { value: "hmMobile390", isRotated: false } }, render: () => ( ), }