Initial commit: HMDesignSystem baseline
This commit is contained in:
commit
b6a0a61103
486 files changed
+68432
No files matched your search
@@ -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. */
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
@@ -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";
|
||||
@@ -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(),
|
||||
}))
|
||||
@@ -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={[]} /> }
|
||||
@@ -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>
|
||||
),
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
}
|
||||
@@ -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 />,
|
||||
}
|
||||
@@ -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 /> }
|
||||
@@ -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>
|
||||
),
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in new issue
Block a user