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('')
// 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).
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)
// `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: 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.
// The representative page names the connected area only; the verification Stories keep the default 12px join note.
const contentLabel = "콘텐츠 영역"
export const All: Story = {
name: "GNB",
render: () => (