Files
HMDesign/src/patterns/gnb.stories.tsx
T

566 lines
24 KiB
TypeScript

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 { STORY_KICKER, 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('<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80"><rect width="80" height="80" fill="#266651"/><circle cx="40" cy="31" r="14" fill="#bddfd4"/><rect x="14" y="50" width="52" height="40" rx="22" fill="#bddfd4"/></svg>')
// 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('<svg xmlns="http://www.w3.org/2000/svg" width="120" height="32"><rect width="120" height="32" rx="4" fill="#fff" fill-opacity=".9"/><text x="60" y="21" font-size="13" font-weight="700" text-anchor="middle" fill="#123328">TEST LOGO</text></svg>')
// 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 (
<Popover>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button type="button" variant="ghost" size="icon" aria-label="검색">
<SearchIcon aria-hidden="true" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>검색</TooltipContent>
</Tooltip>
<PopoverContent align="end" style={{ "--hm-popover-padding": "0", "--hm-popover-width": "320px" } as CSSProperties}>
<Command>
<CommandInput placeholder="메뉴·프로젝트 검색" />
<CommandList>
<CommandEmpty>결과가 없습니다.</CommandEmpty>
<CommandGroup heading="바로가기">
<CommandItem>진행 중 프로젝트</CommandItem>
<CommandItem>월간 리포트</CommandItem>
<CommandItem>사용자 관리</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
function NotificationAction({ count = 3 }: { count?: number }) {
return (
<Popover>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button type="button" variant="ghost" size="icon" aria-label={`알림 ${count}건`} style={{ position: "relative" }}>
<BellIcon aria-hidden="true" />
<Badge
variant="destructive"
aria-hidden="true"
className="size-6"
style={{ position: "absolute", top: -8, right: -8, padding: 0, borderRadius: "50%" }}
>
{count}
</Badge>
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>알림</TooltipContent>
</Tooltip>
<PopoverContent align="end" style={{ "--hm-popover-width": "300px" } as CSSProperties}>
<div className="grid gap-2">
<p className="font-bold">새 알림</p>
<p>1호선 구간 점검 결과가 등록되었습니다.</p>
<p>월간 리포트 승인이 요청되었습니다.</p>
</div>
</PopoverContent>
</Popover>
)
}
function UserMenu({ person, defaultOpen }: { person: { name: string; description: string; image?: string }; defaultOpen?: boolean }) {
return (
<DropdownMenu defaultOpen={defaultOpen}>
<DropdownMenuTrigger asChild>
<GNBUser name={person.name} description={person.description}>
<Avatar>
{person.image ? <AvatarImage src={person.image} alt="" /> : null}
<AvatarFallback>{person.name.slice(0, 1)}</AvatarFallback>
</Avatar>
</GNBUser>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{person.name}</DropdownMenuLabel>
<DropdownMenuItem>프로필</DropdownMenuItem>
<DropdownMenuItem>설정</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">로그아웃</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
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 (
<GNB sticky={sticky}>
<GNBMobileMenu title="메뉴" defaultOpen={mobileOpen} open={mobileMenuOpen} onOpenChange={onMobileMenuOpenChange}>
{mobileChildren ?? items.map((item) =>
item.children ? (
<GNBMobileGroup key={item.id} label={item.label}>
{item.children.map((child) => (
<GNBMobileLink key={child.id} href={`#${child.id}`} active={current === child.id} onClick={go(child.id)}>
{child.label}
</GNBMobileLink>
))}
</GNBMobileGroup>
) : (
<GNBMobileLink key={item.id} href={`#${item.id}`} active={current === item.id} onClick={go(item.id)}>
{item.label}
</GNBMobileLink>
)
)}
</GNBMobileMenu>
<GNBBrand href="#dashboard" onClick={go("dashboard")}>
{logo ? <img src={logo} alt="" /> : null}
<span>
{eyebrow ? <small>{eyebrow}</small> : null}
<strong>{brand}</strong>
</span>
</GNBBrand>
<GNBNav>
<NavigationMenu viewport={false} aria-label="주 메뉴" defaultValue={openMenu}>
<NavigationMenuList>
{items.map((item) =>
item.children ? (
<NavigationMenuItem key={item.id} value={item.id}>
<NavigationMenuTrigger aria-current={isCurrent(current, item.id) ? "page" : undefined}>{item.label}</NavigationMenuTrigger>
<NavigationMenuContent>
<ul>
{item.children.map((child) => (
<li key={child.id}>
<NavigationMenuLink href={`#${child.id}`} active={current === child.id} onClick={go(child.id)}>
{child.label}
</NavigationMenuLink>
</li>
))}
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
) : (
<NavigationMenuItem key={item.id}>
<NavigationMenuLink href={`#${item.id}`} active={current === item.id} onClick={go(item.id)}>
{item.label}
</NavigationMenuLink>
</NavigationMenuItem>
)
)}
</NavigationMenuList>
</NavigationMenu>
</GNBNav>
<GNBUtilities>
{utilities ? (
<>
<SearchAction />
<NotificationAction />
</>
) : null}
<UserMenu person={person} defaultOpen={userOpen} />
</GNBUtilities>
</GNB>
)
}
// 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 (
<div>
{children}
<div style={contentStyle}>
<p className="text-sm text-muted-foreground">{label ?? "연결된 콘텐츠 영역: GNB는 자기 높이와 12px 접점까지만 차지합니다."}</p>
</div>
</div>
)
}
function StickyPage() {
return (
<div
role="region"
aria-label="Sticky GNB 스크롤 예시"
tabIndex={0}
style={{ height: 360, overflow: "auto", scrollbarWidth: "none" }}
>
<Bar sticky />
<div style={{ ...contentStyle, minHeight: 900 }} className="grid content-start gap-3 text-sm text-muted-foreground">
{Array.from({ length: 24 }, (_, i) => (
<p key={i}>스크롤 본문 {i + 1}</p>
))}
</div>
</div>
)
}
// 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<Record<string, boolean>>({ 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 ? (
<LNBGroup key={node.id} label={node.label} current={isCurrent(current, node.id)}
open={groups[node.id] ?? false} onOpenChange={open => setGroups(state => ({ ...state, [node.id]: open }))}>
{desktopMenu(node.children)}
</LNBGroup>
) : (
<LNBItem key={node.id} href={`#${node.id}`} active={current === node.id} onClick={go(node.id)}>
{node.icon ? <node.icon aria-hidden="true" /> : null}{node.label}
</LNBItem>
))
// 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 ? (
<LNBMobileGroup key={node.id} label={node.label}>{mobileMenu(node.children)}</LNBMobileGroup>
) : (
<LNBMobileLink key={node.id} href={`#${node.id}`} active={current === node.id} onClick={go(node.id)}>{node.label}</LNBMobileLink>
))
return (
<div data-hm-gnb-layout="sidebar" data-hm-color={color} style={{ height: fullHeight ? "100dvh" : 560 }}>
<Bar items={sidebarGnbItems} current={current} onCurrent={setCurrent}
mobileMenuOpen={mobileOpen} onMobileMenuOpenChange={setMobileOpen}
mobileChildren={<div data-slot="lnb-mobile-nav">{mobileMenu(sidebarNodes)}</div>} />
<div data-hm-gnb-body>
<LNB data-hm-gnb-sidebar>
<LNBNav aria-label="서비스 메뉴">{desktopMenu(sidebarNodes)}</LNBNav>
</LNB>
<main data-hm-gnb-main>
<div style={{ padding: contentStyle.padding }}>
<h2 className="text-lg font-bold">프로젝트 업무 화면</h2>
<p className="mt-2 text-sm text-muted-foreground" data-current-menu>선택: {current}</p>
<p className="mt-4 text-sm">선택한 메뉴의 콘텐츠를 표시합니다.</p>
</div>
</main>
</div>
</div>
)
}
const meta = { title: "Patterns/Layout/GNB", component: GNB, parameters: { layout: "fullscreen", controls: { disable: true } } } satisfies Meta<typeof GNB>
export default meta
type Story = StoryObj<typeof meta>
// 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: () => (
<StoryPage>
<StoryPageHeader
kicker={STORY_KICKER.patterns}
title="GNB"
description="서비스 최상단의 전역 내비게이션입니다. 1024px 이상은 Desktop 메뉴, 1023px 이하는 햄버거 → Sheet 메뉴를 사용합니다."
/>
<StorySection
title="Overview"
description="현재 위치(대시보드)는 Beige 면이 아래 콘텐츠와 오목하게 이어집니다. hover는 Black 10%. 프로젝트·관리는 hover/click으로 White 하위 패널이 열립니다."
>
<Page>
<Bar />
</Page>
</StorySection>
<StorySection
title="States · Navigation Menu Open"
description="프로젝트 메뉴가 열려 White 면으로 패널과 이어지고, 하위 현재 위치(진행 중 프로젝트)는 Beige로 표시됩니다. 긴 하위 이름은 Desktop 패널 폭을 확장하여 한 줄로 표시합니다."
>
<div style={{ minHeight: 360 }}>
<Page label="프로젝트 > 진행 중 프로젝트">
<Bar initial="projects/active" openMenu="projects" />
</Page>
</div>
</StorySection>
<StorySection
title="Variants · Long Labels"
description="긴 브랜드명·메뉴명·사용자명. 브랜드와 사용자 글자는 말줄임, 메뉴 항목은 줄바꿈 없이 유지되고 공간을 넘으면 메뉴 영역에서 잘립니다."
>
<Page>
<Bar items={longNavItems} person={longUser} eyebrow="한맥기술 인프라 통합 시스템" brand="스마트 시설물 통합 관리 시스템" />
</Page>
</StorySection>
<StorySection
title="Variants · With Logo"
description="로고 asset이 있을 때만 왼쪽에 원본 비율로 놓입니다. 없으면 영역·간격 없이 글자만 둡니다(Overview). 표시 이미지는 배치 확인용 테스트 이미지이며 한맥 로고가 아닙니다."
>
<Page>
<Bar logo={testLogo} />
</Page>
</StorySection>
<StorySection
title="Variants · Utilities"
description="선택 사항. ui-viz의 GNB 오른쪽은 이름·역할만 둡니다. 검색·알림이 필요한 서비스는 HM ghost Button 아이콘을 GNBUtilities에 조합합니다(Tooltip, Badge, Popover, Command)."
>
<Page>
<Bar utilities />
</Page>
</StorySection>
<StorySection
title="Usage · Sticky"
description="일반 GNB와 같은 외형으로 스크롤 영역 상단에 붙습니다. 본문 위에서 휠/터치로 스크롤하거나 영역에 포커스한 뒤 PageDown/End/Home 키로 확인합니다."
>
<StickyPage />
</StorySection>
<StorySection title="Usage · GNB Sidebar"
description="기존 Reference 전용 콘텐츠 LNB입니다. 본문과 같은 배경, 세로 구분선 하나, 아이콘 기본 생략·화살표 유지. 신규 AI GNB_ONLY 화면에는 Sidebar를 생성하지 않습니다.">
<SidebarPage />
</StorySection>
<StorySection title="Usage · Brown 콘텐츠 LNB" description="기존 Reference의 Brown 선택·Hover를 비교합니다. Green 선택색을 혼용하지 않습니다."><SidebarPage color="brown" /></StorySection>
</StoryPage>
),
}
export const Default: Story = { tags: ["verification"], render: () => <Page><Bar /></Page> }
export const Sticky: Story = { tags: ["verification"], render: () => <StickyPage /> }
export const Sidebar: Story = { tags: ["verification"], name: "GNB Sidebar", render: () => <SidebarPage fullHeight /> }
export const NavigationMenuOpen: Story = {
tags: ["verification"],
name: "Navigation Menu Open",
render: () => (
<div style={{ minHeight: 360 }}>
<Page label="프로젝트 > 진행 중 프로젝트">
<Bar initial="projects/active" openMenu="projects" />
</Page>
</div>
),
}
export const ShortDepthOpen: Story = {
tags: ["verification"],
render: () => <Page><Bar openMenu="admin" /></Page>,
}
export const RightEdgeLongDepth: Story = {
tags: ["verification"],
render: () => <Page><Bar openMenu="admin" items={navItems.map((item) => item.id === "admin" ? { ...item, children: navItems[1].children } : item)} /></Page>,
}
// 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) => () => <Page><Bar items={depthAlignmentItems(long)} openMenu={openMenu} /></Page>
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: () => <div style={{ width: 600, maxWidth: "100%" }}><Page><Bar items={depthAlignmentItems(true)} openMenu="first" /></Page></div>,
}
export const EdgeLastLong: Story = {
tags: ["verification"],
render: () => <div style={{ width: 600, maxWidth: "100%", marginLeft: "auto" }}><Page><Bar items={depthAlignmentItems(true)} openMenu="last" /></Page></div>,
}
export const UserMenuOpen: Story = { tags: ["verification"], name: "User Menu Open", render: () => <Page><Bar userOpen /></Page> }
export const LongLabels: Story = {
tags: ["verification"],
name: "Long Labels",
render: () => (
<Page>
<Bar items={longNavItems} person={longUser} eyebrow="한맥기술 인프라 통합 시스템" brand="스마트 시설물 통합 관리 시스템" />
</Page>
),
}
export const WithLogo: Story = { tags: ["verification"], name: "With Logo", render: () => <Page><Bar logo={testLogo} /></Page> }
export const Utilities: Story = { tags: ["verification"], render: () => <Page><Bar utilities /></Page> }
// 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: () => <Page><Bar initial="projects/active" utilities /></Page> }
export const MobileMenuOpen: Story = {
tags: ["verification"],
name: "Mobile Menu Open",
globals: { viewport: { value: "hmMobile390", isRotated: false } },
render: () => (
<Page>
<Bar initial="projects/active" mobileOpen />
</Page>
),
}