566 lines
24 KiB
TypeScript
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>
|
|
),
|
|
}
|