Initial commit: HMDesignSystem baseline

This commit is contained in:
shkim4 committed 2026-10-08 16:58:09 +09:00
commit b6a0a61103
486 files changed
+68432

No files matched your search

+178
View File
@@ -0,0 +1,178 @@
@import "./dashboard-status-contract.css";
/* Runtime-neutral status contract → Story mapping. HM has no exact public status semantics for these three inks. */
.dashboard-production {
--dashboard-work-progress: var(--hm-dashboard-status-active);
--dashboard-work-hold: var(--hm-dashboard-status-hold);
--dashboard-work-issue: var(--hm-dashboard-status-issue);
--dashboard-work-badge-background: color-mix(in srgb, var(--hm-sem-palette-green-dark-2) 90%, transparent);
}
/* Existing Badge outline variant, overridden through its component mappings only within Dashboard.
Variant C has plain table text, so use its dark selected surface to keep the white done ink legible on HM's light table. */
.dashboard-production [data-slot="badge"][data-status] {
--hm-badge-outline-text: var(--dashboard-status-color);
--hm-badge-outline-background: var(--dashboard-work-badge-background);
--hm-badge-outline-border-color: color-mix(in srgb, var(--hm-sem-palette-black) 50%, transparent);
}
.dashboard-production [data-status="active"] { --dashboard-status-color: var(--dashboard-work-progress); }
.dashboard-production [data-status="hold"] { --dashboard-status-color: var(--dashboard-work-hold); }
.dashboard-production [data-status="done"] { --dashboard-status-color: var(--hm-dashboard-status-done); }
.dashboard-production [data-status="issue"][data-issue="true"] { --dashboard-status-color: var(--dashboard-work-issue); }
.dashboard-production [data-story-slot="work-badges"] {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
/* Story-only Variant C Compact Header (ui_viz patterns-variant-c--all). Geometry follows the reference: header 48 / padding-x 20,
title + 18 after it, status bar 40 high floating from the header top and centred at 50% + 18px, padding-x 36, gap 8, 28px controls,
1x16 dividers, bottom radius 16 with concave wings. Colours and type are HM tokens. Content join / padding stay Dashboard Layout's. */
.dashboard-production-compact [data-story-slot="compact-header"] {
position: relative;
z-index: 1;
display: flex;
flex: none;
align-items: center;
height: 48px;
padding: 0 20px;
background: var(--hm-dashboard-header-background);
color: var(--hm-dashboard-nav-text);
}
.dashboard-story-compact [data-story-slot="compact-brand"] {
display: flex;
flex: none;
align-items: center;
height: 48px;
padding-right: 18px;
}
.dashboard-story-compact [data-story-slot="compact-brand"] strong,
.dashboard-story-compact [data-story-slot="compact-context"] {
font-size: 14px;
font-weight: 700;
white-space: nowrap;
-webkit-text-stroke: var(--hm-dashboard-nav-text-stroke-width) var(--hm-dashboard-nav-text-stroke-color);
paint-order: stroke fill;
}
.dashboard-story-compact [data-story-slot="compact-context"] {
font-size: 13px;
}
.dashboard-story-compact [data-story-slot="compact-status"] {
--compact-wing: var(--hm-sem-palette-green-default);
position: absolute;
top: 0;
left: calc(50% + 18px);
transform: translateX(-50%);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 40px;
padding: 0 36px;
border-radius: 0 0 16px 16px;
background: linear-gradient(90deg, var(--hm-sem-palette-green-default) 20%, var(--hm-sem-palette-green-light-1) 50%, var(--hm-sem-palette-green-default) 80%);
filter: drop-shadow(0 2px 4px color-mix(in srgb, var(--hm-sem-palette-black) 25%, transparent));
white-space: nowrap;
}
.dashboard-story-compact [data-story-slot="compact-status"]::before,
.dashboard-story-compact [data-story-slot="compact-status"]::after {
content: "";
position: absolute;
top: 0;
width: 36px;
height: 30px;
pointer-events: none;
}
.dashboard-story-compact [data-story-slot="compact-status"]::before {
left: -35px;
border-radius: 0 20px 0 0;
box-shadow: 18px 0 0 var(--compact-wing);
}
.dashboard-story-compact [data-story-slot="compact-status"]::after {
right: -35px;
border-radius: 20px 0 0 0;
box-shadow: -18px 0 0 var(--compact-wing);
}
.dashboard-story-compact [data-story-slot="compact-divider"] {
flex: none;
width: 1px;
height: 16px;
margin: 0 4px;
background: color-mix(in srgb, var(--hm-dashboard-nav-text-secondary) 30%, transparent);
}
.dashboard-story-compact [data-story-slot="compact-status-group"] {
display: flex;
align-items: center;
gap: 8px;
}
/* Filter states follow the reference values (black 35/55%, selected Green Dark 2 90% with a Green Light 4 ring). No HM Semantic matches
them (state.active / accent differ), so the exact HM palette primitives are used. Selected wins over hover, as in the reference. */
.dashboard-story-compact [data-story-slot="compact-status"] [data-slot="button"] {
gap: 6px;
height: 28px;
padding: 0 10px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
border-color: color-mix(in srgb, var(--hm-sem-palette-black) 50%, transparent);
background: color-mix(in srgb, var(--hm-sem-palette-black) 35%, transparent);
color: color-mix(in srgb, var(--hm-sem-palette-white) 95%, transparent);
}
.dashboard-story-compact [data-story-slot="compact-status"] [data-slot="button"]:hover {
border-color: color-mix(in srgb, var(--hm-sem-palette-green-light-4) 40%, transparent);
background: color-mix(in srgb, var(--hm-sem-palette-black) 55%, transparent);
color: var(--hm-sem-palette-white);
}
.dashboard-story-compact [data-story-slot="compact-status"] [data-slot="button"][aria-pressed="true"] {
border-color: color-mix(in srgb, var(--hm-sem-palette-green-light-4) 65%, transparent);
background: color-mix(in srgb, var(--hm-sem-palette-green-dark-2) 90%, transparent);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--hm-sem-palette-green-light-4) 35%, transparent);
color: var(--hm-sem-palette-white);
}
/* Count ink shares dashboardWorkStatus with the table Badge; labels retain Reference white ink. */
.dashboard-story-compact [data-story-slot="compact-status"] [data-status] strong {
color: var(--dashboard-status-color);
font-size: 14px;
font-weight: 700;
line-height: 1;
-webkit-text-stroke: 1px color-mix(in srgb, var(--hm-sem-palette-black) 22%, transparent);
paint-order: stroke fill;
}
/* Issue is separately divided and has its own Default/Hover/Selected surface in Variant C. */
.dashboard-story-compact [data-story-slot="compact-status"] [data-status="issue"] {
background: color-mix(in srgb, var(--hm-sem-palette-green-light-3) 28%, transparent);
border-color: color-mix(in srgb, var(--hm-sem-palette-green-light-4) 45%, transparent);
color: var(--hm-sem-palette-white);
}
.dashboard-story-compact [data-story-slot="compact-status"] [data-status="issue"]:hover {
background: color-mix(in srgb, var(--hm-sem-palette-green-light-3) 45%, transparent);
border-color: color-mix(in srgb, var(--hm-sem-palette-green-light-4) 65%, transparent);
}
.dashboard-story-compact [data-story-slot="compact-status"] [data-status="issue"][aria-pressed="true"] {
background: color-mix(in srgb, var(--hm-sem-action-destructive) 25%, transparent);
border-color: color-mix(in srgb, var(--hm-sem-action-destructive) 70%, transparent);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--hm-sem-action-destructive) 40%, transparent);
}
.dashboard-story-compact [data-story-slot="compact-status"] [data-slot="button"]:focus-visible {
outline: var(--hm-dashboard-nav-focus-outline);
}
/* Compact geometry is shared by all viewports. Dashboard's native scroll frame preserves its minimum width. */
+422
View File
@@ -0,0 +1,422 @@
import { Fragment, useState, type MouseEvent, type ReactNode } from "react"
import { ChartColumnIcon, ChevronRightIcon, LayoutGridIcon, MoreHorizontalIcon, SettingsIcon, type LucideIcon } from "lucide-react"
import {
Avatar,
AvatarFallback,
AvatarImage,
Badge,
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
Button,
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
DashboardContent,
DashboardLayout,
DashboardHeader,
DashboardSidebar,
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
DashboardMain,
DataTable,
DataTableColumnHeader,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
FilterBar,
FilterBarActions,
FilterBarSearch,
PageHeader,
PageHeaderActions,
PageHeaderContent,
PageHeaderDescription,
PageHeaderTitle,
Progress,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Skeleton,
dataTableSelectColumn,
useDataTable,
type DataTableColumnDef,
} from "@hm-design/ui"
import { formatDate } from "./sample-users"
import { countDashboardWorkFilter, dashboardWorks, dashboardWorkFilters, dashboardWorkStatus, dashboardWorkStyle, matchesDashboardWorkStatus, type DashboardWork, type DashboardWorkFilter } from "./dashboard-work-status"
import "./dashboard-composition.css"
// Dashboard Mode: internal navigation, Page Header, KPI cards, Filter Bar and Data Table.
// Menu and profile data are local to this Story. See docs/patterns/dashboard-layout.md.
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>')
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 }: { trigger: ReactNode; side?: "top" | "right" | "bottom" | "left"; align?: "start" | "center" | "end"; modal?: boolean; matchTrigger?: boolean }) {
return (
<DropdownMenu modal={modal}>
<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>
)
}
type Mode = "compact" | "sidebar" | "header-sidebar"
type StatusFilter = DashboardWorkFilter
const statusFilters = dashboardWorkFilters.map((id) => ({ id, ...dashboardWorkStatus[id] }))
// Variant C (ui_viz patterns-variant-c--all) composition: a 48px Compact Header with the title on the left and a floating 40px status
// filter bar centred on the header, followed immediately by the content surface. No GNB, LNB or profile. The structure mirrors the
// reference (header > brand + status bar [context | 전체 | group]); colours, type and controls are HM.
function CompactHeader({ data, status, onStatusChange }: { data: DashboardWork[]; status: StatusFilter; onStatusChange: (status: StatusFilter) => void }) {
const count = (id: StatusFilter) => countDashboardWorkFilter(data, id)
const filterButton = (filter: (typeof statusFilters)[number]) => (
<Button key={filter.id} type="button" variant="ghost" size="sm" data-filter={filter.id} data-status={filter.id} data-issue={filter.id === "issue" ? "true" : undefined} style={dashboardWorkStyle(filter.id)} aria-pressed={status === filter.id} aria-controls="dashboard-compact-results" onClick={() => onStatusChange(filter.id)}>
{filter.label} <strong>{count(filter.id)}</strong>건
</Button>
)
const [all, ...rest] = statusFilters
const workStates = rest.filter((filter) => filter.id !== "issue")
const issue = rest.find((filter) => filter.id === "issue")!
return (
<DashboardHeader data-story-slot="compact-header">
<div data-story-slot="compact-brand">
<strong>프로젝트 대시보드</strong>
</div>
<div data-story-slot="compact-status" role="group" aria-label="업무 상태 필터">
<span data-story-slot="compact-context">업무</span>
<span data-story-slot="compact-divider" aria-hidden="true" />
{filterButton(all)}
<span data-story-slot="compact-divider" aria-hidden="true" />
<div data-story-slot="compact-status-group">{workStates.map(filterButton)}</div>
<span data-story-slot="compact-divider" aria-hidden="true" />
{filterButton(issue)}
</div>
</DashboardHeader>
)
}
// Dashboard internal menu; kept at every viewport width.
type LocalNode = { id: string; label: string; icon?: LucideIcon; children?: LocalNode[] }
const localNav = (thirdLevel?: boolean): LocalNode[] => [
{ id: "overview", label: "개요", icon: LayoutGridIcon },
{
id: "projects",
label: "업무 관리",
children: [
thirdLevel
? {
id: "projects/tasks",
label: "업무 현황",
children: [
{ id: "projects/tasks/all", label: "전체 업무" },
{ id: "projects/tasks/mine", label: "담당 업무" },
{ id: "projects/tasks/delayed", label: "지연 업무" },
],
}
: { id: "projects/tasks", label: "업무 현황" },
{ id: "projects/schedule", label: "일정" },
],
},
{ id: "reports", label: "리포트", icon: ChartColumnIcon },
{ id: "settings", label: "설정", icon: SettingsIcon },
]
const statusBadge = (status: Exclude<StatusFilter, "all">) => (
<Badge variant="outline" data-status={status} data-issue={status === "issue" ? "true" : undefined} data-work-status={status} style={dashboardWorkStyle(status)}>{dashboardWorkStatus[status].label}</Badge>
)
const columns: DataTableColumnDef<DashboardWork>[] = [
dataTableSelectColumn<DashboardWork>(),
{ accessorKey: "name", header: ({ column }) => <DataTableColumnHeader column={column} title="업무명" /> },
{ accessorKey: "department", header: "부서" },
{ accessorKey: "status", header: "상태", cell: ({ row }) => <span data-story-slot="work-badges">{statusBadge(row.original.status)}{row.original.issue ? statusBadge("issue") : null}</span>, filterFn: (row, _columnId, value: StatusFilter) => matchesDashboardWorkStatus(row.original, value), enableSorting: false },
{
accessorKey: "createdAt",
header: ({ column }) => <DataTableColumnHeader column={column} title="등록일" />,
cell: ({ row }) => formatDate(row.original.createdAt),
},
]
function TaskList({ data, loading, compact, selectedStatus = "all", onReset }: { data: DashboardWork[]; loading?: boolean; compact?: boolean; selectedStatus?: StatusFilter; onReset?: () => void }) {
const table = useDataTable({ columns, data, getRowId: (row) => row.id })
const [search, setSearch] = useState("")
const [status, setStatus] = useState("all")
const total = table.getFilteredRowModel().rows.length
return (
<Card>
<CardHeader>
<CardTitle>업무 목록</CardTitle>
<CardDescription role="status">{dashboardWorkStatus[compact ? selectedStatus : status as StatusFilter].label} 업무 {total}건</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4">
<FilterBar>
<FilterBarSearch
value={search}
onValueChange={(value) => {
setSearch(value)
table.setGlobalFilter(value || undefined)
}}
placeholder="업무명·부서 검색"
/>
{compact ? null : <Select
value={status}
onValueChange={(value) => {
setStatus(value)
table.getColumn("status")?.setFilterValue(value === "all" ? undefined : value)
}}
>
<SelectTrigger size="medium" aria-label="상태">
<SelectValue placeholder="상태" />
</SelectTrigger>
<SelectContent>
{statusFilters.map((filter) => <SelectItem key={filter.id} value={filter.id}>{filter.label}</SelectItem>)}
</SelectContent>
</Select>}
<FilterBarActions>
<Button
type="button"
variant="cancel"
appearance="solid"
onClick={() => {
setSearch("")
setStatus("all")
table.resetGlobalFilter(true)
table.resetColumnFilters(true)
onReset?.()
}}
>
초기화
</Button>
</FilterBarActions>
</FilterBar>
<DataTable table={table} loading={loading} emptyMessage="조건에 맞는 업무가 없습니다." />
</div>
</CardContent>
</Card>
)
}
function Kpis({ data, loading }: { data: DashboardWork[]; loading?: boolean }) {
const kpis = statusFilters.map(({ id, label }) => {
const value = countDashboardWorkFilter(data, id)
const progress = data.length ? Math.round(value / data.length * 100) : 0
return { id, label, value, progress }
})
return (
<div className="grid gap-6" style={{ gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 200px), 1fr))" }}>
{kpis.map((kpi) => (
<Card key={kpi.label}>
<CardHeader>
<CardDescription>{kpi.label}</CardDescription>
{loading ? null : (
<CardAction>
{kpi.id === "all" ? <Badge variant="secondary">전체</Badge> : statusBadge(kpi.id)}
</CardAction>
)}
</CardHeader>
<CardContent>
{loading ? (
<div className="grid gap-3">
<Skeleton style={{ height: 36, width: 80 }} />
<Skeleton style={{ height: 8 }} />
</div>
) : (
<div className="grid gap-3">
<p style={{ margin: 0, fontSize: 28, lineHeight: "36px", fontWeight: 700 }}>{kpi.value}</p>
<Progress value={kpi.progress} aria-label={`${kpi.label} 비율`} />
</div>
)}
</CardContent>
</Card>
))}
</div>
)
}
export function DashboardScreen({
mode = "compact",
data = dashboardWorks,
loading,
longTitle,
wide,
thirdLevel,
}: {
mode?: Mode
data?: DashboardWork[]
loading?: boolean
longTitle?: boolean
wide?: boolean
thirdLevel?: boolean
}) {
const [current, setCurrent] = useState(thirdLevel ? "projects/tasks/all" : "projects/tasks")
const [compactStatus, setCompactStatus] = useState<StatusFilter>("all")
const compact = mode === "compact"
const resultData = compact ? data.filter((row) => matchesDashboardWorkStatus(row, compactStatus)) : data
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({})
const go = (id: string) => (event: MouseEvent) => {
event.preventDefault()
setCurrent(id)
}
const isCurrent = (id: string) => current === id || current.startsWith(`${id}/`)
const nav = localNav(thirdLevel)
const dashboardMenu = (nodes: LocalNode[]): ReactNode =>
nodes.map((node) =>
node.children ? (
<Collapsible
key={node.id}
data-current={isCurrent(node.id) ? "" : undefined}
open={openGroups[node.id] ?? true}
onOpenChange={(value) => setOpenGroups((state) => ({ ...state, [node.id]: value }))}
>
<CollapsibleTrigger asChild><Button variant="ghost" type="button"><ChevronRightIcon aria-hidden="true" />{node.label}</Button></CollapsibleTrigger>
<CollapsibleContent className="pl-3">{dashboardMenu(node.children)}</CollapsibleContent>
</Collapsible>
) : (
<Button key={node.id} variant="ghost" asChild><a href={`#${node.id}`} aria-current={current === node.id ? "page" : undefined} onClick={go(node.id)}>
{node.icon ? <node.icon aria-hidden="true" /> : null}
{node.label}
</a></Button>
)
)
const title = longTitle ? "전사 프로젝트 포트폴리오의 진행 현황과 담당 부서별 업무 상세 현황" : "업무 현황"
const sidebar = (
<DashboardSidebar>
<strong>프로젝트 대시보드</strong>
<nav aria-label="Dashboard 업무 메뉴">{dashboardMenu(nav)}</nav>
<ProfileMenu side="right" align="end" trigger={
<Button type="button" variant="ghost" aria-label={`${profileData.name} 사용자 메뉴`}>
<ProfileAvatar />{profileData.name}
</Button>
} />
</DashboardSidebar>
)
// Variant C content: heading, then the result card (Page Header's own 20px bottom spacing; no breadcrumb, actions or KPI cards in the reference).
const compactMain = (
<DashboardMain id="dashboard-compact-results">
<PageHeader>
<PageHeaderContent>
<PageHeaderTitle>업무 현황을 한눈에 확인하세요</PageHeaderTitle>
</PageHeaderContent>
</PageHeader>
<TaskList data={resultData} loading={loading} compact selectedStatus={compactStatus} onReset={() => setCompactStatus("all")} />
</DashboardMain>
)
const main = compact ? compactMain : (
<DashboardMain>
<PageHeader>
<PageHeaderContent>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="#dashboard">홈</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink href="#projects">프로젝트</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>업무 현황</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<PageHeaderTitle>{title}</PageHeaderTitle>
<PageHeaderDescription>프로젝트별 업무 진행 상황을 확인하고 조회합니다.</PageHeaderDescription>
</PageHeaderContent>
<PageHeaderActions>
<Button type="button" variant="cancel" appearance="solid">
다운로드
</Button>
<Button type="button" variant="primary">
신규 등록
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="primary" appearance="outline" size="icon" aria-label="더보기">
<MoreHorizontalIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>일괄 수정</DropdownMenuItem>
<DropdownMenuItem>보관</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</PageHeaderActions>
</PageHeader>
<DashboardContent>
<Kpis data={data} loading={loading} />
<TaskList data={resultData} loading={loading} />
{wide ? (
<Card>
<CardHeader>
<CardTitle>넓은 내용</CardTitle>
<CardDescription>페이지를 밀지 않고 카드 안에서 가로 스크롤합니다.</CardDescription>
</CardHeader>
<CardContent>
<div style={{ overflowX: "auto" }}>
<div style={{ width: 1600, height: 80, background: "var(--hm-sem-bg-content)" }} />
</div>
</CardContent>
</Card>
) : null}
</DashboardContent>
</DashboardMain>
)
return (
<DashboardLayout
className={compact ? "dashboard-production dashboard-story-compact" : "dashboard-production"}
header={compact ? <CompactHeader data={data} status={compactStatus} onStatusChange={setCompactStatus} /> : mode === "header-sidebar" ? <DashboardHeader><strong>업무 현황</strong></DashboardHeader> : undefined}
sidebar={compact ? undefined : sidebar}
>
{main}
</DashboardLayout>
)
}
+178
View File
@@ -0,0 +1,178 @@
@import "./dashboard-status-contract.css";
/* Runtime-neutral status contract → Story mapping. HM has no exact public status semantics for these three inks. */
.dashboard-story {
--dashboard-work-progress: var(--hm-dashboard-status-active);
--dashboard-work-hold: var(--hm-dashboard-status-hold);
--dashboard-work-issue: var(--hm-dashboard-status-issue);
--dashboard-work-badge-background: color-mix(in srgb, var(--hm-sem-palette-green-dark-2) 90%, transparent);
}
/* Existing Badge outline variant, overridden through its component mappings only within Dashboard.
Variant C has plain table text, so use its dark selected surface to keep the white done ink legible on HM's light table. */
.dashboard-story [data-slot="badge"][data-status] {
--hm-badge-outline-text: var(--dashboard-status-color);
--hm-badge-outline-background: var(--dashboard-work-badge-background);
--hm-badge-outline-border-color: color-mix(in srgb, var(--hm-sem-palette-black) 50%, transparent);
}
.dashboard-story [data-status="active"] { --dashboard-status-color: var(--dashboard-work-progress); }
.dashboard-story [data-status="hold"] { --dashboard-status-color: var(--dashboard-work-hold); }
.dashboard-story [data-status="done"] { --dashboard-status-color: var(--hm-dashboard-status-done); }
.dashboard-story [data-status="issue"][data-issue="true"] { --dashboard-status-color: var(--dashboard-work-issue); }
.dashboard-story [data-story-slot="work-badges"] {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
/* Story-only Variant C Compact Header (ui_viz patterns-variant-c--all). Geometry follows the reference: header 48 / padding-x 20,
title + 18 after it, status bar 40 high floating from the header top and centred at 50% + 18px, padding-x 36, gap 8, 28px controls,
1x16 dividers, bottom radius 16 with concave wings. Colours and type are HM tokens. Content join / padding stay Dashboard Layout's. */
.dashboard-story-compact [data-story-slot="compact-header"] {
position: relative;
z-index: 1;
display: flex;
flex: none;
align-items: center;
height: 48px;
padding: 0 20px;
background: var(--hm-dashboard-header-background);
color: var(--hm-dashboard-nav-text);
}
.dashboard-story-compact [data-story-slot="compact-brand"] {
display: flex;
flex: none;
align-items: center;
height: 48px;
padding-right: 18px;
}
.dashboard-story-compact [data-story-slot="compact-brand"] strong,
.dashboard-story-compact [data-story-slot="compact-context"] {
font-size: 14px;
font-weight: 700;
white-space: nowrap;
-webkit-text-stroke: var(--hm-dashboard-nav-text-stroke-width) var(--hm-dashboard-nav-text-stroke-color);
paint-order: stroke fill;
}
.dashboard-story-compact [data-story-slot="compact-context"] {
font-size: 13px;
}
.dashboard-story-compact [data-story-slot="compact-status"] {
--compact-wing: var(--hm-sem-palette-green-default);
position: absolute;
top: 0;
left: calc(50% + 18px);
transform: translateX(-50%);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 40px;
padding: 0 36px;
border-radius: 0 0 16px 16px;
background: linear-gradient(90deg, var(--hm-sem-palette-green-default) 20%, var(--hm-sem-palette-green-light-1) 50%, var(--hm-sem-palette-green-default) 80%);
filter: drop-shadow(0 2px 4px color-mix(in srgb, var(--hm-sem-palette-black) 25%, transparent));
white-space: nowrap;
}
.dashboard-story-compact [data-story-slot="compact-status"]::before,
.dashboard-story-compact [data-story-slot="compact-status"]::after {
content: "";
position: absolute;
top: 0;
width: 36px;
height: 30px;
pointer-events: none;
}
.dashboard-story-compact [data-story-slot="compact-status"]::before {
left: -35px;
border-radius: 0 20px 0 0;
box-shadow: 18px 0 0 var(--compact-wing);
}
.dashboard-story-compact [data-story-slot="compact-status"]::after {
right: -35px;
border-radius: 20px 0 0 0;
box-shadow: -18px 0 0 var(--compact-wing);
}
.dashboard-story-compact [data-story-slot="compact-divider"] {
flex: none;
width: 1px;
height: 16px;
margin: 0 4px;
background: color-mix(in srgb, var(--hm-dashboard-nav-text-secondary) 30%, transparent);
}
.dashboard-story-compact [data-story-slot="compact-status-group"] {
display: flex;
align-items: center;
gap: 8px;
}
/* Filter states follow the reference values (black 35/55%, selected Green Dark 2 90% with a Green Light 4 ring). No HM Semantic matches
them (state.active / accent differ), so the exact HM palette primitives are used. Selected wins over hover, as in the reference. */
.dashboard-story-compact [data-story-slot="compact-status"] [data-slot="button"] {
gap: 6px;
height: 28px;
padding: 0 10px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
border-color: color-mix(in srgb, var(--hm-sem-palette-black) 50%, transparent);
background: color-mix(in srgb, var(--hm-sem-palette-black) 35%, transparent);
color: color-mix(in srgb, var(--hm-sem-palette-white) 95%, transparent);
}
.dashboard-story-compact [data-story-slot="compact-status"] [data-slot="button"]:hover {
border-color: color-mix(in srgb, var(--hm-sem-palette-green-light-4) 40%, transparent);
background: color-mix(in srgb, var(--hm-sem-palette-black) 55%, transparent);
color: var(--hm-sem-palette-white);
}
.dashboard-story-compact [data-story-slot="compact-status"] [data-slot="button"][aria-pressed="true"] {
border-color: color-mix(in srgb, var(--hm-sem-palette-green-light-4) 65%, transparent);
background: color-mix(in srgb, var(--hm-sem-palette-green-dark-2) 90%, transparent);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--hm-sem-palette-green-light-4) 35%, transparent);
color: var(--hm-sem-palette-white);
}
/* Count ink shares dashboardWorkStatus with the table Badge; labels retain Reference white ink. */
.dashboard-story-compact [data-story-slot="compact-status"] [data-status] strong {
color: var(--dashboard-status-color);
font-size: 14px;
font-weight: 700;
line-height: 1;
-webkit-text-stroke: 1px color-mix(in srgb, var(--hm-sem-palette-black) 22%, transparent);
paint-order: stroke fill;
}
/* Issue is separately divided and has its own Default/Hover/Selected surface in Variant C. */
.dashboard-story-compact [data-story-slot="compact-status"] [data-status="issue"] {
background: color-mix(in srgb, var(--hm-sem-palette-green-light-3) 28%, transparent);
border-color: color-mix(in srgb, var(--hm-sem-palette-green-light-4) 45%, transparent);
color: var(--hm-sem-palette-white);
}
.dashboard-story-compact [data-story-slot="compact-status"] [data-status="issue"]:hover {
background: color-mix(in srgb, var(--hm-sem-palette-green-light-3) 45%, transparent);
border-color: color-mix(in srgb, var(--hm-sem-palette-green-light-4) 65%, transparent);
}
.dashboard-story-compact [data-story-slot="compact-status"] [data-status="issue"][aria-pressed="true"] {
background: color-mix(in srgb, var(--hm-sem-action-destructive) 25%, transparent);
border-color: color-mix(in srgb, var(--hm-sem-action-destructive) 70%, transparent);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--hm-sem-action-destructive) 40%, transparent);
}
.dashboard-story-compact [data-story-slot="compact-status"] [data-slot="button"]:focus-visible {
outline: var(--hm-dashboard-nav-focus-outline);
}
/* Compact geometry is shared by all viewports. Dashboard's native scroll frame preserves its minimum width. */
+473
View File
@@ -0,0 +1,473 @@
import { Fragment, useState, type CSSProperties, type MouseEvent, type ReactNode } from "react"
import { DashboardScreen } from "./dashboard-composition"
import type { Meta, StoryObj } from "@storybook/react-vite"
import { ChartColumnIcon, ChevronRightIcon, LayoutGridIcon, MoreHorizontalIcon, SettingsIcon, type LucideIcon } from "lucide-react"
import {
Avatar,
AvatarFallback,
AvatarImage,
Badge,
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
Button,
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
DashboardContent,
DashboardLayout,
DashboardHeader,
DashboardSidebar,
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
DashboardMain,
DataTable,
DataTableColumnHeader,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
FilterBar,
FilterBarActions,
FilterBarSearch,
PageHeader,
PageHeaderActions,
PageHeaderContent,
PageHeaderDescription,
PageHeaderTitle,
Progress,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Skeleton,
dataTableSelectColumn,
useDataTable,
type DataTableColumnDef,
} from "@hm-design/ui"
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
import { formatDate } from "./sample-users"
import { countDashboardWorkFilter, dashboardWorks, dashboardWorkFilters, dashboardWorkStatus, dashboardWorkStyle, matchesDashboardWorkStatus, type DashboardWork, type DashboardWorkFilter } from "./dashboard-work-status"
import "./dashboard-layout.stories.css"
// Dashboard Mode: internal navigation, Page Header, KPI cards, Filter Bar and Data Table.
// Menu and profile data are local to this Story. See docs/patterns/dashboard-layout.md.
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>')
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 }: { trigger: ReactNode; side?: "top" | "right" | "bottom" | "left"; align?: "start" | "center" | "end"; modal?: boolean; matchTrigger?: boolean }) {
return (
<DropdownMenu modal={modal}>
<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>
)
}
type Mode = "compact" | "sidebar" | "header-sidebar"
type StatusFilter = DashboardWorkFilter
const statusFilters = dashboardWorkFilters.map((id) => ({ id, ...dashboardWorkStatus[id] }))
// Variant C (ui_viz patterns-variant-c--all) composition: a 48px Compact Header with the title on the left and a floating 40px status
// filter bar centred on the header, followed immediately by the content surface. No GNB, LNB or profile. The structure mirrors the
// reference (header > brand + status bar [context | 전체 | group]); colours, type and controls are HM.
function CompactHeader({ data, status, onStatusChange }: { data: DashboardWork[]; status: StatusFilter; onStatusChange: (status: StatusFilter) => void }) {
const count = (id: StatusFilter) => countDashboardWorkFilter(data, id)
const filterButton = (filter: (typeof statusFilters)[number]) => (
<Button key={filter.id} type="button" variant="ghost" size="sm" data-filter={filter.id} data-status={filter.id} data-issue={filter.id === "issue" ? "true" : undefined} style={dashboardWorkStyle(filter.id)} aria-pressed={status === filter.id} aria-controls="dashboard-compact-results" onClick={() => onStatusChange(filter.id)}>
{filter.label} <strong>{count(filter.id)}</strong>건
</Button>
)
const [all, ...rest] = statusFilters
const workStates = rest.filter((filter) => filter.id !== "issue")
const issue = rest.find((filter) => filter.id === "issue")!
return (
<DashboardHeader data-story-slot="compact-header">
<div data-story-slot="compact-brand">
<strong>프로젝트 대시보드</strong>
</div>
<div data-story-slot="compact-status" role="group" aria-label="업무 상태 필터">
<span data-story-slot="compact-context">업무</span>
<span data-story-slot="compact-divider" aria-hidden="true" />
{filterButton(all)}
<span data-story-slot="compact-divider" aria-hidden="true" />
<div data-story-slot="compact-status-group">{workStates.map(filterButton)}</div>
<span data-story-slot="compact-divider" aria-hidden="true" />
{filterButton(issue)}
</div>
</DashboardHeader>
)
}
// Dashboard internal menu; kept at every viewport width.
type LocalNode = { id: string; label: string; icon?: LucideIcon; children?: LocalNode[] }
const localNav = (thirdLevel?: boolean): LocalNode[] => [
{ id: "overview", label: "개요", icon: LayoutGridIcon },
{
id: "projects",
label: "업무 관리",
children: [
thirdLevel
? {
id: "projects/tasks",
label: "업무 현황",
children: [
{ id: "projects/tasks/all", label: "전체 업무" },
{ id: "projects/tasks/mine", label: "담당 업무" },
{ id: "projects/tasks/delayed", label: "지연 업무" },
],
}
: { id: "projects/tasks", label: "업무 현황" },
{ id: "projects/schedule", label: "일정" },
],
},
{ id: "reports", label: "리포트", icon: ChartColumnIcon },
{ id: "settings", label: "설정", icon: SettingsIcon },
]
const statusBadge = (status: Exclude<StatusFilter, "all">) => (
<Badge variant="outline" data-status={status} data-issue={status === "issue" ? "true" : undefined} data-work-status={status} style={dashboardWorkStyle(status)}>{dashboardWorkStatus[status].label}</Badge>
)
const columns: DataTableColumnDef<DashboardWork>[] = [
dataTableSelectColumn<DashboardWork>(),
{ accessorKey: "name", header: ({ column }) => <DataTableColumnHeader column={column} title="업무명" /> },
{ accessorKey: "department", header: "부서" },
{ accessorKey: "status", header: "상태", cell: ({ row }) => <span data-story-slot="work-badges">{statusBadge(row.original.status)}{row.original.issue ? statusBadge("issue") : null}</span>, filterFn: (row, _columnId, value: StatusFilter) => matchesDashboardWorkStatus(row.original, value), enableSorting: false },
{
accessorKey: "createdAt",
header: ({ column }) => <DataTableColumnHeader column={column} title="등록일" />,
cell: ({ row }) => formatDate(row.original.createdAt),
},
]
function TaskList({ data, loading, compact, selectedStatus = "all", onReset }: { data: DashboardWork[]; loading?: boolean; compact?: boolean; selectedStatus?: StatusFilter; onReset?: () => void }) {
const table = useDataTable({ columns, data, getRowId: (row) => row.id })
const [search, setSearch] = useState("")
const [status, setStatus] = useState("all")
const total = table.getFilteredRowModel().rows.length
return (
<Card>
<CardHeader>
<CardTitle>업무 목록</CardTitle>
<CardDescription role="status">{dashboardWorkStatus[compact ? selectedStatus : status as StatusFilter].label} 업무 {total}건</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4">
<FilterBar>
<FilterBarSearch
value={search}
onValueChange={(value) => {
setSearch(value)
table.setGlobalFilter(value || undefined)
}}
placeholder="업무명·부서 검색"
/>
{compact ? null : <Select
value={status}
onValueChange={(value) => {
setStatus(value)
table.getColumn("status")?.setFilterValue(value === "all" ? undefined : value)
}}
>
<SelectTrigger size="medium" aria-label="상태">
<SelectValue placeholder="상태" />
</SelectTrigger>
<SelectContent>
{statusFilters.map((filter) => <SelectItem key={filter.id} value={filter.id}>{filter.label}</SelectItem>)}
</SelectContent>
</Select>}
<FilterBarActions>
<Button
type="button"
variant="cancel"
appearance="solid"
onClick={() => {
setSearch("")
setStatus("all")
table.resetGlobalFilter(true)
table.resetColumnFilters(true)
onReset?.()
}}
>
초기화
</Button>
</FilterBarActions>
</FilterBar>
<DataTable table={table} loading={loading} emptyMessage="조건에 맞는 업무가 없습니다." />
</div>
</CardContent>
</Card>
)
}
function Kpis({ data, loading }: { data: DashboardWork[]; loading?: boolean }) {
const kpis = statusFilters.map(({ id, label }) => {
const value = countDashboardWorkFilter(data, id)
const progress = data.length ? Math.round(value / data.length * 100) : 0
return { id, label, value, progress }
})
return (
<div className="grid gap-6" style={{ gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 200px), 1fr))" }}>
{kpis.map((kpi) => (
<Card key={kpi.label}>
<CardHeader>
<CardDescription>{kpi.label}</CardDescription>
{loading ? null : (
<CardAction>
{kpi.id === "all" ? <Badge variant="secondary">전체</Badge> : statusBadge(kpi.id)}
</CardAction>
)}
</CardHeader>
<CardContent>
{loading ? (
<div className="grid gap-3">
<Skeleton style={{ height: 36, width: 80 }} />
<Skeleton style={{ height: 8 }} />
</div>
) : (
<div className="grid gap-3">
<p style={{ margin: 0, fontSize: 28, lineHeight: "36px", fontWeight: 700 }}>{kpi.value}</p>
<Progress value={kpi.progress} aria-label={`${kpi.label} 비율`} />
</div>
)}
</CardContent>
</Card>
))}
</div>
)
}
function Screen({
mode = "compact",
data = dashboardWorks,
loading,
longTitle,
wide,
thirdLevel,
}: {
mode?: Mode
data?: DashboardWork[]
loading?: boolean
longTitle?: boolean
wide?: boolean
thirdLevel?: boolean
}) {
const [current, setCurrent] = useState(thirdLevel ? "projects/tasks/all" : "projects/tasks")
const [compactStatus, setCompactStatus] = useState<StatusFilter>("all")
const compact = mode === "compact"
const resultData = compact ? data.filter((row) => matchesDashboardWorkStatus(row, compactStatus)) : data
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({})
const go = (id: string) => (event: MouseEvent) => {
event.preventDefault()
setCurrent(id)
}
const isCurrent = (id: string) => current === id || current.startsWith(`${id}/`)
const nav = localNav(thirdLevel)
const dashboardMenu = (nodes: LocalNode[]): ReactNode =>
nodes.map((node) =>
node.children ? (
<Collapsible
key={node.id}
data-current={isCurrent(node.id) ? "" : undefined}
open={openGroups[node.id] ?? true}
onOpenChange={(value) => setOpenGroups((state) => ({ ...state, [node.id]: value }))}
>
<CollapsibleTrigger asChild><Button variant="ghost" type="button"><ChevronRightIcon aria-hidden="true" />{node.label}</Button></CollapsibleTrigger>
<CollapsibleContent className="pl-3">{dashboardMenu(node.children)}</CollapsibleContent>
</Collapsible>
) : (
<Button key={node.id} variant="ghost" asChild><a href={`#${node.id}`} aria-current={current === node.id ? "page" : undefined} onClick={go(node.id)}>
{node.icon ? <node.icon aria-hidden="true" /> : null}
{node.label}
</a></Button>
)
)
const title = longTitle ? "전사 프로젝트 포트폴리오의 진행 현황과 담당 부서별 업무 상세 현황" : "업무 현황"
const sidebar = (
<DashboardSidebar>
<strong>프로젝트 대시보드</strong>
<nav aria-label="Dashboard 업무 메뉴">{dashboardMenu(nav)}</nav>
<ProfileMenu side="right" align="end" trigger={
<Button type="button" variant="ghost" aria-label={`${profileData.name} 사용자 메뉴`}>
<ProfileAvatar />{profileData.name}
</Button>
} />
</DashboardSidebar>
)
// Variant C content: heading, then the result card (Page Header's own 20px bottom spacing; no breadcrumb, actions or KPI cards in the reference).
const compactMain = (
<DashboardMain id="dashboard-compact-results">
<PageHeader>
<PageHeaderContent>
<PageHeaderTitle>업무 현황을 한눈에 확인하세요</PageHeaderTitle>
</PageHeaderContent>
</PageHeader>
<TaskList data={resultData} loading={loading} compact selectedStatus={compactStatus} onReset={() => setCompactStatus("all")} />
</DashboardMain>
)
const main = compact ? compactMain : (
<DashboardMain>
<PageHeader>
<PageHeaderContent>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="#dashboard">홈</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink href="#projects">프로젝트</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>업무 현황</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<PageHeaderTitle>{title}</PageHeaderTitle>
<PageHeaderDescription>프로젝트별 업무 진행 상황을 확인하고 조회합니다.</PageHeaderDescription>
</PageHeaderContent>
<PageHeaderActions>
<Button type="button" variant="cancel" appearance="solid">
다운로드
</Button>
<Button type="button" variant="primary">
신규 등록
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="primary" appearance="outline" size="icon" aria-label="더보기">
<MoreHorizontalIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>일괄 수정</DropdownMenuItem>
<DropdownMenuItem>보관</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</PageHeaderActions>
</PageHeader>
<DashboardContent>
<Kpis data={data} loading={loading} />
<TaskList data={resultData} loading={loading} />
{wide ? (
<Card>
<CardHeader>
<CardTitle>넓은 내용</CardTitle>
<CardDescription>페이지를 밀지 않고 카드 안에서 가로 스크롤합니다.</CardDescription>
</CardHeader>
<CardContent>
<div style={{ overflowX: "auto" }}>
<div style={{ width: 1600, height: 80, background: "var(--hm-sem-bg-content)" }} />
</div>
</CardContent>
</Card>
) : null}
</DashboardContent>
</DashboardMain>
)
return (
<DashboardLayout
className={compact ? "dashboard-story dashboard-story-compact" : "dashboard-story"}
header={compact ? <CompactHeader data={data} status={compactStatus} onStatusChange={setCompactStatus} /> : mode === "header-sidebar" ? <DashboardHeader><strong>업무 현황</strong></DashboardHeader> : undefined}
sidebar={compact ? undefined : sidebar}
>
{main}
</DashboardLayout>
)
}
const frame = (height?: number): CSSProperties | undefined => (height ? ({ "--hm-dashboard-height": `${height}px` } as CSSProperties) : undefined)
// Shared by Storybook and the standalone Vite export. Keeping the representative
// story composition here prevents the export from becoming a second hand-written UI.
export function DashboardAll() {
return (
<StoryPage>
<StoryPageHeader
kicker={STORY_KICKER.patterns}
title="Dashboard"
description="업무 화면의 전체 틀입니다. DASHBOARD 단일 모드로, 내부 48px Compact Header와 상태 필터 바 아래에 콘텐츠를 바로 잇습니다."
/>
<StorySection
title="Overview"
description="Compact Header(48px) · 상태 필터 · 콘텐츠. 최소 폭 960px을 유지하며 좁은 화면에서는 Dashboard 전체를 가로 스크롤합니다."
>
<DashboardScreen mode="compact" />
</StorySection>
</StoryPage>
)
}
// Menu label is Dashboard; the fixed id keeps the existing story IDs (patterns-layout-dashboard-layout--*) used by links and QA inventory.
const meta = { id: "patterns-layout-dashboard-layout", title: "Patterns/Layout/Dashboard", excludeStories: ["DashboardAll"], parameters: { layout: "fullscreen", controls: { disable: true } } } satisfies Meta
export default meta
type Story = StoryObj<typeof meta>
// Representative Story reproduces the ui_viz Variant C layout (48px Compact Header, centred status bar, content) with HM parts.
// Operating DASHBOARD Mode has no Sidebar; retained Sidebar examples are compatibility verification only.
// The page heading and Dashboard's native minimum-width scroll frame share the common Inner.
export const All: Story = {
name: "Dashboard",
render: () => <DashboardAll />,
}
// Keep existing Sidebar coverage and Story IDs; these are not operating references.
function SidebarVerification({ children }: { children: ReactNode }) {
return <>
<p role="note">VERIFICATION ONLY — 운영 사용 금지</p>
{children}
</>
}
export const WithSidebar: Story = { tags: ["verification"], name: "VERIFICATION ONLY — 운영 사용 금지 · Dashboard Header + Sidebar", render: () => <SidebarVerification><Screen mode="header-sidebar" /></SidebarVerification> }
export const ThirdLevel: Story = { tags: ["verification"], name: "VERIFICATION ONLY — 운영 사용 금지 · Third Level", render: () => <SidebarVerification><Screen mode="sidebar" thirdLevel /></SidebarVerification> }
export const SidebarOnly: Story = { tags: ["verification"], name: "VERIFICATION ONLY — 운영 사용 금지 · Dashboard Sidebar", render: () => <SidebarVerification><Screen mode="sidebar" /></SidebarVerification> }
export const LongContent: Story = { tags: ["verification"], name: "VERIFICATION ONLY — 운영 사용 금지 · Long Content", render: () => <SidebarVerification><Screen mode="sidebar" longTitle wide /></SidebarVerification> }
export const Loading: Story = { tags: ["verification"], render: () => <Screen loading /> }
export const Empty: Story = { tags: ["verification"], render: () => <Screen data={[]} /> }
export const Framed: Story = { tags: ["verification"], name: "VERIFICATION ONLY — 운영 사용 금지 · Framed", render: () => <SidebarVerification><div style={frame(640)}><Screen mode="sidebar" /></div></SidebarVerification> }
@@ -0,0 +1,2 @@
/* Compatibility path for existing Storybook and repository-relative HTML imports. */
@import "../../packages/ui/src/patterns/dashboard-status/dashboard-status.css";
+56
View File
@@ -0,0 +1,56 @@
import type { CSSProperties } from "react"
import {
countDashboardFilter,
dashboardFilterDefinitions,
dashboardFilterIds,
dashboardStatusDefinitions,
matchesDashboardFilter,
type DashboardFilterId,
type DashboardStatusId,
} from "@hm-design/ui"
// Story-local work data. User account states in sample-users.ts are a different domain.
// Variant C treats issue as an independent flag: a held task can also have an issue.
export type DashboardWorkStatus = DashboardStatusId
export type DashboardWorkFilter = DashboardFilterId
export type DashboardWork = {
id: string
name: string
department: string
status: DashboardWorkStatus
issue?: boolean
createdAt: string
}
export const dashboardWorkStatus = {
all: { label: dashboardFilterDefinitions.all.label, color: "var(--hm-sem-palette-yellow-accent)" },
active: { label: dashboardStatusDefinitions.active.label, color: "var(--dashboard-work-progress)" },
hold: { label: dashboardStatusDefinitions.hold.label, color: "var(--dashboard-work-hold)" },
done: { label: dashboardStatusDefinitions.done.label, color: "var(--hm-dashboard-status-done)" },
issue: { label: dashboardFilterDefinitions.issue.label, color: "var(--dashboard-work-issue)" },
} satisfies Record<DashboardWorkFilter, { label: string; color: string }>
export const dashboardWorkFilters = dashboardFilterIds
// Both filter counts and the existing HM Badge consume this component mapping.
export const dashboardWorkStyle = (status: DashboardWorkFilter): CSSProperties =>
({ "--dashboard-status-color": dashboardWorkStatus[status].color } as CSSProperties)
export const matchesDashboardWorkStatus = (work: DashboardWork, status: DashboardWorkFilter) =>
matchesDashboardFilter(work, status)
export const countDashboardWorkFilter = (works: readonly DashboardWork[], filter: DashboardWorkFilter) => countDashboardFilter(works, filter)
const names = ["대시보드 화면 검토", "업무 자료 정리", "현장 점검 보고", "설계 의견 반영", "품질 검토", "일정 확인", "도면 검수", "안전 점검", "회의록 작성", "최종 보고서 검토"]
const departments = ["설계팀", "시공팀", "안전관리팀", "품질팀", "경영지원팀"]
const statuses: DashboardWorkStatus[] = ["hold", "active", "done", "active"]
// Keep pagination coverage without reinterpreting any shared user fixture or public enum.
export const dashboardWorks: DashboardWork[] = Array.from({ length: 47 }, (_, i) => ({
id: `T-${String(i + 1).padStart(3, "0")}`,
name: `${names[i % names.length]}${i >= names.length ? ` ${Math.floor(i / names.length) + 1}` : ""}`,
department: departments[(i * 3) % departments.length],
status: statuses[i % statuses.length],
issue: i % 11 === 0,
createdAt: new Date(2026, 9, 1 + (i % 28)).toISOString(),
}))
+207
View File
@@ -0,0 +1,207 @@
import { useMemo, useState } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite"
import {
Badge,
Button,
Combobox,
DataTable,
DataTableColumnHeader,
DataTableViewOptions,
DatePicker,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
FilterBar,
FilterBarActions,
FilterBarSearch,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
dataTableSelectColumn,
toast,
useDataTable,
type DataTableColumnDef,
} from "@hm-design/ui"
import { MoreHorizontalIcon } from "lucide-react"
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
import { formatDate, sameDay, sampleUsers, statusLabel, type SampleUser } from "./sample-users"
// Data Table is a pattern: the HM Table primitive driven by TanStack Table v9 (useDataTable). Columns and data stay referentially stable
// (module scope / useMemo). Row actions use the HM Dropdown Menu and report through Sonner `toast` (Story composition only).
const statusBadge = (status: SampleUser["status"]) => (
<Badge variant={status === "active" ? "default" : status === "pending" ? "secondary" : "outline"}>{statusLabel[status]}</Badge>
)
const columnLabels = { name: "이름", department: "부서", status: "상태", createdAt: "등록일" }
const columns: DataTableColumnDef<SampleUser>[] = [
dataTableSelectColumn<SampleUser>(),
{ accessorKey: "name", header: ({ column }) => <DataTableColumnHeader column={column} title="이름" /> },
{ accessorKey: "department", header: "부서" },
{
accessorKey: "status",
header: "상태",
cell: ({ row }) => statusBadge(row.original.status),
filterFn: "equalsString",
enableSorting: false,
},
{
accessorKey: "createdAt",
header: ({ column }) => <DataTableColumnHeader column={column} title="등록일" />,
cell: ({ row }) => formatDate(row.original.createdAt),
filterFn: (row, columnId, value: Date) => sameDay(row.getValue<string>(columnId), value),
},
{
id: "actions",
enableHiding: false,
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="primary" appearance="outline" size="icon-sm" aria-label={`${row.original.name} 작업`}>
<MoreHorizontalIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => toast(`${row.original.name} 보기`)}>보기</DropdownMenuItem>
<DropdownMenuItem onSelect={() => toast(`${row.original.name} 수정`)}>수정</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onSelect={() => toast.error(`${row.original.name} 삭제 요청`)}>
삭제
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
},
]
function Basic({ data = sampleUsers, loading }: { data?: SampleUser[]; loading?: boolean }) {
const table = useDataTable({ columns, data, getRowId: (row) => row.id })
return (
<div className="grid w-full gap-3">
<div className="flex justify-end">
<DataTableViewOptions table={table} labels={columnLabels} />
</div>
<DataTable table={table} loading={loading} />
</div>
)
}
const statusOptions = [
{ value: "active", label: "활성" },
{ value: "pending", label: "대기" },
{ value: "inactive", label: "비활성" },
]
const departmentOptions = [...new Set(sampleUsers.map((u) => u.department))].map((d) => ({ value: d, label: d }))
// Filter Bar → filter state → Data Table: search is the TanStack global filter, status / date are column filters.
function WithFilterBar() {
const data = useMemo(() => sampleUsers, [])
const table = useDataTable({ columns, data, getRowId: (row) => row.id })
const [search, setSearch] = useState("")
const [status, setStatus] = useState("all")
const [date, setDate] = useState<Date | undefined>()
const [department, setDepartment] = useState<string | undefined>()
const setSearchValue = (value: string) => {
setSearch(value)
table.setGlobalFilter(value || undefined)
}
const reset = () => {
setSearch("")
setStatus("all")
setDate(undefined)
setDepartment(undefined)
table.resetGlobalFilter(true)
table.resetColumnFilters(true)
}
return (
<div className="grid w-full gap-4">
<FilterBar>
<FilterBarSearch value={search} onValueChange={setSearchValue} placeholder="이름·부서 검색" />
<Select
value={status}
onValueChange={(value) => {
setStatus(value)
table.getColumn("status")?.setFilterValue(value === "all" ? undefined : value)
}}
>
<SelectTrigger size="medium" aria-label="상태">
<SelectValue placeholder="상태" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">전체 상태</SelectItem>
{statusOptions.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Combobox
controlled
size="medium"
aria-label="부서"
options={departmentOptions}
value={department}
placeholder="부서"
onValueChange={(value) => {
setDepartment(value)
setSearchValue(value)
}}
/>
<DatePicker
controlled
size="medium"
aria-label="등록일"
placeholder="등록일"
value={date}
onValueChange={(value) => {
setDate(value)
table.getColumn("createdAt")?.setFilterValue(value)
}}
/>
<FilterBarActions>
<Button type="button" variant="cancel" appearance="outline" onClick={reset}>
초기화
</Button>
<DataTableViewOptions table={table} labels={columnLabels} />
</FilterBarActions>
</FilterBar>
<DataTable table={table} emptyMessage="조건에 맞는 사용자가 없습니다." />
</div>
)
}
const meta = { title: "Patterns/Data/Data Table", parameters: { layout: "padded", controls: { disable: true } } } satisfies Meta
export default meta
type Story = StoryObj<typeof meta>
// Representative Story: selection (mixed header), sorting, column visibility, row actions and pagination with page size.
export const All: Story = {
name: "Data Table",
parameters: { layout: "fullscreen" },
render: () => (
<StoryPage>
<StoryPageHeader
kicker={STORY_KICKER.patterns}
title="Data Table"
description="업무 목록을 표로 보여 주는 Pattern입니다. 선택·정렬·열 표시·페이지 이동을 하나의 표에서 다룹니다."
/>
<StorySection title="Overview" description="행 선택(헤더 혼합 상태), 정렬, 열 표시, 행 작업, 페이지 크기와 페이지 이동을 실제로 조작합니다.">
<Basic />
</StorySection>
</StoryPage>
),
}
// Combination Story: Filter Bar (search, status, department, date, reset) filtering the Data Table rows.
export const WithFilterBarStory: Story = { tags: ["verification"], name: "With Filter Bar", render: () => <WithFilterBar /> }
export const Loading: Story = { tags: ["verification"], render: () => <Basic loading /> }
export const Empty: Story = { tags: ["verification"], render: () => <Basic data={[]} /> }
+94
View File
@@ -0,0 +1,94 @@
import { useState } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite"
import {
Button,
Combobox,
DatePicker,
FilterBar,
FilterBarActions,
FilterBarSearch,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@hm-design/ui"
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// Filter Bar is a layout pattern: the consumer composes the HM controls as children and owns every value. It does not depend on Data Table.
const categories = [
{ value: "design", label: "설계" },
{ value: "build", label: "시공" },
{ value: "safety", label: "안전" },
]
function Full() {
const [search, setSearch] = useState("")
const [status, setStatus] = useState("all")
const [category, setCategory] = useState<string | undefined>()
const [date, setDate] = useState<Date | undefined>()
const active = [search, status !== "all" && status, category, date].filter(Boolean).length
return (
<div className="grid w-full gap-3">
<FilterBar>
<FilterBarSearch value={search} onValueChange={setSearch} placeholder="검색어 입력" />
<Select value={status} onValueChange={setStatus}>
<SelectTrigger size="medium" aria-label="상태">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">전체 상태</SelectItem>
<SelectItem value="active">활성</SelectItem>
<SelectItem value="inactive">비활성</SelectItem>
</SelectContent>
</Select>
<Combobox controlled size="medium" aria-label="분류" options={categories} value={category} onValueChange={setCategory} placeholder="분류" />
<DatePicker controlled size="medium" aria-label="날짜" value={date} onValueChange={setDate} placeholder="날짜" />
<FilterBarActions>
<Button
type="button"
variant="cancel"
appearance="outline"
onClick={() => {
setSearch("")
setStatus("all")
setCategory(undefined)
setDate(undefined)
}}
>
초기화
</Button>
<Button type="button" variant="primary" appearance="outline">
조회
</Button>
</FilterBarActions>
</FilterBar>
<p className="text-sm text-muted-foreground" role="status">
적용된 조건 {active}개
</p>
</div>
)
}
const meta = { title: "Patterns/Data/Filter Bar", parameters: { layout: "padded", controls: { disable: true } } } satisfies Meta
export default meta
type Story = StoryObj<typeof meta>
// Representative Story: search, Select, Combobox, Date Picker and the actions row. Narrow it to 390px to see the wrapping.
export const All: Story = {
name: "Filter Bar",
parameters: { layout: "fullscreen" },
render: () => (
<StoryPage>
<StoryPageHeader
kicker={STORY_KICKER.patterns}
title="Filter Bar"
description="목록 위에서 조회 조건을 고르는 레이아웃 Pattern입니다. HM 컨트롤을 children으로 조합하며 모든 값은 소비 코드가 소유합니다. Data Table에 의존하지 않습니다."
/>
<StorySection title="Overview" description="검색 · Select · Combobox · Date Picker와 초기화 / 조회 버튼입니다. 폭이 좁으면 줄바꿈됩니다.">
<Full />
</StorySection>
</StoryPage>
),
}
+563
View File
@@ -0,0 +1,563 @@
import { useEffect, useState, type CSSProperties, type MouseEvent, type ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite"
import { BellIcon, ChartColumnIcon, LayoutGridIcon, 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.
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: "대시보드", 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: "일정 관리" },
...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 }: { fullHeight?: boolean }) {
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" 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="GNB ONLY 내부 Sidebar입니다. 기존 LNB 메뉴를 그대로 사용하고 외곽 배경만 Green Light 2로 바꿉니다. 모바일은 GNB의 단일 Sheet에 같은 메뉴를 표시합니다.">
<SidebarPage />
</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>
),
}
+756
View File
@@ -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 />,
}
+57
View File
@@ -0,0 +1,57 @@
import { useState } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite"
import { Button, LoadingScreen } from "@hm-design/ui"
import { StateBoard } from "@/components/story-board"
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// Loading Screen is a Dashboard-local pattern (ui-viz LoadingScreen). Inline fills its parent; fullscreen covers the viewport. The
// fullscreen demo is mounted and removed by local state only; there is no async task, cancel or close control in the pattern.
function Fullscreen() {
const [open, setOpen] = useState(false)
return (
<>
<Button appearance="outline" onClick={() => setOpen(true)}>전체화면 미리보기</Button>
{open ? (
<>
<LoadingScreen fullscreen>데이터를 불러오는 중</LoadingScreen>
<Button className="fixed top-7 right-7 z-[10000]" onClick={() => setOpen(false)}>닫기</Button>
</>
) : null}
</>
)
}
const meta = { title: "Patterns/Layout/Loading Screen", parameters: { layout: "padded", controls: { disable: true } } } satisfies Meta
export default meta
type Story = StoryObj<typeof meta>
// The inline specimen fills its parent inside the common page Inner.
export const All: Story = {
name: "Loading Screen",
parameters: { layout: "fullscreen" },
render: () => (
<StoryPage>
<StoryPageHeader
kicker={STORY_KICKER.patterns}
title="Loading Screen"
description="Dashboard 안에서 데이터를 불러오는 동안 표시하는 화면입니다. Inline은 부모 영역을 채우고, fullscreen은 viewport 전체를 덮습니다."
/>
<StorySection title="Overview" description="Inline: 부모 영역(여기서는 공통 Inner 폭)을 채웁니다.">
<LoadingScreen />
</StorySection>
<StorySection title="Variants" description="사용자 문구와 좁은 부모 영역입니다.">
<StateBoard
states={[
{ id: "label", label: "Custom label", children: <LoadingScreen>데이터를 불러오는 중</LoadingScreen> },
{ id: "narrow", label: "Narrow parent (240px)", children: <div style={{ width: 240 }}><LoadingScreen /></div> },
]}
/>
</StorySection>
<StorySection title="Usage" description="fullscreen은 viewport 전체를 덮습니다. 버튼으로 열고 오른쪽 위 닫기로 닫습니다.">
<Fullscreen />
</StorySection>
</StoryPage>
),
}
export const Inline: Story = { tags: ["verification"], render: () => <LoadingScreen /> }
+67
View File
@@ -0,0 +1,67 @@
import type { Meta, StoryObj } from "@storybook/react-vite"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
Button,
PageHeader,
PageHeaderActions,
PageHeaderContent,
PageHeaderDescription,
PageHeaderTitle,
} from "@hm-design/ui"
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
const meta = {
title: "Patterns/Layout/Page Header",
component: PageHeader,
parameters: { layout: "fullscreen", controls: { disable: true } },
} satisfies Meta<typeof PageHeader>
export default meta
type Story = StoryObj<typeof meta>
// Content composition only. The consuming screen owns navigation and action handlers.
// Dashboard's minimum width and internal navigation belong to DashboardLayout.
export const All: Story = {
name: "Page Header",
render: () => (
<StoryPage>
<StoryPageHeader
kicker={STORY_KICKER.patterns}
title="Page Header"
description="화면 콘텐츠의 제목 영역입니다. Breadcrumb, 제목, 설명과 대표 Action을 조합합니다."
/>
<StorySection title="Overview" description="단일 Brand Action을 사용하는 기본 조합입니다.">
<PageHeader>
<PageHeaderContent>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="#home">홈</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink href="#projects">프로젝트</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>업무 현황</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<PageHeaderTitle>업무 현황</PageHeaderTitle>
<PageHeaderDescription>프로젝트별 업무 진행 상황을 확인하고 조회합니다.</PageHeaderDescription>
</PageHeaderContent>
<PageHeaderActions>
<Button type="button" variant="primary" appearance="brand" size="default">
신규 등록
</Button>
</PageHeaderActions>
</PageHeader>
</StorySection>
</StoryPage>
),
}
+30
View File
@@ -0,0 +1,30 @@
// Local sample data for the pattern Stories (no network).
export type SampleUser = {
id: string
name: string
department: string
status: "active" | "pending" | "inactive"
createdAt: string // ISO date
}
export const statusLabel: Record<SampleUser["status"], string> = { active: "활성", pending: "대기", inactive: "비활성" }
const names = ["김민수", "이서연", "박지훈", "최유진", "정하늘", "강도현", "조수빈", "윤재민", "임나래", "한지우", "오세준", "서하은"]
const departments = ["설계팀", "시공팀", "안전관리팀", "품질팀", "경영지원팀"]
const statuses: SampleUser["status"][] = ["active", "active", "pending", "inactive"]
// 47 rows: more than two pages at 20 per page, with dates spread over October 2026.
export const sampleUsers: SampleUser[] = Array.from({ length: 47 }, (_, i) => ({
id: `U-${String(i + 1).padStart(3, "0")}`,
name: `${names[i % names.length]}${i >= names.length ? ` ${Math.floor(i / names.length) + 1}` : ""}`,
department: departments[(i * 3) % departments.length],
status: statuses[(i * 5) % statuses.length],
createdAt: new Date(2026, 9, 1 + (i % 28)).toISOString(),
}))
export const formatDate = (iso: string) => new Intl.DateTimeFormat("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit" }).format(new Date(iso))
export const sameDay = (iso: string, date: Date) => {
const d = new Date(iso)
return d.getFullYear() === date.getFullYear() && d.getMonth() === date.getMonth() && d.getDate() === date.getDate()
}