- Adopt the official LNB desktop spec as package defaults: 240px column, 14px menu text, 48px 1-depth rows and 42px 2/3-depth rows, with new item/subitem padding-block and profile min-height tokens. - Match --hm-lnb-current-depth to the Storybook Manager selected item: sampled soft inset falloff (left/top/bottom) and a fade over the last quarter, keeping the concave corners and content join. - Drop the size overrides from the LNB Story CSS and update the LNB, GNB and Dashboard docs, AI guides and the theme verification baseline.
721 lines
30 KiB
TypeScript
721 lines
30 KiB
TypeScript
import { Fragment, useState, type CSSProperties, type MouseEvent, type ReactNode } from "react"
|
|
import type { Meta, StoryObj } from "@storybook/react-vite"
|
|
// LNBProfile's default accessible name includes the visible name and description; explicit labels must preserve them.
|
|
import { ChartColumnIcon, LayoutGridIcon, ListChecksIcon, SettingsIcon, type LucideIcon } from "lucide-react"
|
|
import {
|
|
GNB,
|
|
GNBBrand,
|
|
GNBMobileMenu,
|
|
GNBNav,
|
|
GNBUser,
|
|
GNBUtilities,
|
|
Avatar,
|
|
AvatarFallback,
|
|
AvatarImage,
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
LNB,
|
|
LNBBrand,
|
|
LNBGroup,
|
|
LNBItem,
|
|
LNBNav,
|
|
LNBProfile,
|
|
LNBMobileHeader,
|
|
LNBMobileBrand,
|
|
LNBMobileGroup,
|
|
LNBMobileLink,
|
|
LNBMobileMenu,
|
|
} from "@hm-design/ui"
|
|
import { StateBoard } from "@/components/story-board"
|
|
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
|
import "./lnb.stories.css"
|
|
|
|
// LNB is a composition pattern. Layout visual reference is the ui-viz LNB (the shared package default: 240px green column, 14/700 items,
|
|
// 48px 1-depth and 42px 2/3-depth rows, Beige current item joined to the content, Black 10% hover, Black 8% open group). The sizes come from the package
|
|
// (`--hm-lnb-*` in packages/ui/src/theme/lnb.css); lnb.stories.css only keeps Story presentation (212px min width, brand bottom padding, profile text,
|
|
// divider lines). The menu data and the current location are local sample data for this Story
|
|
// (no router). The content next to the column is a positioned surface with `--hm-lnb-join` rounded left corners: the LNB Mode page
|
|
// owns it, here it only shows the connection. The column is desktop only (hidden below 1024px).
|
|
|
|
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>')
|
|
|
|
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 profileData = {
|
|
name: "김한맥",
|
|
description: "프로젝트 관리자",
|
|
photo,
|
|
fallback: "김",
|
|
}
|
|
|
|
const profileActions: { label: string; destructive?: boolean }[] = [
|
|
{ label: "프로필 설정" },
|
|
{ label: "로그아웃", destructive: true },
|
|
]
|
|
|
|
function ProfileAvatar() {
|
|
return (
|
|
<Avatar>
|
|
<AvatarImage src={profileData.photo} alt="" />
|
|
<AvatarFallback>{profileData.fallback}</AvatarFallback>
|
|
</Avatar>
|
|
)
|
|
}
|
|
|
|
function ProfileMenu({ trigger, side = "bottom", align = "start", modal, matchTrigger = false, defaultOpen = false }: { trigger: ReactNode; side?: "top" | "right" | "bottom" | "left"; align?: "start" | "center" | "end"; modal?: boolean; matchTrigger?: boolean; defaultOpen?: boolean }) {
|
|
return (
|
|
<DropdownMenu modal={modal} defaultOpen={defaultOpen}>
|
|
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
|
<DropdownMenuContent side={side} align={align} className={matchTrigger ? "min-w-(--radix-dropdown-menu-trigger-width)" : undefined}>
|
|
<DropdownMenuLabel>{profileData.name}</DropdownMenuLabel>
|
|
{profileActions.map((action) => (
|
|
<Fragment key={action.label}>
|
|
{action.destructive ? <DropdownMenuSeparator /> : null}
|
|
<DropdownMenuItem variant={action.destructive ? "destructive" : "default"}>{action.label}</DropdownMenuItem>
|
|
</Fragment>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)
|
|
}
|
|
|
|
const shellStyle: CSSProperties = {
|
|
display: "grid",
|
|
gridTemplateColumns: "var(--hm-lnb-width) minmax(0, 1fr)",
|
|
minHeight: 460,
|
|
background: "var(--hm-lnb-background-end)",
|
|
}
|
|
const contentStyle: CSSProperties = {
|
|
position: "relative",
|
|
// Keep connected content in the second track when the first-track LNB becomes display:none at <=1023px.
|
|
// Without explicit placement, Grid auto-placement moves it into the now-empty 0px LNB track.
|
|
gridColumn: 2,
|
|
minWidth: 0,
|
|
padding: 34,
|
|
borderRadius: "var(--hm-lnb-join) 0 0 var(--hm-lnb-join)",
|
|
background: "var(--hm-sem-bg-content)",
|
|
}
|
|
|
|
type Child = { id: string; label: string }
|
|
type LocalNode = { id: string; label: string; icon?: LucideIcon; children?: LocalNode[] }
|
|
|
|
// Line between large menu groups (a top-level group and its neighbours). A direct child of LNBNav, so it follows the menu rows'
|
|
// x start; the look is in lnb.stories.css (same line as the profile divider). Desktop column only: the mobile Sheet is unchanged.
|
|
function GroupDivider() {
|
|
return <div role="separator" className="hm-lnb-story-group-divider" />
|
|
}
|
|
|
|
function Menu({
|
|
initial = "overview",
|
|
groupOpen = true,
|
|
logo,
|
|
eyebrow = "한맥 업무 시스템",
|
|
brand = "프로젝트 대시보드",
|
|
longLabels,
|
|
many,
|
|
height,
|
|
profile = true,
|
|
dividers = true,
|
|
}: {
|
|
initial?: string | null
|
|
groupOpen?: boolean
|
|
logo?: string
|
|
eyebrow?: string
|
|
/** `null`: no LNBBrand (a GNB above carries the service identity). */
|
|
brand?: string | null
|
|
longLabels?: boolean
|
|
many?: boolean
|
|
height?: number
|
|
/** Story composition only: GNB + LNB gives profile ownership to the GNB. */
|
|
profile?: boolean
|
|
/** Group divider lines around the menu group. `false` hides them (the profile divider above the profile stays). */
|
|
dividers?: boolean
|
|
}) {
|
|
const [current, setCurrent] = useState<string | null>(initial)
|
|
const [open, setOpen] = useState(groupOpen)
|
|
const go = (id: string) => (event: MouseEvent) => {
|
|
event.preventDefault()
|
|
setCurrent(id)
|
|
}
|
|
const children: Child[] = longLabels
|
|
? [
|
|
{ id: "tasks/list", label: "전사 프로젝트 포트폴리오의 진행 현황 목록" },
|
|
{ id: "tasks/board", label: "업무 보드" },
|
|
]
|
|
: [
|
|
{ id: "tasks/list", label: "메뉴 2-1" },
|
|
{ id: "tasks/board", label: "메뉴 2-2" },
|
|
]
|
|
const extra = many ? Array.from({ length: 8 }, (_, i) => ({ id: `extra-${i}`, label: `추가 메뉴 ${i + 1}` })) : []
|
|
return (
|
|
<div style={{ ...shellStyle, height }}>
|
|
<LNB>
|
|
{brand !== null ? (
|
|
<LNBBrand>
|
|
{logo ? <img src={logo} alt="" /> : null}
|
|
<span>
|
|
{eyebrow ? <small>{eyebrow}</small> : null}
|
|
<strong>{brand}</strong>
|
|
</span>
|
|
</LNBBrand>
|
|
) : null}
|
|
<LNBNav aria-label="서비스 메뉴">
|
|
<LNBItem href="#overview" active={current === "overview"} onClick={go("overview")}>
|
|
<LayoutGridIcon aria-hidden="true" />
|
|
{longLabels ? "프로젝트 현황과 전체 일정을 한눈에 보는 개요" : "메뉴 1"}
|
|
</LNBItem>
|
|
{dividers ? <GroupDivider /> : null}
|
|
<LNBGroup label={longLabels ? "업무 관리와 진행 상황 점검" : "메뉴 2"} current={Boolean(current?.startsWith("tasks/"))} open={open} onOpenChange={setOpen}>
|
|
{children.map((child) => (
|
|
<LNBItem key={child.id} href={`#${child.id}`} active={current === child.id} onClick={go(child.id)}>
|
|
{child.label}
|
|
</LNBItem>
|
|
))}
|
|
</LNBGroup>
|
|
{dividers ? <GroupDivider /> : null}
|
|
<LNBItem href="#reports" active={current === "reports"} onClick={go("reports")}>
|
|
<ChartColumnIcon aria-hidden="true" />
|
|
메뉴 3
|
|
</LNBItem>
|
|
<LNBItem href="#settings" active={current === "settings"} onClick={go("settings")}>
|
|
<SettingsIcon aria-hidden="true" />
|
|
메뉴 4
|
|
</LNBItem>
|
|
{extra.map((item) => (
|
|
<LNBItem key={item.id} href={`#${item.id}`} active={current === item.id} onClick={go(item.id)}>
|
|
<ListChecksIcon aria-hidden="true" />
|
|
{item.label}
|
|
</LNBItem>
|
|
))}
|
|
</LNBNav>
|
|
{profile ? (
|
|
<ProfileMenu
|
|
side="right"
|
|
align="end"
|
|
matchTrigger
|
|
trigger={
|
|
<LNBProfile name={longLabels ? "김한맥 (한맥기술 인프라사업본부 스마트시설팀)" : profileData.name} description={longLabels ? "통합 시스템 관리자 · 인프라사업본부" : profileData.description}>
|
|
<Avatar>
|
|
<AvatarImage src={longLabels ? brokenPhoto : profileData.photo} alt="" />
|
|
<AvatarFallback>{profileData.fallback}</AvatarFallback>
|
|
</Avatar>
|
|
</LNBProfile>
|
|
}
|
|
/>
|
|
) : null}
|
|
</LNB>
|
|
<div style={contentStyle}>
|
|
<h2 className="text-lg font-bold">연결된 콘텐츠</h2>
|
|
<p className="text-sm text-muted-foreground" data-current-menu>
|
|
선택: {current ?? "없음"}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// 3-depth: an `LNBGroup` nested in a group (no depth prop). The selected link is `active`, every group on its path is `current`, and
|
|
// each group's `open` is kept here so the nested group stays open after the outer one is closed and reopened.
|
|
const thirdLevelChildren = (long?: boolean): Child[] =>
|
|
long
|
|
? [
|
|
{ id: "projects/design/tokens", label: "컬러 토큰" },
|
|
{ id: "projects/design/components", label: "컴포넌트 스타일 규칙" },
|
|
{ id: "projects/design/layout", label: "레이아웃 규칙과 화면 폭별 여백 그리드 기준" },
|
|
]
|
|
: [
|
|
{ id: "projects/design/tokens", label: "컬러 토큰" },
|
|
{ id: "projects/design/components", label: "컴포넌트 스타일" },
|
|
{ id: "projects/design/layout", label: "레이아웃 규칙" },
|
|
]
|
|
|
|
function ThirdLevelMenu({
|
|
initial = "projects/design/tokens",
|
|
projectOpen = true,
|
|
designOpen = true,
|
|
longLabels,
|
|
many,
|
|
height,
|
|
}: {
|
|
initial?: string | null
|
|
projectOpen?: boolean
|
|
designOpen?: boolean
|
|
longLabels?: boolean
|
|
many?: boolean
|
|
height?: number
|
|
}) {
|
|
const [current, setCurrent] = useState<string | null>(initial)
|
|
const extra = many ? Array.from({ length: 6 }, (_, i) => ({ id: `extra-${i}`, label: `추가 메뉴 ${i + 1}` })) : []
|
|
const [open, setOpen] = useState({ projects: projectOpen, design: designOpen })
|
|
const go = (id: string) => (event: MouseEvent) => {
|
|
event.preventDefault()
|
|
setCurrent(id)
|
|
}
|
|
const onPath = (id: string) => Boolean(current?.startsWith(`${id}/`))
|
|
return (
|
|
<div style={{ ...shellStyle, height }}>
|
|
<LNB>
|
|
<LNBBrand>
|
|
<span>
|
|
<small>한맥 업무 시스템</small>
|
|
<strong>프로젝트 대시보드</strong>
|
|
</span>
|
|
</LNBBrand>
|
|
<LNBNav aria-label="서비스 메뉴">
|
|
<LNBItem href="#overview" active={current === "overview"} onClick={go("overview")}>
|
|
<LayoutGridIcon aria-hidden="true" />
|
|
대시보드
|
|
</LNBItem>
|
|
<GroupDivider />
|
|
<LNBGroup label="프로젝트 관리" current={onPath("projects")} open={open.projects} onOpenChange={(value) => setOpen((state) => ({ ...state, projects: value }))}>
|
|
<LNBGroup label="디자인 관리" current={onPath("projects/design")} open={open.design} onOpenChange={(value) => setOpen((state) => ({ ...state, design: value }))}>
|
|
{thirdLevelChildren(longLabels).map((child) => (
|
|
<LNBItem key={child.id} href={`#${child.id}`} active={current === child.id} onClick={go(child.id)}>
|
|
{child.label}
|
|
</LNBItem>
|
|
))}
|
|
</LNBGroup>
|
|
<LNBItem href="#projects/tasks" active={current === "projects/tasks"} onClick={go("projects/tasks")}>
|
|
업무 목록
|
|
</LNBItem>
|
|
<LNBItem href="#projects/schedule" active={current === "projects/schedule"} onClick={go("projects/schedule")}>
|
|
일정 관리
|
|
</LNBItem>
|
|
</LNBGroup>
|
|
<GroupDivider />
|
|
<LNBItem href="#reports" active={current === "reports"} onClick={go("reports")}>
|
|
<ChartColumnIcon aria-hidden="true" />
|
|
현황 보고
|
|
</LNBItem>
|
|
<LNBItem href="#settings" active={current === "settings"} onClick={go("settings")}>
|
|
<SettingsIcon aria-hidden="true" />
|
|
설정
|
|
</LNBItem>
|
|
{extra.map((item) => (
|
|
<LNBItem key={item.id} href={`#${item.id}`} active={current === item.id} onClick={go(item.id)}>
|
|
<ListChecksIcon aria-hidden="true" />
|
|
{item.label}
|
|
</LNBItem>
|
|
))}
|
|
</LNBNav>
|
|
<ProfileMenu side="right" align="end" matchTrigger trigger={<LNBProfile name={profileData.name} description={profileData.description}><ProfileAvatar /></LNBProfile>} />
|
|
</LNB>
|
|
<div style={contentStyle}>
|
|
<h2 className="text-lg font-bold">연결된 콘텐츠</h2>
|
|
<p className="text-sm text-muted-foreground" data-current-menu>
|
|
선택: {current ?? "없음"}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function localProfile(longLabels?: boolean) {
|
|
return {
|
|
name: longLabels ? "김한맥 (한맥기술 인프라사업본부 스마트시설팀)" : profileData.name,
|
|
description: longLabels ? "통합 시스템 관리자 · 인프라사업본부" : profileData.description,
|
|
photo: longLabels ? brokenPhoto : profileData.photo,
|
|
}
|
|
}
|
|
|
|
function simpleNav(longLabels?: boolean, many?: boolean): LocalNode[] {
|
|
const children: LocalNode[] = longLabels
|
|
? [
|
|
{ id: "tasks/list", label: "전사 프로젝트 포트폴리오의 진행 현황 목록" },
|
|
{ id: "tasks/board", label: "업무 보드" },
|
|
]
|
|
: [
|
|
{ id: "tasks/list", label: "메뉴 2-1" },
|
|
{ id: "tasks/board", label: "메뉴 2-2" },
|
|
]
|
|
const extra: LocalNode[] = many ? Array.from({ length: 8 }, (_, i) => ({ id: `extra-${i}`, label: `추가 메뉴 ${i + 1}`, icon: ListChecksIcon })) : []
|
|
return [
|
|
{ id: "overview", label: longLabels ? "프로젝트 현황과 전체 일정을 한눈에 보는 개요" : "메뉴 1", icon: LayoutGridIcon },
|
|
{ id: "tasks", label: longLabels ? "업무 관리와 진행 상황 점검" : "메뉴 2", children },
|
|
{ id: "reports", label: "메뉴 3", icon: ChartColumnIcon },
|
|
{ id: "settings", label: "메뉴 4", icon: SettingsIcon },
|
|
...extra,
|
|
]
|
|
}
|
|
|
|
function thirdLevelNav(longLabels?: boolean, many?: boolean): LocalNode[] {
|
|
const extra: LocalNode[] = many ? Array.from({ length: 6 }, (_, i) => ({ id: `extra-${i}`, label: `추가 메뉴 ${i + 1}`, icon: ListChecksIcon })) : []
|
|
return [
|
|
{ id: "overview", label: "대시보드", icon: LayoutGridIcon },
|
|
{
|
|
id: "projects",
|
|
label: "프로젝트 관리",
|
|
children: [
|
|
{ id: "projects/design", label: "디자인 관리", children: thirdLevelChildren(longLabels) },
|
|
{ id: "projects/tasks", label: "업무 목록" },
|
|
{ id: "projects/schedule", label: "일정 관리" },
|
|
],
|
|
},
|
|
{ id: "reports", label: "현황 보고", icon: ChartColumnIcon },
|
|
{ id: "settings", label: "설정", icon: SettingsIcon },
|
|
...extra,
|
|
]
|
|
}
|
|
|
|
// Story-only responsive composition. Desktop LNB and mobile Sheet are two views over one tree and one state source.
|
|
function ResponsiveSpecimen({
|
|
nodes,
|
|
initial,
|
|
initialOpen,
|
|
logo,
|
|
eyebrow = "한맥 업무 시스템",
|
|
brand = "프로젝트 대시보드",
|
|
longLabels,
|
|
height,
|
|
dividers = true,
|
|
}: {
|
|
nodes: LocalNode[]
|
|
initial: string | null
|
|
initialOpen: Record<string, boolean>
|
|
logo?: string
|
|
eyebrow?: string
|
|
brand?: string
|
|
longLabels?: boolean
|
|
height?: number
|
|
/** Group divider lines in the desktop menu, like `Menu`'s `dividers`. `false` hides them (the profile divider stays). */
|
|
dividers?: boolean
|
|
}) {
|
|
const [current, setCurrent] = useState<string | null>(initial)
|
|
const [open, setOpen] = useState<Record<string, boolean>>(initialOpen)
|
|
const profile = localProfile(longLabels)
|
|
const go = (id: string) => (event: MouseEvent) => {
|
|
event.preventDefault()
|
|
setCurrent(id)
|
|
}
|
|
const onPath = (id: string) => Boolean(current?.startsWith(`${id}/`))
|
|
const isOpen = (id: string) => open[id] ?? true
|
|
const lnbMenu = (items: LocalNode[], top = true): ReactNode =>
|
|
items.map((node, index) => (
|
|
<Fragment key={node.id}>
|
|
{dividers && top && index > 0 && (node.children || items[index - 1].children) ? <GroupDivider /> : null}
|
|
{node.children ? (
|
|
<LNBGroup
|
|
label={node.label}
|
|
current={onPath(node.id)}
|
|
open={isOpen(node.id)}
|
|
onOpenChange={(value) => setOpen((state) => ({ ...state, [node.id]: value }))}
|
|
>
|
|
{lnbMenu(node.children, false)}
|
|
</LNBGroup>
|
|
) : (
|
|
<LNBItem href={`#${node.id}`} active={current === node.id} onClick={go(node.id)}>
|
|
{node.icon ? <node.icon aria-hidden="true" /> : null}
|
|
{node.label}
|
|
</LNBItem>
|
|
)}
|
|
</Fragment>
|
|
))
|
|
const sheetMenu = (items: LocalNode[]): ReactNode =>
|
|
items.map((node) =>
|
|
node.children ? (
|
|
<LNBMobileGroup key={node.id} label={node.label} data-open={isOpen(node.id) ? "" : undefined}>
|
|
{isOpen(node.id) ? sheetMenu(node.children) : null}
|
|
</LNBMobileGroup>
|
|
) : (
|
|
<LNBMobileLink key={node.id} href={`#${node.id}`} active={current === node.id} onClick={go(node.id)}>
|
|
{node.label}
|
|
</LNBMobileLink>
|
|
)
|
|
)
|
|
const brandContent = (
|
|
<>
|
|
{logo ? <img src={logo} alt="" /> : null}
|
|
<span>
|
|
{eyebrow ? <small>{eyebrow}</small> : null}
|
|
<strong>{brand}</strong>
|
|
</span>
|
|
</>
|
|
)
|
|
const profileTrigger = (
|
|
<LNBProfile name={profile.name} description={profile.description}>
|
|
<Avatar>
|
|
<AvatarImage src={profile.photo} alt="" />
|
|
<AvatarFallback>{profileData.fallback}</AvatarFallback>
|
|
</Avatar>
|
|
</LNBProfile>
|
|
)
|
|
return (
|
|
<div
|
|
className="grid min-w-0 grid-cols-1 bg-[var(--hm-lnb-background-end)] lg:grid-cols-[var(--hm-lnb-width)_minmax(0,1fr)]"
|
|
style={{ minHeight: height ?? 460, height }}
|
|
data-story-slot="responsive-lnb-specimen"
|
|
>
|
|
<LNB>
|
|
<LNBBrand>{brandContent}</LNBBrand>
|
|
<LNBNav aria-label="서비스 메뉴">{lnbMenu(nodes)}</LNBNav>
|
|
<ProfileMenu side="right" align="end" matchTrigger trigger={profileTrigger} />
|
|
</LNB>
|
|
<div className="flex min-w-0 flex-col lg:col-start-2" data-story-slot="lnb-content-stack">
|
|
<div className="lg:hidden" data-story-slot="local-mobile-menu">
|
|
<LNBMobileHeader>
|
|
<LNBMobileMenu title="서비스 메뉴">
|
|
{sheetMenu(nodes)}
|
|
<div className="mt-4 border-t border-border pt-4">
|
|
<ProfileMenu
|
|
modal={false}
|
|
trigger={
|
|
<LNBProfile
|
|
className="hm-lnb-story-mobile-profile"
|
|
name={profile.name}
|
|
description={profile.description}
|
|
>
|
|
<Avatar>
|
|
<AvatarImage src={profile.photo} alt="" />
|
|
<AvatarFallback>{profileData.fallback}</AvatarFallback>
|
|
</Avatar>
|
|
</LNBProfile>
|
|
}
|
|
/>
|
|
</div>
|
|
</LNBMobileMenu>
|
|
<LNBMobileBrand asChild>
|
|
<div>{brandContent}</div>
|
|
</LNBMobileBrand>
|
|
</LNBMobileHeader>
|
|
</div>
|
|
{/* Content paints above the navigation join strip, within the same LNB Mode. */}
|
|
<div className="relative min-w-0 flex-1 max-lg:rounded-t-[var(--hm-lnb-mobile-join)] bg-[var(--hm-sem-bg-content)] p-[34px] lg:rounded-l-[var(--hm-lnb-join)]" data-story-slot="connected-content">
|
|
<h2 className="text-lg font-bold">연결된 콘텐츠</h2>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// LNB Mode: desktop column and LNB Mobile Header/Sheet share one local tree and state.
|
|
// This responsive change stays within LNB Mode.
|
|
const standaloneNav: LocalNode[] = [
|
|
{ id: "overview", label: "대시보드", icon: LayoutGridIcon },
|
|
{
|
|
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: "일정 관리" },
|
|
],
|
|
},
|
|
{ id: "reports", label: "현황 보고", icon: ChartColumnIcon },
|
|
{ id: "settings", label: "설정", icon: SettingsIcon },
|
|
]
|
|
|
|
function Standalone({ mobileOpen = false, profileOpen = false, dividers = true }: { mobileOpen?: boolean; profileOpen?: boolean; dividers?: boolean }) {
|
|
const [current, setCurrent] = useState("projects/design/tokens")
|
|
const [open, setOpen] = useState<Record<string, boolean>>({})
|
|
const go = (id: string) => (event: MouseEvent) => {
|
|
event.preventDefault()
|
|
setCurrent(id)
|
|
}
|
|
const onPath = (id: string) => current.startsWith(`${id}/`)
|
|
const lnbMenu = (nodes: LocalNode[], top = true): ReactNode =>
|
|
nodes.map((node, index) => (
|
|
<Fragment key={node.id}>
|
|
{dividers && top && index > 0 && (node.children || nodes[index - 1].children) ? <GroupDivider /> : null}
|
|
{node.children ? (
|
|
<LNBGroup
|
|
label={node.label}
|
|
current={onPath(node.id)}
|
|
open={open[node.id] ?? true}
|
|
onOpenChange={(value) => setOpen((state) => ({ ...state, [node.id]: value }))}
|
|
>
|
|
{lnbMenu(node.children, false)}
|
|
</LNBGroup>
|
|
) : (
|
|
<LNBItem href={`#${node.id}`} active={current === node.id} onClick={go(node.id)}>
|
|
{node.icon ? <node.icon aria-hidden="true" /> : null}
|
|
{node.label}
|
|
</LNBItem>
|
|
)}
|
|
</Fragment>
|
|
))
|
|
const sheetMenu = (nodes: LocalNode[]): ReactNode =>
|
|
nodes.map((node) =>
|
|
node.children ? (
|
|
<LNBMobileGroup key={node.id} label={node.label}>
|
|
{sheetMenu(node.children)}
|
|
</LNBMobileGroup>
|
|
) : (
|
|
<LNBMobileLink key={node.id} href={`#${node.id}`} active={current === node.id} onClick={go(node.id)}>
|
|
{node.label}
|
|
</LNBMobileLink>
|
|
)
|
|
)
|
|
const brand = (
|
|
<span>
|
|
<small>한맥 업무 시스템</small>
|
|
<strong>프로젝트 대시보드</strong>
|
|
</span>
|
|
)
|
|
return (
|
|
// LNB Mode owns its height independently of Dashboard.
|
|
<div
|
|
className="grid min-h-0 min-w-0 grid-cols-1 bg-[var(--hm-lnb-background-end)] lg:grid-cols-[var(--hm-lnb-width)_minmax(0,1fr)]"
|
|
style={{ height: "100dvh" }}
|
|
data-story-slot="responsive-lnb-specimen"
|
|
>
|
|
<LNB>
|
|
<LNBBrand>{brand}</LNBBrand>
|
|
<LNBNav aria-label="서비스 메뉴">{lnbMenu(standaloneNav)}</LNBNav>
|
|
<ProfileMenu side="right" align="end" defaultOpen={profileOpen} matchTrigger trigger={<LNBProfile name={profileData.name} description={profileData.description}><ProfileAvatar /></LNBProfile>} />
|
|
</LNB>
|
|
<div className="flex min-h-0 min-w-0 flex-1 flex-col" data-story-slot="lnb-content-stack">
|
|
<div className="lg:hidden" data-story-slot="local-mobile-menu">
|
|
<LNBMobileHeader>
|
|
<LNBMobileMenu title="서비스 메뉴" defaultOpen={mobileOpen}>
|
|
{sheetMenu(standaloneNav)}
|
|
<div className="mt-4 border-t border-border pt-4">
|
|
<ProfileMenu
|
|
modal={false}
|
|
trigger={
|
|
<LNBProfile
|
|
className="hm-lnb-story-mobile-profile"
|
|
name={profileData.name}
|
|
description={profileData.description}
|
|
>
|
|
<ProfileAvatar />
|
|
</LNBProfile>
|
|
}
|
|
/>
|
|
</div>
|
|
</LNBMobileMenu>
|
|
<LNBMobileBrand asChild>
|
|
<div>{brand}</div>
|
|
</LNBMobileBrand>
|
|
</LNBMobileHeader>
|
|
</div>
|
|
<main className="relative min-h-0 min-w-0 flex-1 overflow-auto max-lg:rounded-t-[var(--hm-lnb-mobile-join)] bg-[var(--hm-sem-bg-content)] p-[34px] lg:rounded-l-[var(--hm-lnb-join)]" data-story-slot="connected-content">
|
|
<h2 className="text-lg font-bold">연결된 콘텐츠</h2>
|
|
</main>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// A 390px viewport for the mobile Stories (the switch is a viewport media query, so a narrow container cannot show it).
|
|
const mobileViewport = {
|
|
parameters: { viewport: { options: { hmMobile: { name: "Mobile 390", styles: { width: "390px", height: "844px" }, type: "mobile" } } } },
|
|
globals: { viewport: { value: "hmMobile", isRotated: false } },
|
|
}
|
|
|
|
// `hm-lnb-story` (display: contents, see lnb.stories.css) scopes the 212px min width, brand bottom padding, profile text and divider lines to this Story only.
|
|
const meta = { title: "Patterns/Layout/LNB", component: LNB, decorators: [(Story) => <div className="hm-lnb-story"><Story /></div>], parameters: { layout: "fullscreen", controls: { disable: true } } } satisfies Meta<typeof LNB>
|
|
export default meta
|
|
type Story = StoryObj<typeof meta>
|
|
|
|
// Representative Story: the common Story page with the column in every state side by side. Hover and click work on the real items.
|
|
// The specimens are bounded LNB columns (their own frame, not the viewport width), so they stay on the page inner.
|
|
export const All: Story = {
|
|
name: "LNB",
|
|
render: () => (
|
|
<StoryPage>
|
|
<StoryPageHeader title="LNB" />
|
|
<StorySection title="States">
|
|
<StateBoard
|
|
min={560}
|
|
states={[
|
|
{ id: "selected", label: "Selected", children: <ResponsiveSpecimen nodes={simpleNav()} initial="overview" initialOpen={{ tasks: true }} dividers={false} /> },
|
|
{ id: "expanded", label: "Expanded", children: <ResponsiveSpecimen nodes={simpleNav()} initial={null} initialOpen={{ tasks: true }} /> },
|
|
{ id: "states-third-selected", label: "3뎁스 · Selected", children: <ResponsiveSpecimen nodes={thirdLevelNav()} initial="projects/design/tokens" initialOpen={{ projects: true, "projects/design": true }} /> },
|
|
]}
|
|
/>
|
|
</StorySection>
|
|
</StoryPage>
|
|
),
|
|
}
|
|
|
|
// Usage examples live in their own verification Stories: Scroll, Third Level Scroll, LNB Standalone Desktop / Mobile (and Mobile Menu Open).
|
|
|
|
export const Default: Story = { tags: ["verification"], render: () => <Menu initial={null} groupOpen={false} /> }
|
|
export const Selected: Story = { tags: ["verification"], render: () => <Menu dividers={false} /> }
|
|
export const Expanded: Story = { tags: ["verification"], render: () => <Menu initial={null} /> }
|
|
export const SelectedChild: Story = { tags: ["verification"], name: "Selected Child", render: () => <Menu initial="tasks/list" /> }
|
|
export const CollapsedWithSelection: Story = { tags: ["verification"], name: "Collapsed With Selection", render: () => <Menu initial="tasks/list" groupOpen={false} /> }
|
|
export const WithLogo: Story = { tags: ["verification"], name: "With Logo", render: () => <Menu logo={testLogo} /> }
|
|
export const LongLabels: Story = { tags: ["verification"], name: "Long Labels", render: () => <Menu initial="tasks/list" longLabels /> }
|
|
export const Scroll: Story = { tags: ["verification"], render: () => <Menu many height={460} /> }
|
|
export const ThirdLevel: Story = { tags: ["verification"], name: "Third Level", render: () => <ThirdLevelMenu /> }
|
|
export const ThirdLevelLongLabels: Story = { tags: ["verification"], name: "Third Level Long Labels", render: () => <ThirdLevelMenu initial="projects/design/components" longLabels /> }
|
|
export const ThirdLevelScroll: Story = { tags: ["verification"], name: "Third Level Scroll", render: () => <ThirdLevelMenu initial="projects/design/layout" longLabels many height={460} /> }
|
|
|
|
// VERIFICATION ONLY: legacy GNB + LNB technical check, forbidden in operating pages. The GNB owns the brand,
|
|
// so the LNB below has no LNBBrand and the menu starts at the column top.
|
|
export const WithGNB: Story = {
|
|
tags: ["verification"],
|
|
name: "VERIFICATION ONLY · GNB + LNB",
|
|
render: () => (
|
|
<div>
|
|
<p role="note">VERIFICATION ONLY — 기존 혼합 구조 기술 검증. 운영 사용 금지.</p>
|
|
<GNB>
|
|
<GNBMobileMenu title="메뉴">
|
|
<div className="mt-4 border-t border-border pt-4">
|
|
<ProfileMenu
|
|
modal={false}
|
|
trigger={
|
|
<GNBUser
|
|
name={profileData.name}
|
|
description={profileData.description}
|
|
className="w-full justify-start !text-foreground [&_[data-slot=gnb-user-description]]:!text-muted-foreground [&_[data-slot=gnb-user-text]]:!grid"
|
|
>
|
|
<ProfileAvatar />
|
|
</GNBUser>
|
|
}
|
|
/>
|
|
</div>
|
|
</GNBMobileMenu>
|
|
<GNBBrand href="#overview">
|
|
<span>
|
|
<small>한맥 업무 시스템</small>
|
|
<strong>프로젝트 대시보드</strong>
|
|
</span>
|
|
</GNBBrand>
|
|
<GNBNav />
|
|
<GNBUtilities className="hidden lg:flex">
|
|
<ProfileMenu align="end" trigger={<GNBUser name={profileData.name} description={profileData.description}><ProfileAvatar /></GNBUser>} />
|
|
</GNBUtilities>
|
|
</GNB>
|
|
<Menu brand={null} profile={false} />
|
|
</div>
|
|
),
|
|
}
|
|
|
|
// LNB without a GNB, responsive. Desktop (>= 1024px): the column. Mobile (<= 1023px): the column is hidden and the connected-content
|
|
// column becomes a full-width stack whose first row opens a Sheet with the same tree, selected item and every depth.
|
|
// `dividers` is a Story arg (default true: the Story itself keeps its group dividers).
|
|
export const StandaloneDesktop: StoryObj<{ dividers: boolean }> = {
|
|
tags: ["verification"],
|
|
name: "LNB Standalone Desktop",
|
|
args: { dividers: true },
|
|
render: (args) => <Standalone dividers={args.dividers} />,
|
|
}
|
|
export const LNBProfileMenuOpen: Story = { tags: ["verification"], name: "LNB Profile Menu Open", render: () => <Standalone profileOpen /> }
|
|
export const StandaloneMobile: Story = { tags: ["verification"], name: "LNB Standalone Mobile", ...mobileViewport, render: () => <Standalone /> }
|
|
export const StandaloneMobileMenuOpen: Story = {
|
|
tags: ["verification"],
|
|
name: "LNB Standalone Mobile Menu Open",
|
|
...mobileViewport,
|
|
render: () => <Standalone mobileOpen />,
|
|
}
|