Initial commit: HMDesignSystem baseline
This commit is contained in:
commit
b6a0a61103
486 files changed
+68432
No files matched your search
@@ -0,0 +1,756 @@
|
||||
import { Fragment, useState, type CSSProperties, type MouseEvent, type ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
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 { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// LNB is a composition pattern. Layout visual reference is the ui-viz LNB (272px green column, 18/700 items, Beige current item joined
|
||||
// to the content, Black 10% hover, Black 20% open group). 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[] }
|
||||
|
||||
function Menu({
|
||||
initial = "overview",
|
||||
groupOpen = true,
|
||||
logo,
|
||||
eyebrow = "한맥 업무 시스템",
|
||||
brand = "프로젝트 대시보드",
|
||||
longLabels,
|
||||
many,
|
||||
height,
|
||||
profile = 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
|
||||
}) {
|
||||
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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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,
|
||||
}: {
|
||||
nodes: LocalNode[]
|
||||
initial: string | null
|
||||
initialOpen: Record<string, boolean>
|
||||
logo?: string
|
||||
eyebrow?: string
|
||||
brand?: string
|
||||
longLabels?: boolean
|
||||
height?: number
|
||||
}) {
|
||||
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[]): ReactNode =>
|
||||
items.map((node) =>
|
||||
node.children ? (
|
||||
<LNBGroup
|
||||
key={node.id}
|
||||
label={node.label}
|
||||
current={onPath(node.id)}
|
||||
open={isOpen(node.id)}
|
||||
onOpenChange={(value) => setOpen((state) => ({ ...state, [node.id]: value }))}
|
||||
>
|
||||
{lnbMenu(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>
|
||||
)
|
||||
)
|
||||
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
|
||||
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>
|
||||
<p className="text-sm text-muted-foreground" data-current-menu>
|
||||
선택: {current ?? "없음"}
|
||||
</p>
|
||||
</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 }: { mobileOpen?: boolean; profileOpen?: 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[]): ReactNode =>
|
||||
nodes.map((node) =>
|
||||
node.children ? (
|
||||
<LNBGroup
|
||||
key={node.id}
|
||||
label={node.label}
|
||||
current={onPath(node.id)}
|
||||
open={open[node.id] ?? true}
|
||||
onOpenChange={(value) => setOpen((state) => ({ ...state, [node.id]: value }))}
|
||||
>
|
||||
{lnbMenu(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>
|
||||
)
|
||||
)
|
||||
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
|
||||
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>
|
||||
<p className="text-sm text-muted-foreground" data-current-menu>
|
||||
선택: {current}
|
||||
</p>
|
||||
</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 } },
|
||||
}
|
||||
|
||||
// Representative-board preview of a Story at a fixed viewport. The LNB/bar switch is a viewport media query, so the board embeds the
|
||||
// verification Story (the same `Standalone` composition) in an iframe of that width instead of rebuilding it in a narrow container.
|
||||
// Each iframe is its own document, so the closed Sheets do not share a portal or focus with the board.
|
||||
function ViewportPreview({ story, label, width, height }: { story: string; label: string; width: number; height: number }) {
|
||||
return (
|
||||
<figure className="grid min-w-0 content-start gap-2">
|
||||
<figcaption className="text-sm text-muted-foreground">
|
||||
{label} · {width}px
|
||||
</figcaption>
|
||||
<div className="max-w-full overflow-x-auto">
|
||||
<iframe
|
||||
title={`${label} ${width}px`}
|
||||
src={`iframe.html?id=patterns-layout-lnb--${story}&viewMode=story`}
|
||||
width={width}
|
||||
height={height}
|
||||
className="box-content block border border-black/10"
|
||||
/>
|
||||
</div>
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = { title: "Patterns/Layout/LNB", component: LNB, 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
|
||||
kicker={STORY_KICKER.patterns}
|
||||
title="LNB"
|
||||
description="화면 왼쪽의 지역 내비게이션입니다. 1024px 이상에서 표시하고, 1023px 이하는 숨긴 뒤 모바일 메뉴로 대체합니다."
|
||||
/>
|
||||
<StorySection title="Overview" description="모든 뎁스 18px. 선택은 Beige 면 + 초록 글자, 왼쪽 모서리 8로 콘텐츠와 오목하게 이어집니다.">
|
||||
<StateBoard
|
||||
min={560}
|
||||
states={[
|
||||
{ id: "selected", label: "Selected", children: <ResponsiveSpecimen nodes={simpleNav()} initial="overview" initialOpen={{ tasks: true }} /> },
|
||||
{ id: "third-selected", label: "3뎁스 메뉴 · 선택", note: "3뎁스는 20px 들여쓰기와 1px 그린 연결선으로 구분하며, 선은 마지막 메뉴에서 끝납니다. 선택은 베이지, 상위 경로는 노란색입니다.", children: <ResponsiveSpecimen nodes={thirdLevelNav()} initial="projects/design/tokens" initialOpen={{ projects: true, "projects/design": true }} /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="States" description="그룹 펼침·접힘과 하위 선택에 따른 표시입니다. 선택된 경로의 상위는 접혀도 노란색을 유지합니다.">
|
||||
<StateBoard
|
||||
min={560}
|
||||
states={[
|
||||
{ id: "default", label: "Default · 기존 LNB", note: "선택 없음, 2뎁스 그룹 접힘. 항목에 마우스를 올리면 Black 10%가 콘텐츠 경계까지 닿습니다.", children: <ResponsiveSpecimen nodes={simpleNav()} initial={null} initialOpen={{ tasks: false }} /> },
|
||||
{ id: "expanded", label: "Expanded · 2뎁스 펼침", note: "하위 선택 없이 펼친 그룹: Black 20% 면 + 1px 초록 선.", children: <ResponsiveSpecimen nodes={simpleNav()} initial={null} initialOpen={{ tasks: true }} /> },
|
||||
{ id: "selected-child", label: "Selected Child · 2뎁스 선택", note: "하위 선택: 상위 이름·화살표가 노란색, 하위는 Beige.", children: <ResponsiveSpecimen nodes={simpleNav()} initial="tasks/list" initialOpen={{ tasks: true }} /> },
|
||||
{ id: "collapsed-with-selection", label: "Collapsed With Selection · 접힘", note: "하위가 선택된 채 접힌 그룹도 상위가 노란색을 유지합니다.", children: <ResponsiveSpecimen nodes={simpleNav()} initial="tasks/list" initialOpen={{ tasks: false }} /> },
|
||||
{ id: "third-second-collapsed", label: "3뎁스 메뉴 · 2뎁스 접힘", note: "디자인 관리를 접어도 선택된 경로의 1·2뎁스 표시를 유지합니다. 하위 메뉴와 연결선이 함께 숨겨집니다.", children: <ResponsiveSpecimen nodes={thirdLevelNav()} initial="projects/design/tokens" initialOpen={{ projects: true, "projects/design": false }} /> },
|
||||
{ id: "third-first-collapsed", label: "3뎁스 메뉴 · 1뎁스 접힘", note: "프로젝트 관리를 접어도 노란색을 유지합니다. 다시 펼치면 디자인 관리의 열림 상태가 그대로입니다(open은 화면이 관리).", children: <ResponsiveSpecimen nodes={thirdLevelNav()} initial="projects/design/tokens" initialOpen={{ projects: false, "projects/design": true }} /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Variants" description="로고 유무와 긴 이름입니다.">
|
||||
<StateBoard
|
||||
min={560}
|
||||
states={[
|
||||
{ id: "with-logo", label: "With Logo · 로고 있음(테스트 이미지)", note: "로고는 서비스명 위, 같은 시작선. 없으면 영역·간격 없이 서비스명만 둡니다.", children: <ResponsiveSpecimen nodes={simpleNav()} initial="overview" initialOpen={{ tasks: true }} logo={testLogo} /> },
|
||||
{ id: "long", label: "Long labels", note: "긴 이름은 줄바꿈되며 항목 높이가 늘어납니다.", children: <ResponsiveSpecimen nodes={simpleNav(true)} initial="tasks/list" initialOpen={{ tasks: true }} longLabels /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Usage · Scroll" description="메뉴가 열보다 길면 메뉴 영역만 세로로 스크롤하고 브랜드·프로필은 그대로입니다(높이 460).">
|
||||
<StateBoard
|
||||
min={560}
|
||||
states={[
|
||||
{ id: "scroll", label: "Scroll", children: <ResponsiveSpecimen nodes={simpleNav(false, true)} initial="overview" initialOpen={{ tasks: true }} height={460} /> },
|
||||
{ id: "third-scroll", label: "3뎁스 메뉴 · Scroll", note: "3뎁스·연결선·긴 이름도 메뉴 영역 안에서 함께 스크롤됩니다. 스크롤 영역은 브랜드 바로 아래에서 시작해 프로필 바로 위에서 끝납니다.", children: <ResponsiveSpecimen nodes={thirdLevelNav(true, true)} initial="projects/design/layout" initialOpen={{ projects: true, "projects/design": true }} longLabels height={460} /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection
|
||||
title="Usage · LNB Standalone 반응형 (GNB 없음)"
|
||||
description="1024px 이상은 왼쪽 LNB의 브랜드·프로필을, 1023px 이하는 LNB를 숨긴 뒤 Local 모바일 바와 Sheet의 메뉴·프로필을 사용합니다. 열림·키보드·Escape는 LNB Standalone Mobile Menu Open에서 확인합니다."
|
||||
>
|
||||
<div className="flex flex-wrap items-start gap-6">
|
||||
<ViewportPreview story="standalone-desktop" label="Desktop" width={1024} height={640} />
|
||||
<ViewportPreview story="standalone-mobile" label="Mobile" width={390} height={640} />
|
||||
</div>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const Default: Story = { tags: ["verification"], render: () => <Menu initial={null} groupOpen={false} /> }
|
||||
export const Selected: Story = { tags: ["verification"], render: () => <Menu /> }
|
||||
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.
|
||||
export const StandaloneDesktop: Story = { tags: ["verification"], name: "LNB Standalone Desktop", render: () => <Standalone /> }
|
||||
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 />,
|
||||
}
|
||||
Reference in new issue
Block a user