feat: integrate latest HMDesign design system updates

This commit is contained in:
DESKTOP-KPCP6AL\ㅅㅎ committed 2026-10-11 11:09:03 +09:00
1 parent 99faec189e
commit 1c4e0bb9a1
209 files changed
+8845 -552

No files matched your search

+2 -99
View File
@@ -1,102 +1,5 @@
import { useState } from "react"
import { ArrowRight, Check, ChevronRight, Layers3, LayoutGrid, Palette, Sparkles } from "lucide-react"
import { Button } from "@hm-design/ui"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hm-design/ui"
import { Dialog, DialogBody, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@hm-design/ui"
import { Input } from "@hm-design/ui"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@hm-design/ui"
const colors = [
{ name: "HM Green", value: "#123328", tone: "dark" },
{ name: "HM Brown", value: "#3E3523", tone: "dark" },
{ name: "Content Beige", value: "#F5F0E5", tone: "light" },
{ name: "Surface White", value: "#FFFFFF", tone: "light" },
] as const
import WorkDashboard from "./work-dashboard/WorkDashboard"
export default function App() {
const [projectName, setProjectName] = useState("")
const [category, setCategory] = useState("")
const [saved, setSaved] = useState(false)
return (
<div className="hm-app min-h-screen bg-background text-foreground">
<header className="hm-topbar">
<div className="hm-container flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<span className="hm-mark" aria-hidden="true">HM</span>
<div><p className="hm-brand-name">HM Design System</p><p className="hm-brand-caption">한맥 디자인 시스템 · UI Preview</p></div>
</div>
<span className="hm-topbar-status"><span aria-hidden="true" /> Prototype 01</span>
</div>
</header>
<main className="hm-container pb-16 pt-10 md:pt-14">
<div className="hm-breadcrumb mb-7 flex items-center gap-2 text-sm"><span>Design System</span><ChevronRight size={14} aria-hidden="true" /><strong>Overview</strong></div>
<section className="hm-hero grid gap-8 p-7 md:grid-cols-[minmax(0,1fr)_255px] md:p-10">
<div className="relative z-10">
<div className="hm-eyebrow"><Sparkles size={14} aria-hidden="true" /> FIRST LOOK · HM UI</div>
<h1>한맥의 기본 UI를<br /><span>한 화면에서 확인하세요.</span></h1>
<p className="hm-hero-copy">기존 디자인 자료의 색상과 형태를 바탕으로 shadcn 컴포넌트를 구성한 초기 미리보기입니다. 실제 화면에서 조작하며 다음 단계를 결정할 수 있습니다.</p>
<a className="hm-hero-link" href="#components">컴포넌트 살펴보기 <ArrowRight size={17} aria-hidden="true" /></a>
</div>
<div className="hm-hero-art" aria-hidden="true"><div className="hm-hero-art-line" /><div className="hm-hero-art-square"><LayoutGrid size={45} strokeWidth={1.2} /></div><div className="hm-hero-art-pill"><span /><span /><span /></div></div>
</section>
<section id="components" className="mt-12 scroll-mt-8">
<div className="hm-section-heading mb-5 flex flex-wrap items-end justify-between gap-3">
<div><p className="hm-section-kicker">COMPONENTS</p><h2>기본 컴포넌트</h2><p>버튼과 입력 컨트롤의 형태, 상태, 조합을 빠르게 살펴봅니다.</p></div>
<span className="hm-count">5 components</span>
</div>
<div className="grid gap-5 lg:grid-cols-[1.05fr_.95fr]">
<Card className="hm-panel">
<CardHeader className="hm-panel-header"><div className="hm-panel-icon"><Layers3 size={18} aria-hidden="true" /></div><CardTitle>Button</CardTitle><CardDescription>주요 행동부터 보조 행동까지</CardDescription></CardHeader>
<CardContent className="hm-panel-content">
<div className="hm-control-row">
<Button onClick={() => setSaved(true)}>주요 버튼</Button>
<Button variant="primary" appearance="solid" onClick={() => setSaved(true)}>일반 실행</Button>
<Button variant="primary" appearance="outline">보조 버튼</Button>
</div>
<div className="hm-control-row mt-4">
<Button variant="cancel" appearance="solid" onClick={() => setSaved(false)}>취소</Button>
<Button variant="destructive" appearance="outline">삭제</Button>
<Button disabled>비활성</Button>
</div>
<p className="hm-component-note">기존 버튼 규격의 Green 실행, Gray 취소, Red Outline 삭제 표현을 참고했습니다.</p>
</CardContent>
</Card>
<Card className="hm-panel">
<CardHeader className="hm-panel-header"><div className="hm-panel-icon"><Palette size={18} aria-hidden="true" /></div><CardTitle>Color & surface</CardTitle><CardDescription>초기 화면에 사용한 HM 컬러</CardDescription></CardHeader>
<CardContent className="hm-panel-content"><div className="hm-swatch-grid">{colors.map((color) => <div className="hm-swatch" key={color.name}><span className={`hm-swatch-color hm-swatch-color--${color.tone}`} style={{ backgroundColor: color.value }} /><span className="hm-swatch-name">{color.name}</span><span className="hm-swatch-value">{color.value}</span></div>)}</div></CardContent>
</Card>
</div>
<div className="mt-5 grid gap-5 lg:grid-cols-[1.05fr_.95fr]">
<Card className="hm-panel">
<CardHeader className="hm-panel-header"><div className="hm-panel-icon"><LayoutGrid size={18} aria-hidden="true" /></div><CardTitle>Input & Select</CardTitle><CardDescription>업무 화면의 기본 입력 조합</CardDescription></CardHeader>
<CardContent className="hm-panel-content">
<div className="grid gap-4 sm:grid-cols-2">
<div className="hm-field"><label htmlFor="project-name">프로젝트명</label><Input id="project-name" size="medium" placeholder="프로젝트명을 입력하세요" value={projectName} onChange={(event) => { setProjectName(event.target.value); setSaved(false) }} /></div>
<div className="hm-field"><label htmlFor="project-category">분류</label><Select value={category} onValueChange={(value) => { setCategory(value); setSaved(false) }}><SelectTrigger id="project-category" size="medium" className="w-full"><SelectValue placeholder="분류 선택" /></SelectTrigger><SelectContent><SelectItem value="design">디자인 검토</SelectItem><SelectItem value="development">개발 적용</SelectItem><SelectItem value="operation">운영 관리</SelectItem></SelectContent></Select></div>
</div>
<div className="hm-form-footer mt-6 flex flex-wrap items-center justify-between gap-3"><p aria-live="polite">{saved ? <><Check size={15} aria-hidden="true" /> 입력 상태를 확인했습니다.</> : "값을 입력하고 버튼을 눌러 상태를 확인해 보세요."}</p><Button variant="primary" appearance="solid" onClick={() => setSaved(true)}>상태 확인</Button></div>
</CardContent>
</Card>
<Card className="hm-panel hm-panel--showcase">
<CardHeader className="hm-panel-header"><div className="hm-panel-icon"><LayoutGrid size={18} aria-hidden="true" /></div><CardTitle>Card & Dialog</CardTitle><CardDescription>콘텐츠 영역과 팝업 동작</CardDescription></CardHeader>
<CardContent className="hm-panel-content"><div className="hm-inner-card"><span className="hm-inner-card-label">PREVIEW CARD</span><h3>하나의 작업을 명확하게</h3><p>카드 안에서 정보를 정리하고 필요한 행동으로 이어지는 구성을 확인합니다.</p>
<Dialog><DialogTrigger asChild><Button variant="primary" appearance="outline">Dialog 열기</Button></DialogTrigger><DialogContent><DialogHeader><DialogTitle>작업 확인</DialogTitle><DialogDescription>현재 입력한 내용을 확인한 뒤 계속 진행할 수 있습니다.</DialogDescription></DialogHeader><DialogBody><div className="hm-dialog-summary"><span>프로젝트명</span><strong>{projectName || "입력 전"}</strong><span>분류</span><strong>{category === "design" ? "디자인 검토" : category === "development" ? "개발 적용" : category === "operation" ? "운영 관리" : "선택 전"}</strong></div></DialogBody><DialogFooter><DialogClose asChild><Button variant="cancel" appearance="solid">닫기</Button></DialogClose><DialogClose asChild><Button variant="primary" appearance="solid" onClick={() => setSaved(true)}>확인</Button></DialogClose></DialogFooter></DialogContent></Dialog>
</div></CardContent>
</Card>
</div>
</section>
<footer className="hm-footer mt-12 flex flex-wrap items-center justify-between gap-3 text-sm"><span>HM Design System · 초기 UI Preview</span><span>React · Tailwind CSS v4 · shadcn/ui · Storybook</span></footer>
</main>
</div>
)
return <WorkDashboard />
}
+3 -3
View File
@@ -53,8 +53,8 @@ function Destructive({ defaultOpen }: { defaultOpen?: boolean }) {
<AlertDialogDescription>삭제하면 되돌릴 수 없습니다. 프로젝트의 모든 데이터가 함께 삭제됩니다.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel appearance="outline">취소</AlertDialogCancel>
<AlertDialogAction variant="destructive" appearance="outline">
<AlertDialogCancel appearance="solid">취소</AlertDialogCancel>
<AlertDialogAction variant="destructive" appearance="solid">
삭제
</AlertDialogAction>
</AlertDialogFooter>
@@ -81,7 +81,7 @@ export const All: Story = {
<StorySection title="Overview" description="일반 확인창은 같은 크기의 Solid 버튼 세트입니다. 버튼을 눌러 엽니다.">
<Confirmation />
</StorySection>
<StorySection title="Variants" description="삭제 확인창은 취소와 삭제 모두 같은 크기의 Outline 세트입니다.">
<StorySection title="Variants" description="삭제 확인창은 취소와 삭제 모두 같은 크기의 Standard Solid 세트입니다.">
<Destructive />
</StorySection>
</StoryPage>
+5 -5
View File
@@ -25,7 +25,7 @@ const rolesFor = (appearance: ButtonAppearance) =>
appearance === "brand"
? ["primary", "secondary", "modify"].includes(role)
: appearance === "solid"
? role !== "destructive"
? true
: ["primary", "secondary", "destructive", "cancel"].includes(role),
)
@@ -45,7 +45,7 @@ const meta = {
variant: {
control: "select",
options: roles.map(([role]) => role),
description: "역할(Role): primary Green, secondary Brown, modify Orange, destructive Red, cancel Gray. destructive는 outline만, modify는 brand/solid만, cancel은 solid/outline만 지원합니다. docs/components/button.md §6.1 참조.",
description: "역할(Role): primary Green, secondary Brown, modify Orange, destructive Red, cancel Gray. destructive는 solid/outline, modify는 brand/solid만, cancel은 solid/outline만 지원합니다. docs/components/button.md §6.1 참조.",
},
appearance: {
control: "inline-radio",
@@ -205,10 +205,10 @@ function LongLabelBoard() {
function DestructiveBoard() {
return (
<div className={row}>
<Button variant="cancel" appearance="outline">
<Button variant="cancel" appearance="solid">
Cancel
</Button>
<Button variant="destructive" appearance="outline">
<Button variant="destructive" appearance="solid">
Delete
</Button>
</div>
@@ -282,7 +282,7 @@ export const Destructive: Story = {
tags: ["verification"],
render: () => <DestructiveBoard />,
parameters: {
docs: { description: { story: "검증 전용 삭제·취소 세트: Cancel은 Gray Outline, Delete는 Red Outline이며 appearance와 size를 통일합니다. Red Hover/Pressed 면은 Red 8%입니다." } },
docs: { description: { story: "검증 전용 삭제·취소 세트: Cancel은 Gray Solid, Delete는 Red Solid이며 appearance와 size를 통일합니다. Solid Hover/Pressed는 기존 Black 20% overlay이며 Red 토큰을 사용합니다." } },
},
}
+1 -1
View File
@@ -126,7 +126,7 @@ export const All: Story = {
title="Combobox"
description="검색해서 하나를 고르는 Popover + Command 조합입니다. 검색이 필요 없는 짧은 목록은 Select를 씁니다."
/>
<StorySection title="Overview">{board("empty")}</StorySection>
<StorySection title="Overview" description="Trigger는 dialog 팝업을 열고, 내부 검색 입력이 listbox를 제어합니다. Enter/Space로 열기, 방향키·Enter로 선택, Escape로 닫기 후 Trigger로 focus가 복귀합니다.">{board("empty")}</StorySection>
<StorySection title="Sizes" description="size를 생략하면 Large입니다. 같은 size의 Select와 나란히 비교합니다.">
<StateBoard
min={320}
+6 -4
View File
@@ -9,6 +9,8 @@ import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/compon
// Dialog is an overlay (Radix Portal), so it does not fit the StateBoard. The representative Story is a row of triggers that open
// the real Dialog for each ui_viz case (dialog.md §9 Reference Viewer examples); the same cases also exist as separate Stories tagged
// `verification` that start open. Pseudo-state helpers are not used to fake an open Dialog.
// Usage: native label below is a preserved Reference fixture. New HM React fields use public Label
// with the actual control id (docs/components/label.md §5); fixture actions are not screen requirements.
const longText = "긴 텍스트는 줄바꿈으로 전체를 표시합니다. 공백 없는 긴 문자열도 Dialog 너비를 밀어내지 않고 줄바꿈합니다. ".repeat(8)
@@ -66,12 +68,12 @@ const confirmActions = (
const deleteActions = (
<>
<DialogClose asChild>
<Button variant="cancel" appearance="outline">
<Button variant="cancel" appearance="solid">
취소
</Button>
</DialogClose>
<DialogClose asChild>
<Button variant="destructive" appearance="outline">
<Button variant="destructive" appearance="solid">
삭제
</Button>
</DialogClose>
@@ -150,7 +152,7 @@ function WideContent({ defaultOpen }: { defaultOpen?: boolean }) {
function DeleteConfirmation({ defaultOpen }: { defaultOpen?: boolean }) {
return (
<Frame trigger="삭제 확인" defaultOpen={defaultOpen} title="항목 삭제" description="삭제하면 되돌릴 수 없습니다." actions={deleteActions}>
<p>선택한 항목을 삭제합니다. 취소와 삭제 실행은 같은 크기의 Outline 세트입니다.</p>
<p>선택한 항목을 삭제합니다. 취소와 삭제 실행은 같은 크기의 Standard Solid 세트입니다.</p>
</Frame>
)
}
@@ -194,7 +196,7 @@ export const All: Story = {
<BodyScroll />
</div>
</StorySection>
<StorySection title="Usage" description="일반 확인은 Solid 세트, 삭제 확인은 취소·삭제 모두 Outline 세트입니다.">
<StorySection title="Usage" description="일반 확인과 삭제 확인 모두 취소·실행을 Standard Solid 세트로 구성합니다.">
<div className="flex flex-wrap gap-3">
<DeleteConfirmation />
<FormContent />
+7 -5
View File
@@ -1,4 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite"
// Usage: preserved native label fixtures do not define a new-screen exception. Use public Label
// for supported fields (docs/components/label.md §5); copy only actions requested by the screen.
import { Button, Drawer, DrawerBody, DrawerClose, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle, DrawerTrigger, Input } from "@hm-design/ui"
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
@@ -44,13 +46,13 @@ function Frame({
const actions = (
<>
<DrawerClose asChild>
<Button variant="primary" appearance="solid">
확인
<Button variant="cancel" appearance="solid">
취소
</Button>
</DrawerClose>
<DrawerClose asChild>
<Button variant="cancel" appearance="solid">
취소
<Button variant="primary" appearance="solid">
실행
</Button>
</DrawerClose>
</>
@@ -103,7 +105,7 @@ export const All: Story = {
title="Drawer"
description="화면 아래에서 올라오는 모달 패널입니다. 핸들이나 패널을 아래로 끌어 닫습니다."
/>
<StorySection title="Overview" description="기본 Drawer입니다. 버튼을 눌러 엽니다.">
<StorySection title="Overview" description="데스크톱: 하단 우측 가로 취소 → 실행. 모바일(640px 이하): 전체 폭 세로 실행 위 / 취소 아래. 같은 Drawer를 화면 폭에 맞춰 확인합니다.">
<Basic />
</StorySection>
<StorySection title="Usage" description="폼 입력 조합과, 내용이 길 때 Body만 scroll 하는 경우입니다.">
+8 -2
View File
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite"
import { Input, Label } from "@hm-design/ui"
import { Input, Label, Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@hm-design/ui"
import { StateBoard } from "@/components/story-board"
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
@@ -30,7 +30,7 @@ export const All: Story = {
<StoryPageHeader
kicker={STORY_KICKER.components}
title="Label"
description="입력 컨트롤의 이름입니다. Field Label 14/20/500을 쓰며, htmlFor로 연결하면 라벨 클릭 시 컨트롤로 focus가 이동합니다."
description="Label은 항목명이고 Input은 입력칸입니다. Field Label 14/20/500을 쓰며, htmlFor로 연결하면 라벨 클릭 시 해당 Input이나 Select로 초점이 이동합니다."
/>
<StorySection title="Overview">
<StateBoard
@@ -71,6 +71,12 @@ export const All: Story = {
/>
</StorySection>
<StorySection title="Usage" description="Required 별표는 Label이 아닌 소비 조합입니다. 긴 라벨은 줄바꿈됩니다.">
<div className="mb-4 grid max-w-sm gap-2">
<Label htmlFor="label-select">부서 선택</Label>
<Select><SelectTrigger id="label-select" size="medium"><SelectValue placeholder="부서를 선택하세요" /></SelectTrigger>
<SelectContent><SelectItem value="design">디자인</SelectItem><SelectItem value="engineering">개발</SelectItem></SelectContent>
</Select>
</div>
<StateBoard
states={[
{
@@ -0,0 +1,37 @@
import { useState } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite"
import { Checkbox, Progress, ToggleGroup, ToggleGroupItem, Combobox, DatePicker, FilterBarSearch, Menubar, MenubarMenu, MenubarTrigger, MenubarContent, MenubarItem } from "@hm-design/ui"
const meta = { title: "Verification/Publisher Regression", tags: ["verification"], parameters: { layout: "padded" } } satisfies Meta
export default meta
function Consumers() {
const [checked, setChecked] = useState(false)
const [query, setQuery] = useState("Editable")
return <div className="grid max-w-2xl gap-4">
<Checkbox asChild checked={checked} onCheckedChange={value => setChecked(value === true)} aria-label="Consumer checkbox"><button id="consumer-checkbox"><span data-consumer-child>Consumer child</span></button></Checkbox>
<Progress asChild value={35} aria-label="Consumer progress"><section id="consumer-progress"><span data-consumer-child>Consumer progress</span></section></Progress>
<ToggleGroup type="multiple" spacing={3} style={{ color: "var(--hm-sem-fg-primary)" }} aria-label="Spacing merge"><ToggleGroupItem value="one">One</ToggleGroupItem><ToggleGroupItem value="two">Two</ToggleGroupItem></ToggleGroup>
<Combobox aria-label="Department" options={[{ value: "design", label: "Design" }]} />
<Combobox aria-label="Owner" options={[{ value: "hm", label: "Hanmac" }]} />
<DatePicker aria-label="Start date" /><DatePicker aria-label="End date" />
<FilterBarSearch value="Read only" readOnly onValueChange={() => { throw Error("readOnly mutated") }} />
<FilterBarSearch value="Disabled" disabled onValueChange={() => { throw Error("disabled mutated") }} />
<FilterBarSearch value={query} onValueChange={setQuery} />
<Menubar><MenubarMenu><MenubarTrigger>File regression</MenubarTrigger><MenubarContent><MenubarItem>New regression</MenubarItem></MenubarContent></MenubarMenu><MenubarMenu><MenubarTrigger>Edit regression</MenubarTrigger><MenubarContent><MenubarItem>Undo regression</MenubarItem></MenubarContent></MenubarMenu></Menubar>
</div>
}
export const ConsumersRegression: StoryObj<typeof meta> = {
render: () => <Consumers />,
play: async ({ canvasElement }) => {
const doc = canvasElement.ownerDocument
const key = (target: HTMLElement, value: string) => { target.focus(); target.dispatchEvent(new KeyboardEvent("keydown", { key: value, bubbles: true })) }
const settle = () => new Promise(resolve => setTimeout(resolve, 160))
key(canvasElement.querySelector<HTMLElement>('[data-slot="menubar-trigger"]')!, "ArrowDown")
await settle()
key(doc.querySelector<HTMLElement>('[data-slot="menubar-item"]')!, "ArrowRight")
await settle()
const edit = canvasElement.querySelectorAll<HTMLElement>('[data-slot="menubar-trigger"]')[1]
if (edit.getAttribute("aria-expanded") !== "true" || !doc.querySelector('[data-slot="menubar-content"]')?.textContent?.includes("Undo regression")) throw Error("Menubar switch closed the next popup")
key(doc.querySelector<HTMLElement>('[data-slot="menubar-item"]')!, "Escape")
},
}
+3 -3
View File
@@ -273,10 +273,10 @@ export const OpenTopLong: Story = {
// that SelectContent already forwards, so no Select API exists for it. Both columns share options, selected value and width.
function ListSizeColumn({ title, listSize, width }: { title: string; listSize: SelectSize; width: number }) {
return (
<section className="grid content-start gap-2" style={{ width }} data-list-size={listSize}>
<section className="grid min-w-0 content-start gap-2" style={{ width, maxWidth: "100%" }} data-list-size={listSize}>
<h3 className="m-0 text-sm font-semibold">{title}</h3>
<div className="min-h-56">
<SelectSpecimen size="medium" listSize={listSize} defaultOpen defaultValue="long" options={longOpenOptions} label={"Trigger medium · " + width + "px"} />
<SelectSpecimen size="medium" listSize={listSize} defaultOpen defaultValue="long" options={longOpenOptions} label={"Trigger medium · 최대 " + width + "px"} />
</div>
</section>
)
@@ -286,7 +286,7 @@ export const ListSizeComparison: Story = {
name: "Medium Trigger / List Size Comparison",
parameters: { controls: { disable: true } },
render: () => (
<div className="grid gap-8 [grid-template-columns:repeat(auto-fit,minmax(280px,max-content))]">
<div className="grid gap-8 [grid-template-columns:repeat(auto-fit,minmax(min(100%,384px),max-content))]">
<ListSizeColumn title="A · Medium Trigger + Large List" listSize="large" width={280} />
<ListSizeColumn title="B · Medium Trigger + Medium List" listSize="medium" width={280} />
<ListSizeColumn title="A · Medium Trigger + Large List" listSize="large" width={384} />
+2 -2
View File
@@ -28,7 +28,7 @@ export const All: Story = {
<StoryPageHeader
kicker={STORY_KICKER.components}
title="Separator"
description="콘텐츠 사이를 나누는 선입니다. 기본은 장식용(decorative)이며 접근성 트리에 노출되지 않습니다."
description="콘텐츠 사이를 나누는 선입니다. 제목과 설명을 하나의 묶음으로 두고 구분선은 묶음 뒤 한 곳에 사용합니다. 기본은 장식용(decorative)입니다."
/>
<StorySection title="Overview">
<StateBoard
@@ -39,7 +39,7 @@ export const All: Story = {
label: "Horizontal",
children: (
<div className="grid gap-3 text-sm">
<span>Above</span>
<div><strong>제목</strong><p>제목에 연결된 설명입니다.</p></div>
<Separator />
<span>Below</span>
</div>
+2
View File
@@ -1,4 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite"
// Usage: preserved native label fixtures do not define a new-screen exception. Use public Label
// for supported fields (docs/components/label.md §5); copy only actions requested by the screen.
import { Button, Input, Sheet, SheetBody, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from "@hm-design/ui"
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
+4 -2
View File
@@ -25,8 +25,10 @@ export function StateBoard({ states, min = 320, fixed = false }: { states: Board
data-overview-state={id}
aria-label={label}
>
<Label className="border-b border-black/10 pb-2 text-sm font-medium text-muted-foreground">{label}</Label>
{note && !noteAfter ? <p className="text-sm text-muted-foreground">{note}</p> : null}
<header className="grid gap-2 border-b border-black/10 pb-2">
<Label className="text-sm font-medium text-muted-foreground">{label}</Label>
{note && !noteAfter ? <p className="text-sm text-muted-foreground">{note}</p> : null}
</header>
{children}
{note && noteAfter ? <p className="text-sm text-muted-foreground">{note}</p> : null}
</section>
+69 -13
View File
@@ -1,20 +1,24 @@
import type { Meta, StoryObj } from "@storybook/react-vite"
import type { ComponentProps } from "react"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@hm-design/ui"
import { useId, useState, type ComponentProps } from "react"
import {
Card, CardContent, CardHeader, CardTitle, Input, Label, Textarea,
Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow,
Tabs, TabsContent, TabsList, TabsTrigger,
} from "@hm-design/ui"
import { StateBoard } from "@/components/story-board"
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// Representative "Tabs" Story shows default Primary, explicit API comparisons and Secondary; Legacy stays verification-only.
// Selected connects to the content surface, so every specimen puts the Tabs on a plain bordered panel supplied by the consumer
// (TabsContent here). The panel is layout, not part of Tabs.
// D-01 applies to every Fill specimen: flat panels use the actual parent surface and padding 24px 0.
// D-02 is shipped by the public Tabs theme mapping (Primary top White alpha .8). Legacy stays compatibility-only.
type Variant = "primary" | "secondary"
type ListProps = Pick<ComponentProps<typeof TabsList>, "fill" | "panelSurface">
// Primary = Fill + panelSurface="content", Secondary = Fill + panelSurface="surface" (2026-10-06 사용자 결정).
const variantProps = {
primary: { listProps: {}, panel: "border bg-background" },
secondary: { listProps: { fill: true, panelSurface: "surface" }, panel: "border bg-card" },
primary: { listProps: {}, surface: "bg-background" },
secondary: { listProps: { fill: true, panelSurface: "surface" }, surface: "bg-card" },
} as const
function Specimen({
@@ -32,12 +36,14 @@ function Specimen({
labels?: string[]
listProps?: ListProps
}) {
const { listProps: defaults, panel } = variantProps[variant]
const { listProps: defaults, surface } = variantProps[variant]
const effectiveListProps: ListProps = listProps ?? defaults
const legacy = effectiveListProps.fill === false
const items = labels?.map((label, i) => ({ value: `menu${i + 1}`, label }))
?? Array.from({ length: count }, (_, i) => ({ value: `menu${i + 1}`, label: `메뉴 ${i + 1}` }))
return (
<Tabs defaultValue={defaultValue}>
<TabsList aria-label="업무 필터" {...(listProps ?? defaults)}>
<Tabs defaultValue={defaultValue} className={legacy ? undefined : `min-w-0 ${surface}`}>
<TabsList aria-label="업무 필터" {...effectiveListProps}>
{items.map(({ value, label }, i) => (
<TabsTrigger key={value} value={value} disabled={disabledSecond && i === 1}>
{label}
@@ -45,7 +51,7 @@ function Specimen({
))}
</TabsList>
{items.map(({ value }, i) => (
<TabsContent key={value} value={value} className={`rounded-b p-4 text-sm ${panel}`}>
<TabsContent key={value} value={value} className={legacy ? "rounded-b p-4 text-sm border bg-card" : "px-0 py-6 text-sm"}>
메뉴 {i + 1} 콘텐츠
</TabsContent>
))}
@@ -55,6 +61,47 @@ function Specimen({
const longLabels = ["기본 정보", "보상 협의 및 계약 진행 현황", "토지 및 지장물 조사 내역"]
// Component composition reference, not a new application screen. All visual controls use public HM exports.
// Input state lives above the panels so the draft survives Radix's inactive-panel unmount.
function SurfaceUsage({ inCard = false }: { inCard?: boolean }) {
const id = useId()
const [project, setProject] = useState("")
const [purpose, setPurpose] = useState("")
const tabs = (
<Tabs defaultValue="information" className="min-w-0">
<TabsList panelSurface={inCard ? "surface" : "content"} aria-label={inCard ? "Card 내부 프로젝트 정보" : "페이지 프로젝트 정보"}>
<TabsTrigger value="information">기본 정보</TabsTrigger>
<TabsTrigger value="schedule">일정·산출물</TabsTrigger>
</TabsList>
<TabsContent value="information" className="px-0 py-6">
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor={`${id}-project`}>프로젝트명 (필수)</Label>
<Input id={`${id}-project`} required value={project} onChange={event => setProject(event.target.value)} placeholder="프로젝트명을 입력하세요" />
</div>
<div className="grid gap-2">
<Label htmlFor={`${id}-purpose`}>목적 및 배경</Label>
<Textarea id={`${id}-purpose`} value={purpose} onChange={event => setPurpose(event.target.value)} placeholder="프로젝트의 목적을 입력하세요" />
</div>
</div>
</TabsContent>
<TabsContent value="schedule" className="px-0 py-6">
<Table>
<TableCaption>프로젝트 산출물 일정 예시</TableCaption>
<TableHeader><TableRow><TableHead scope="col">산출물</TableHead><TableHead scope="col">마감일</TableHead></TableRow></TableHeader>
<TableBody><TableRow><TableCell>화면 설계서</TableCell><TableCell>10월 14일</TableCell></TableRow></TableBody>
</Table>
</TabsContent>
</Tabs>
)
return inCard ? (
<Card className="min-w-0">
<CardHeader><CardTitle>프로젝트 정보</CardTitle></CardHeader>
<CardContent className="min-w-0">{tabs}</CardContent>
</Card>
) : <div className="min-w-0 bg-background">{tabs}</div>
}
function LongLabelSpecimens() {
return (
<div className="grid min-w-0 max-w-full gap-5">
@@ -86,7 +133,7 @@ const meta = {
docs: {
description: {
component:
"기본 TabsList는 Primary(fill=true, panelSurface=\"content\")입니다. 대표 Story는 기본 API와 명시적 Primary, Secondary(fill + panelSurface=\"surface\"), Primary Overflow를 비교합니다. Legacy non-fill은 fill={false}로 유지하며 숨김 Compatibility에서 확인합니다. 높이 34, 좌우 16, 상단 radius 4, 16/24/700과 8px 곡선 연결은 기존 값 그대로입니다. Selected는 패널 배경과 이어지고 Hover는 비선택에만 Black 20%입니다. 넘칠 때만 이전/다음 이동 버튼이 나타납니다. 곡선 반경 8px과 비선택 합성색의 과거 REVIEW / TEMPORARY 이력은 유지합니다.",
"기본 TabsList는 Primary(fill=true, panelSurface=\"content\")입니다. 대표 Story는 기본 API와 명시적 Primary, Secondary(fill + panelSurface=\"surface\"), Primary Overflow를 비교합니다. 모든 Fill 예제는 중첩 박스 없는 TabsContent(px-0 py-6)를 사용하며 Usage는 Card 내부 surface와 페이지 content 위의 조합입니다. panelSurface는 List의 선택 면만 바꾸므로 실제 상위 배경에 맞춰 선택합니다. Primary 선택 탭의 gradient는 상단 White alpha 0.8 → content이며 전체 opacity는 1입니다. Secondary는 기존 불투명 White를 유지합니다. Legacy non-fill은 fill={false}로 유지하며 숨김 Compatibility에서 확인합니다. 높이 34, 좌우 16, 상단 radius 4, 16/24/700과 8px 곡선 연결은 기존 값 그대로입니다. Hover는 비선택에만 Black 20%이며 넘칠 때만 이전/다음 이동 버튼이 나타납니다. aria-label과 연결된 입력 Label을 제공하고, 방향키·Home/End·포커스·Disabled·모바일 내부 스크롤을 확인합니다. 곡선 반경 8px과 비선택 합성색의 과거 REVIEW / TEMPORARY 이력은 유지합니다.",
},
},
},
@@ -107,7 +154,7 @@ export const All: Story = {
<StorySection title="Overview">
<StateBoard
states={[
{ id: "primary", label: "Primary · 기본 TabsList", noteAfter: true, note: "fill / panelSurface 생략 → Primary. 선택 면이 위 White → 아래 content panel색 gradient로 콘텐츠와 이어집니다. 비선택 탭에 hover하면 Black 20% overlay가 적용됩니다.", children: <Specimen /> },
{ id: "primary", label: "Primary · 기본 TabsList", noteAfter: true, note: "fill / panelSurface 생략 → Primary. 선택 면이 위 White alpha 0.8 → 아래 content panel색 gradient로 콘텐츠와 이어집니다. 탭 전체 opacity는 1이며 비선택 hover는 기존 Black 20% overlay입니다.", children: <Specimen /> },
]}
/>
</StorySection>
@@ -129,10 +176,19 @@ export const All: Story = {
<StorySection title="States">
<StateBoard
states={[
{ id: "overflow", label: "Overflow · Primary + 이동 버튼", noteAfter: true, note: "Primary Tabs에 탭이 많거나 가용 폭이 좁으면 이전/다음 이동 버튼이 나타납니다. 버튼은 선택을 바꾸지 않고 목록만 한 항목씩 이동합니다. 260px 폭은 Story wrapper의 검증 폭이며 Tabs 자체 폭이 아닙니다.", children: <div className="max-w-[260px]"><Specimen count={8} /></div> },
{ id: "overflow", wide: true, label: "Overflow · Primary + 이동 버튼", noteAfter: true, note: "일반 화면 폭에서 20개 탭의 넘침을 확인합니다. 이전/다음 버튼은 선택을 바꾸지 않고 목록만 이동합니다. 모바일에서도 같은 내부 스크롤을 유지합니다.", children: <div className="w-full min-w-0 max-w-4xl"><Specimen count={20} /></div> },
]}
/>
</StorySection>
<StorySection title="Usage" description="실제 상위 표면에 panelSurface를 맞추고 TabsContent의 중복 박스를 제거합니다. px-0 py-6는 이 조합의 예시이며 공통 spacing 규칙이 아닙니다.">
<StateBoard states={[
{ id: "usage-card", label: "Card 내부 · Secondary", noteAfter: true, note: "바깥 Card의 배경·테두리·기본 여백 24px은 유지합니다. 내부 패널은 배경 transparent·border 0·padding 24px 0이며 입력 요소의 테두리를 보존합니다.", children: <SurfaceUsage inCard /> },
{ id: "usage-page", label: "페이지 직접 배치 · Primary", noteAfter: true, note: "기존 Layout의 content 배경을 사용하는 조합입니다. 추가 흰색 박스를 만들지 않습니다. 일정 탭의 Table은 고유 표면과 내부 스크롤을 유지하고 입력값은 탭 전환 후에도 보존됩니다.", children: <SurfaceUsage /> },
]} />
</StorySection>
<StorySection title="Do / Don't" description="Card 전용 Token을 화면 전체 간격에 사용하거나 모든 제목·설명 간격을 24px로 통일하지 않습니다.">
<p className="text-sm text-muted-foreground">Do: Card 또는 Layout이 외곽 여백을 소유하고 내부 Input·Table·Alert의 고유 스타일을 유지합니다. Don't: 패널 박스 제거를 모든 border 제거로 해석하거나, 그라데이션 alpha 조정을 탭 전체 opacity 변경으로 구현하지 않습니다. 실제 패널 계산값·키보드 동작·모바일 넘침을 확인합니다.</p>
</StorySection>
</StoryPage>
),
}
+9 -2
View File
@@ -1,7 +1,7 @@
import { useState } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite"
import { BoldIcon, ItalicIcon, StarIcon } from "lucide-react"
import { Toggle } from "@hm-design/ui"
import { Toggle, ToggleGroup, ToggleGroupItem } from "@hm-design/ui"
import { StateBoard } from "@/components/story-board"
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
@@ -110,9 +110,16 @@ export const All: Story = {
<StoryPageHeader
kicker={STORY_KICKER.components}
title="Toggle"
description="눌림(On) 상태를 유지하는 단일 버튼입니다."
description="Toggle과 Toggle Group은 눌림(On) 상태를 공유합니다. Default·Outline, Off·On·Disabled와 키보드 Focus를 같은 기준으로 비교합니다."
/>
<StorySection title="Overview">{board("offon")}</StorySection>
<StorySection title="단독 · 연결 그룹" description="단독 Toggle은 독립 선택입니다. spacing=0 그룹은 연결 형태이며 single은 하나, multiple은 여러 항목을 선택합니다.">
<div className="grid gap-4">
<div><p className="mb-2">단독 · 독립 선택</p><Toggle variant="outline">알림</Toggle></div>
<div><p className="mb-2">연결 그룹 · 단일 선택</p><ToggleGroup type="single" variant="outline" defaultValue="left" aria-label="정렬"><ToggleGroupItem value="left">왼쪽</ToggleGroupItem><ToggleGroupItem value="center">가운데</ToggleGroupItem><ToggleGroupItem value="right">오른쪽</ToggleGroupItem></ToggleGroup></div>
<div><p className="mb-2">연결 그룹 · 다중 선택</p><ToggleGroup type="multiple" variant="outline" defaultValue={["bold"]} aria-label="글자 스타일"><ToggleGroupItem value="bold">굵게</ToggleGroupItem><ToggleGroupItem value="italic">기울임</ToggleGroupItem></ToggleGroup></div>
</div>
</StorySection>
<StorySection title="Sizes">
<Sizes />
</StorySection>
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HM Design System · 채용 관리</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
+17
View File
@@ -0,0 +1,17 @@
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import { RecruitScreen } from "@/recruit/recruit-screen"
import "@/index.css"
import "@/hm-theme.css"
import "@hm-design/ui/dashboard-status.css"
import "../dashboard/dashboard-export.css"
const root = document.getElementById("root")
if (!root) throw new Error("Recruit export root was not found")
createRoot(root).render(
<StrictMode>
<RecruitScreen />
</StrictMode>,
)
+19 -20
View File
@@ -1,26 +1,25 @@
import type { Meta, StoryObj } from "@storybook/react-vite"
import { allColorProperties, componentColors, primitiveColors, semanticColors, type ColorReference } from "./reference-data"
import { ReferencePage, ReferenceSection, useResolvedProperties } from "./reference"
import { runtimeThemeContract, type RuntimeThemeToken } from "@hm-design/ui/theme"
import { ReferencePage, ReferenceSection } from "./reference"
function ColorCards({ items, values }: { items: ColorReference[]; values: Record<string, string> }) {
return <div className="hm-foundation-grid">{items.map(item => <article className="hm-foundation-card" key={item.property} data-color-property={item.property}>
<div className="hm-foundation-swatch" data-color-swatch style={{ background: `var(${item.property})` }} />
<h3>{item.label}</h3><p>{item.usage}</p>
<dl><dt>CSS Variable</dt><dd><code>{item.property}</code></dd>
{item.runtimeKey && <><dt>Runtime Key</dt><dd><code>{item.runtimeKey}</code></dd></>}
<dt>Current Value</dt><dd data-current-value>{values[item.property] || "CSS 값을 읽는 중"}</dd>
<dt>{item.defaultValue ? "HM Default Value" : "Source Declaration"}</dt><dd><code>{item.defaultValue ?? item.declared}</code></dd>
{item.defaultValue && <><dt>Source Declaration</dt><dd><code>{item.declared}</code></dd></>}
<dt>Source</dt><dd>{item.source}</dd></dl>
</article>)}</div>
}
// Existing palette tokens only; values come from the public default contract.
const palettes = [
{ name: "Green", prefix: "green", stages: ["light-4", "light-3", "light-2", "light-1", "default", "dark-1", "dark-2"] },
{ name: "Brown", prefix: "brown", stages: ["light-4", "light-2", "light-1", "default", "dark-1", "deep"] },
{ name: "Orange", prefix: "orange", stages: ["light-4", "light", "mid", "default", "dark", "deep"] },
]
function ColorReferencePage() {
const values = useResolvedProperties(allColorProperties)
return <ReferencePage title="Color" intro="Primitive → Semantic → Component Mapping. 같은 색이라도 역할을 구분합니다. Swatch는 현재 CSS Variable을 사용하며 Theme Designer의 저장된 preview를 반영합니다.">
<nav className="hm-foundation-links" aria-label="Color sections"><a href="#primitive">Primitive</a><a href="#semantic">Semantic</a><a href="#component-mapping">Component Mapping</a></nav>
<ReferenceSection id="primitive" title="Primitive Palette" note="현재 primitives.css에 실제 정의된 색만 표시합니다. Primitive에는 공개 Runtime Key를 부여하지 않습니다."><ColorCards items={primitiveColors} values={values} /></ReferenceSection>
<ReferenceSection id="semantic" title="Semantic Role" note="공개 Runtime Key는 contract.ts에서 읽습니다. 내부 파생 CSS 값은 Runtime Key 없이 표시합니다. Gradient도 기존 HM CSS를 직접 사용합니다."><ColorCards items={semanticColors.filter(item => !item.runtimeKey?.startsWith("color.palette."))} values={values} /><details className="hm-foundation-details"><summary>Runtime Palette Aliases ({semanticColors.filter(item => item.runtimeKey?.startsWith("color.palette.")).length})</summary><ColorCards items={semanticColors.filter(item => item.runtimeKey?.startsWith("color.palette."))} values={values} /></details></ReferenceSection>
<ReferenceSection id="component-mapping" title="Component Mapping" note="대표 소비 관계입니다. 아래 변수는 내부 Component Mapping이며 공개 Runtime Key가 아닙니다. Source Declaration에서 연결된 Semantic 역할을 확인하세요."><ColorCards items={componentColors} values={values} /></ReferenceSection>
return <ReferencePage title="Color" intro="현재 HM 브랜드 컬러를 밝은색 → 기본색 → 어두운색 순서로 비교합니다. 기본색을 강조하며 컬러의 상세 의미와 적용 규칙은 추후 보완합니다.">
{palettes.map(palette => <ReferenceSection key={palette.name} title={palette.name} note="기존 컬러값을 유지합니다. 가로 컬러칩에서 단계명과 HEX를 확인합니다.">
<div className="hm-brand-color-row">{palette.stages.map(stage => {
const token = `color.palette.${palette.prefix}-${stage}` as RuntimeThemeToken
const definition = runtimeThemeContract[token]
return <article key={stage} className="hm-brand-color-chip" data-base={stage === "default" ? "" : undefined}>
<div className="hm-foundation-swatch" style={{ background: `var(${definition.property})` }} />
<h3>{stage === "default" ? "Default · 기본색" : stage.replaceAll("-", " ")}</h3><p>{definition.defaultValue.toUpperCase()}</p>
</article>
})}</div>
</ReferenceSection>)}
</ReferencePage>
}
const meta = { title: "Foundation/Color", parameters: { layout: "fullscreen" } } satisfies Meta
+4
View File
@@ -26,3 +26,7 @@
.hm-foundation-radius { width: 100%; height: 64px; margin-bottom: 16px; border: 1px solid var(--hm-sem-stroke-default); background: var(--hm-sem-bg-content); }
.hm-foundation-layout-sample { display: grid; min-width: 0; background: var(--hm-sem-bg-content); border: 1px solid var(--hm-sem-stroke-input-default); }
.hm-foundation-layout-card { min-width: 0; border: 1px solid var(--hm-card-border-color); border-radius: var(--hm-card-radius); background: var(--hm-card-surface); }
.hm-brand-color-row { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 8px; }
.hm-brand-color-chip { flex: 1 0 110px; min-width: 0; padding: 8px; border: 1px solid var(--hm-sem-stroke-default); }
.hm-brand-color-chip[data-base] { border: 2px solid var(--hm-sem-fg-primary); font-weight: var(--hm-font-weight-bold); }
.hm-brand-color-chip h3, .hm-brand-color-chip p { margin: 8px 0 0; font-size: 13px; }
+1 -1
View File
@@ -6,7 +6,7 @@ const weights = ["--hm-input-font-weight", "--hm-font-weight-medium", "--hm-card
function TypographyReferencePage() {
const values = useResolvedProperties(typographyProperties)
return <ReferencePage title="Typography" intro="Current Typography Reference · 기존 HM 컴포넌트의 실제 구현 규격입니다. 새로운 Heading / Body / Caption token 체계를 정의하지 않습니다.">
<ReferenceSection title="Font Family" note="default.css의 실제 Font stack과 theme.css의 로컬 Font asset을 사용합니다."><div className="hm-foundation-panel"><code>--hm-font-family-base</code><p>{values["--hm-font-family-base"]}</p><p>Noto Sans KR · /fonts/NotoSansKR-variable.ttf · font-weight 100–900 asset</p><p className="hm-foundation-note">Asset의 지원 범위와 아래 컴포넌트의 실제 사용 굵기는 별도로 표시합니다.</p></div></ReferenceSection>
<ReferenceSection title="Font Family" note="기본 폰트는 Noto Sans KR입니다. 로딩 실패 시 맑은 고딕, 이후 시스템 sans-serif를 사용합니다."><div className="hm-foundation-panel"><p>Noto Sans KR → 맑은 고딕 → 시스템 sans-serif</p><details><summary>구현 참고 · 전체 CSS Font Stack</summary><code>--hm-font-family-base</code><p>{values["--hm-font-family-base"]}</p><p>/fonts/NotoSansKR-variable.ttf · font-weight 100–900 asset</p></details></div></ReferenceSection>
<ReferenceSection title="Font Weight" note="400(Input) · 500(Medium / Standard Button) · 600(Card title) · 700(Bold). 같은 글자열로 현재 굵기를 비교합니다."><div className="hm-foundation-grid">{weights.map(property => <article className="hm-foundation-card" key={property}><code>{property}</code><p>{values[property]}</p><p style={{ fontWeight: `var(${property})` }}>한맥 프로젝트<br />Hanmac Project<br />0123456789</p></article>)}</div></ReferenceSection>
<ReferenceSection title="Component Specimens" note="크기 / 행높이 / 굵기 / 자간은 specimen에서 computed style로 읽습니다. 자간을 별도 지정하지 않은 컴포넌트는 현재 normal을 유지합니다."><div className="hm-foundation-specimens">{typographyReferences.map(item => <article className="hm-foundation-panel" key={item.label}><h3>{item.label}</h3><p className="hm-foundation-note">packages/ui/src/theme/{item.source}</p><dl><dt>Size</dt><dd><code>{item.size}</code> · {values[item.size]}</dd><dt>Line Height</dt><dd><code>{item.line}</code> · {values[item.line]}</dd><dt>Weight</dt><dd>{item.weight ? <><code>{item.weight}</code> · {values[item.weight]}</> : "normal · 컴포넌트에서 별도 weight 지정 없음"}</dd></dl><Specimen {...item} /></article>)}</div></ReferenceSection>
</ReferencePage>
+3 -1
View File
@@ -1,2 +1,4 @@
@import "tailwindcss";
@import "tailwindcss" source("./");
@import "@hm-design/ui/tailwind.css";
/* Keep utility detection relative to this entry in both the repo and extracted ZIP. */
@source "../.storybook";
+12
View File
@@ -0,0 +1,12 @@
/* Opt-in list composition. FilterBar's general layout contract stays unchanged. */
.hm-list-search-basic, .hm-list-search-extended { gap: 8px; }
.hm-list-search-basic { justify-content: center; }
.hm-list-search-extended { justify-content: flex-start; }
/* Override Dashboard's general nowrap only for these opted-in list searches. */
[data-slot="dashboard-layout"] [data-slot="filter-bar"]:is(.hm-list-search-basic, .hm-list-search-extended) { flex-wrap: wrap; }
.hm-list-search-basic > [data-slot="filter-bar-actions"],
.hm-list-search-extended > [data-slot="filter-bar-actions"] { margin-left: 0; }
@media (max-width: 640px) {
.hm-list-search-basic > [data-slot="filter-bar-actions"],
.hm-list-search-extended > [data-slot="filter-bar-actions"] { justify-content: flex-end; }
}
+3 -1
View File
@@ -1,8 +1,10 @@
import React from "react"
import ReactDOM from "react-dom/client"
import App from "./App"
import "./index.css"
import "./hm-theme.css"
import "./patterns/dashboard-composition.css"
import "./work-dashboard/work-dashboard.css"
import App from "./App"
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
+33 -68
View File
@@ -1,6 +1,7 @@
import { useMemo, useState } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite"
import {
Card, CardContent, PageHeader, PageHeaderContent, PageHeaderTitle, PageHeaderDescription,
Badge,
Button,
Combobox,
@@ -26,6 +27,7 @@ import {
useDataTable,
type DataTableColumnDef,
} from "@hm-design/ui"
import "../list-screen.css"
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"
@@ -99,83 +101,44 @@ const statusOptions = [
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() {
function WithFilterBar({ extended = true }: { extended?: boolean }) {
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 apply = () => {
table.setGlobalFilter(search || undefined)
table.getColumn("status")?.setFilterValue(status === "all" ? undefined : status)
table.getColumn("department")?.setFilterValue(department)
table.getColumn("createdAt")?.setFilterValue(date)
}
const reset = () => {
setSearch("")
setStatus("all")
setDate(undefined)
setDepartment(undefined)
table.resetGlobalFilter(true)
table.resetColumnFilters(true)
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>
)
return <div className="grid min-w-0 gap-4">
<PageHeader><PageHeaderContent><PageHeaderTitle>사용자 목록</PageHeaderTitle><PageHeaderDescription>조건을 입력한 뒤 조회합니다.</PageHeaderDescription></PageHeaderContent></PageHeader>
<Card className="min-w-0"><CardContent>
<div className="grid min-w-0 gap-4">
<form onSubmit={event => { event.preventDefault(); apply() }}>
<FilterBar className={extended ? "hm-list-search-extended" : "hm-list-search-basic"}>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger size="medium" aria-label="상태"><SelectValue placeholder="상태" /></SelectTrigger>
<SelectContent><SelectItem value="all">전체 상태</SelectItem>{statusOptions.map(option => <SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>)}</SelectContent>
</Select>
{extended && <><Combobox controlled size="medium" aria-label="부서" options={departmentOptions} value={department} placeholder="부서" onValueChange={setDepartment} />
<DatePicker controlled size="medium" aria-label="등록일" placeholder="등록일" value={date} onValueChange={setDate} /></>}
<FilterBarSearch value={search} onValueChange={setSearch} placeholder="이름·부서 검색" />
<FilterBarActions><Button type="submit" variant="primary" appearance="solid">조회</Button><Button type="button" variant="cancel" appearance="outline" onClick={reset}>초기화</Button></FilterBarActions>
</FilterBar>
</form>
<div className="flex justify-end"><DataTableViewOptions table={table} labels={columnLabels} /></div>
<DataTable table={table} emptyMessage="조건에 맞는 사용자가 없습니다." />
</div>
</CardContent></Card>
</div>
}
const meta = { title: "Patterns/Data/Data Table", parameters: { layout: "padded", controls: { disable: true } } } satisfies Meta
@@ -196,6 +159,8 @@ export const All: Story = {
<StorySection title="Overview" description="행 선택(헤더 혼합 상태), 정렬, 열 표시, 행 작업, 페이지 크기와 페이지 이동을 실제로 조작합니다.">
<Basic />
</StorySection>
<StorySection title="기본 목록 검색" description="조건·검색어·조회·초기화를 8px 간격으로 가운데 정렬합니다. 제목은 Card 밖에 배치합니다."><WithFilterBar extended={false} /></StorySection>
<StorySection title="추가 조건 목록 검색" description="부서·등록일을 제공하는 구성은 입력값과 관계없이 왼쪽 정렬합니다. 모바일 버튼 행은 우측 정렬합니다."><WithFilterBar /></StorySection>
</StoryPage>
),
}
+16 -7
View File
@@ -1,9 +1,10 @@
import { useState } from "react"
import { useId, useState } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite"
import {
Button,
Combobox,
DatePicker,
Label,
FilterBar,
FilterBarActions,
FilterBarSearch,
@@ -24,15 +25,16 @@ const categories = [
]
function Full() {
const searchId = useId()
const [search, setSearch] = useState("")
const [status, setStatus] = useState("all")
const [category, setCategory] = useState<string | undefined>()
const [date, setDate] = useState<Date | undefined>()
const [applied, setApplied] = useState(0)
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="검색어 입력" />
<FilterBar layout="list-extended">
<Select value={status} onValueChange={setStatus}>
<SelectTrigger size="medium" aria-label="상태">
<SelectValue />
@@ -45,7 +47,10 @@ function Full() {
</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="날짜" />
<Label htmlFor={searchId} className="sr-only">검색어</Label>
<FilterBarSearch id={searchId} value={search} onValueChange={setSearch} placeholder="검색어 입력" />
<FilterBarActions>
<Button type="button" variant="primary" appearance="solid" onClick={() => setApplied(active)}>조회</Button>
<Button
type="button"
variant="cancel"
@@ -55,17 +60,15 @@ function Full() {
setStatus("all")
setCategory(undefined)
setDate(undefined)
setApplied(0)
}}
>
초기화
</Button>
<Button type="button" variant="primary" appearance="outline">
조회
</Button>
</FilterBarActions>
</FilterBar>
<p className="text-sm text-muted-foreground" role="status">
적용된 조건 {active}개
입력한 조건 {active}개 · 조회 적용 조건 {applied}개
</p>
</div>
)
@@ -89,6 +92,12 @@ export const All: Story = {
<StorySection title="Overview" description="검색 · Select · Combobox · Date Picker와 초기화 / 조회 버튼입니다. 폭이 좁으면 줄바꿈됩니다.">
<Full />
</StorySection>
<StorySection title="Usage" description="추가 조건은 list-extended, 기본 조건은 list-basic. 기본 layout은 기존 행 끝 정렬을 유지합니다. 390px에서는 검색과 액션이 줄바꿈됩니다.">
<p>입력은 draft로 유지하고 조회에만 적용합니다. 초기화는 입력과 적용값을 함께 비웁니다. 목록은 PageHeader 밖의 흰 Card 하나에 검색과 결과를 함께 배치합니다.</p>
</StorySection>
<StorySection title="Do & Don't" description="요구한 조건과 액션만 구성합니다.">
<p>Do: 공개 Label과 control ID 연결, Solid Green 조회와 Outline Gray 초기화. Don't: Story에 보이는 분류·날짜·메뉴·CSV를 요구사항 없이 추가하거나 임의 토큰으로 색을 지정하지 않습니다.</p>
</StorySection>
</StoryPage>
),
}
+7 -5
View File
@@ -1,6 +1,6 @@
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 { BellIcon, ChartColumnIcon, SearchIcon, SettingsIcon, type LucideIcon } from "lucide-react"
import {
Avatar,
AvatarFallback,
@@ -52,6 +52,7 @@ import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/compon
// the content, White 2-depth panel, text-only profile); every part inside is an HM component. The menu data, the user and the current
// location below are local sample data for this Story only (no network, no auth, no router). The desktop / mobile switch follows the
// viewport (1024px): narrow the Story to 390px to see the hamburger and the Sheet menu.
// GNBUser's default accessible name includes both the visible name and description; explicit labels must preserve them.
type Destination = { id: string; label: string }
type NavItem = { id: string; label: string; children?: Destination[] }
@@ -339,7 +340,7 @@ function StickyPage() {
// 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: "dashboard", label: "대시보드" },
{
id: "projects", label: "프로젝트 관리", children: [
{ id: "projects/design", label: "디자인 관리", children: [
@@ -366,7 +367,7 @@ const sidebarGnbItems: NavItem[] = navItems.map(item => item.id === "projects" ?
],
} : item)
function SidebarPage({ fullHeight = false }: { fullHeight?: boolean }) {
function SidebarPage({ fullHeight = false, color = "green" }: { fullHeight?: boolean; color?: "green" | "brown" }) {
const [current, setCurrent] = useState("projects/design/tokens")
const [groups, setGroups] = useState<Record<string, boolean>>({ projects: true, "projects/design": true })
const [mobileOpen, setMobileOpen] = useState(false)
@@ -400,7 +401,7 @@ function SidebarPage({ fullHeight = false }: { fullHeight?: boolean }) {
<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 }}>
<div data-hm-gnb-layout="sidebar" data-hm-color={color} style={{ height: fullHeight ? "100dvh" : 560 }}>
<Bar items={sidebarGnbItems} current={current} onCurrent={setCurrent}
mobileMenuOpen={mobileOpen} onMobileMenuOpenChange={setMobileOpen}
mobileChildren={<div data-slot="lnb-mobile-nav">{mobileMenu(sidebarNodes)}</div>} />
@@ -485,9 +486,10 @@ export const All: Story = {
<StickyPage />
</StorySection>
<StorySection title="Usage · GNB Sidebar"
description="GNB ONLY 내부 Sidebar입니다. 기존 LNB 메뉴를 그대로 사용하고 외곽 배경만 Green Light 2로 바꿉니다. 모바일은 GNB의 단일 Sheet에 같은 메뉴를 표시합니다.">
description="기존 Reference 전용 콘텐츠 LNB입니다. 본문과 같은 배경, 세로 구분선 하나, 아이콘 기본 생략·화살표 유지. 신규 AI GNB_ONLY 화면에는 Sidebar를 생성하지 않습니다.">
<SidebarPage />
</StorySection>
<StorySection title="Usage · Brown 콘텐츠 LNB" description="기존 Reference의 Brown 선택·Hover를 비교합니다. Green 선택색을 혼용하지 않습니다."><SidebarPage color="brown" /></StorySection>
</StoryPage>
),
}
+3 -2
View File
@@ -1,5 +1,6 @@
import { Fragment, useState, type CSSProperties, type MouseEvent, type ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite"
// LNBProfile's default accessible name includes the visible name and description; explicit labels must preserve them.
import { ChartColumnIcon, LayoutGridIcon, ListChecksIcon, SettingsIcon, type LucideIcon } from "lucide-react"
import {
GNB,
@@ -33,7 +34,7 @@ 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
// to the content, Black 10% hover, Black 8% 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).
@@ -651,7 +652,7 @@ export const All: Story = {
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: "expanded", label: "Expanded · 2뎁스 펼침", note: "하위 선택 없이 펼친 그룹: Black 8% 면 + 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 }} /> },
+30 -2
View File
@@ -12,6 +12,8 @@ import {
PageHeaderContent,
PageHeaderDescription,
PageHeaderTitle,
Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow,
Tabs, TabsContent, TabsList, TabsTrigger,
} from "@hm-design/ui"
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
@@ -32,9 +34,12 @@ export const All: Story = {
<StoryPageHeader
kicker={STORY_KICKER.patterns}
title="Page Header"
description="화면 콘텐츠의 제목 영역입니다. Breadcrumb, 제목, 설명과 대표 Action을 조합합니다."
description="기본형은 Breadcrumb 없이 제목 중심입니다. 설명·액션·Breadcrumb는 화면의 필요에 따라 선택 적용합니다."
/>
<StorySection title="Overview" description="단일 Brand Action을 사용하는 기본 조합입니다.">
<StorySection title="Overview" description="제목 중심 기본형입니다. Breadcrumb는 생략하고 필요한 설명·액션을 선택 적용합니다.">
<PageHeader><PageHeaderContent><PageHeaderTitle>업무 현황</PageHeaderTitle></PageHeaderContent></PageHeader>
</StorySection>
<StorySection title="Breadcrumb 포함형" description="Breadcrumb·설명·단일 Brand Action과 Primary Tabs 조합입니다. 제목→설명 8px, Header→Tabs 20px을 유지합니다.">
<PageHeader>
<PageHeaderContent>
<Breadcrumb>
@@ -61,6 +66,29 @@ export const All: Story = {
</Button>
</PageHeaderActions>
</PageHeader>
<Tabs defaultValue="mine" className="min-w-0">
<TabsList aria-label="업무 목록 보기">
<TabsTrigger value="mine">내 업무</TabsTrigger>
<TabsTrigger value="completed">완료 업무</TabsTrigger>
</TabsList>
<TabsContent value="mine" className="px-0 py-6">
<Table>
<TableCaption>내 업무 일정 예시</TableCaption>
<TableHeader><TableRow><TableHead scope="col">업무</TableHead><TableHead scope="col">마감일</TableHead><TableHead scope="col">상태</TableHead></TableRow></TableHeader>
<TableBody>
<TableRow><TableCell>화면 설계서 검토</TableCell><TableCell>10월 14일</TableCell><TableCell>검토</TableCell></TableRow>
<TableRow><TableCell>고객 요구사항 정리</TableCell><TableCell>10월 16일</TableCell><TableCell>진행</TableCell></TableRow>
</TableBody>
</Table>
</TabsContent>
<TabsContent value="completed" className="px-0 py-6">
<Table>
<TableCaption>완료 업무 예시</TableCaption>
<TableHeader><TableRow><TableHead scope="col">업무</TableHead><TableHead scope="col">완료일</TableHead><TableHead scope="col">상태</TableHead></TableRow></TableHeader>
<TableBody><TableRow><TableCell>프로젝트 기본 정보 등록</TableCell><TableCell>10월 9일</TableCell><TableCell>완료</TableCell></TableRow></TableBody>
</Table>
</TabsContent>
</Tabs>
</StorySection>
</StoryPage>
),
+80
View File
@@ -0,0 +1,80 @@
import type { CSSProperties } from "react"
import {
countDashboardFilter,
dashboardFilterDefinitions,
dashboardFilterIds,
dashboardStatusDefinitions,
matchesDashboardFilter,
type DashboardFilterId,
type DashboardStatusId,
} from "@hm-design/ui"
// Recruitment sample data. Hiring progress maps onto the shared Dashboard status contract:
// active = 전형 진행, hold = 보류, done = 완료(최종 결정), issue = 독립 flag(서류 누락·일정 조율 등).
export type RecruitStatus = DashboardStatusId
export type RecruitFilter = DashboardFilterId
export type RecruitStage = "서류 심사" | "1차 면접" | "2차 면접" | "처우 협의" | "최종 합격"
export type Candidate = {
id: string
name: string
position: string
department: string
stage: RecruitStage
status: RecruitStatus
issue?: boolean
appliedAt: string
}
export const recruitStages: RecruitStage[] = ["서류 심사", "1차 면접", "2차 면접", "처우 협의", "최종 합격"]
export const recruitStatus = {
all: { label: dashboardFilterDefinitions.all.label, color: "var(--hm-sem-palette-yellow-accent)" },
active: { label: dashboardStatusDefinitions.active.label, color: "var(--hm-dashboard-status-active)" },
hold: { label: dashboardStatusDefinitions.hold.label, color: "var(--hm-dashboard-status-hold)" },
done: { label: dashboardStatusDefinitions.done.label, color: "var(--hm-dashboard-status-done)" },
issue: { label: dashboardFilterDefinitions.issue.label, color: "var(--hm-dashboard-status-issue)" },
} satisfies Record<RecruitFilter, { label: string; color: string }>
export const recruitFilters = dashboardFilterIds
export const recruitStatusStyle = (status: RecruitFilter): CSSProperties =>
({ "--dashboard-status-color": recruitStatus[status].color }) as CSSProperties
export const matchesRecruitStatus = (candidate: Candidate, filter: RecruitFilter) => matchesDashboardFilter(candidate, filter)
export const countRecruitFilter = (candidates: readonly Candidate[], filter: RecruitFilter) => countDashboardFilter(candidates, filter)
const surnames = ["김", "이", "박", "최", "정", "강", "조", "윤", "장", "임"]
const givenNames = ["민준", "서연", "지호", "하은", "도윤", "수아", "예준", "지우", "시우", "채원", "현우", "유진"]
const positions: { position: string; department: string }[] = [
{ position: "구조 설계 엔지니어", department: "설계팀" },
{ position: "현장 시공 관리자", department: "시공팀" },
{ position: "안전 관리자", department: "안전관리팀" },
{ position: "품질 검사원", department: "품질팀" },
{ position: "인사·총무 담당", department: "경영지원팀" },
]
// stage / status / issue are assigned from a fixed table so the data is deterministic and every combination appears.
const progress: { stage: RecruitStage; status: RecruitStatus }[] = [
{ stage: "서류 심사", status: "active" },
{ stage: "1차 면접", status: "active" },
{ stage: "2차 면접", status: "hold" },
{ stage: "처우 협의", status: "active" },
{ stage: "최종 합격", status: "done" },
{ stage: "서류 심사", status: "hold" },
{ stage: "1차 면접", status: "done" },
]
export const candidates: Candidate[] = Array.from({ length: 36 }, (_, i) => {
const flow = progress[i % progress.length]
const job = positions[(i * 3) % positions.length]
return {
id: `R-${String(i + 1).padStart(3, "0")}`,
name: `${surnames[i % surnames.length]}${givenNames[(i * 5) % givenNames.length]}`,
position: job.position,
department: job.department,
stage: flow.stage,
status: flow.status,
issue: i % 9 === 0,
appliedAt: new Date(2026, 8, 1 + (i % 30)).toISOString(),
}
})
+265
View File
@@ -0,0 +1,265 @@
import { useMemo, useState } from "react"
import "../list-screen.css"
import { MoreHorizontalIcon } from "lucide-react"
import {
Badge,
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
Button,
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
DashboardContent,
DashboardHeader,
DashboardLayout,
DashboardMain,
DataTable,
DataTableColumnHeader,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
FilterBar,
FilterBarActions,
FilterBarSearch,
PageHeader,
PageHeaderActions,
PageHeaderContent,
PageHeaderDescription,
PageHeaderTitle,
Progress,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
dataTableSelectColumn,
useDataTable,
type DataTableColumnDef,
} from "@hm-design/ui"
import { formatDate } from "@/patterns/sample-users"
import {
candidates as sampleCandidates,
countRecruitFilter,
matchesRecruitStatus,
recruitFilters,
recruitStages,
recruitStatus,
recruitStatusStyle,
type Candidate,
type RecruitFilter,
type RecruitStage,
} from "./recruit-data"
// Compact Dashboard Header and Dashboard Status → Badge mapping are the existing Dashboard composition styles (no new CSS).
import "@/patterns/dashboard-composition.css"
// DASHBOARD ONLY: 전용 Header(48px) + 업무 상태 필터 + Content. No GNB, LNB or Sidebar (docs/patterns/dashboard-layout.md).
// Selection reason: 채용 관리 is a status-driven operations screen (진행/보류/완료/이슈 counts first), not a menu-driven one.
const statusBadge = (status: Exclude<RecruitFilter, "all">) => (
<Badge variant="outline" data-status={status} data-issue={status === "issue" ? "true" : undefined} style={recruitStatusStyle(status)}>
{recruitStatus[status].label}
</Badge>
)
const columns: DataTableColumnDef<Candidate>[] = [
dataTableSelectColumn<Candidate>(),
{ accessorKey: "name", header: ({ column }) => <DataTableColumnHeader column={column} title="지원자" /> },
{ accessorKey: "position", header: "지원 직무" },
{ accessorKey: "department", header: "부서" },
{ accessorKey: "stage", header: "전형 단계", filterFn: (row, _columnId, value: RecruitStage) => row.original.stage === value },
{
accessorKey: "status",
header: "상태",
enableSorting: false,
cell: ({ row }) => (
<span data-story-slot="work-badges">
{statusBadge(row.original.status)}
{row.original.issue ? statusBadge("issue") : null}
</span>
),
},
{
accessorKey: "appliedAt",
header: ({ column }) => <DataTableColumnHeader column={column} title="지원일" />,
cell: ({ row }) => formatDate(row.original.appliedAt),
},
]
function StatusHeader({ data, status, onStatusChange }: { data: Candidate[]; status: RecruitFilter; onStatusChange: (status: RecruitFilter) => void }) {
const filterButton = (id: RecruitFilter) => (
<Button key={id} type="button" variant="ghost" size="sm" data-filter={id} data-status={id} data-issue={id === "issue" ? "true" : undefined} style={recruitStatusStyle(id)} aria-pressed={status === id} aria-controls="recruit-results" onClick={() => onStatusChange(id)}>
{recruitStatus[id].label} <strong>{countRecruitFilter(data, id)}</strong>건
</Button>
)
const [all, ...rest] = recruitFilters
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">{rest.filter((id) => id !== "issue").map(filterButton)}</div>
<span data-story-slot="compact-divider" aria-hidden="true" />
{filterButton("issue")}
</div>
</DashboardHeader>
)
}
function StageSummary({ data }: { data: Candidate[] }) {
return (
<div className="grid gap-6" style={{ gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 160px), 1fr))" }}>
{recruitStages.map((stage) => {
const count = data.filter((candidate) => candidate.stage === stage).length
const ratio = data.length ? Math.round((count / data.length) * 100) : 0
return (
<Card key={stage}>
<CardHeader>
<CardDescription>{stage}</CardDescription>
<CardAction>
<Badge variant="secondary">{ratio}%</Badge>
</CardAction>
</CardHeader>
<CardContent>
<div className="grid gap-3">
<p style={{ margin: 0, fontSize: 28, lineHeight: "36px", fontWeight: 700 }}>{count}명</p>
<Progress value={ratio} aria-label={`${stage} 비율`} />
</div>
</CardContent>
</Card>
)
})}
</div>
)
}
function CandidateList({ data, status, onReset }: { data: Candidate[]; status: RecruitFilter; onReset: () => void }) {
const table = useDataTable({ columns, data, getRowId: (row) => row.id })
const [search, setSearch] = useState("")
const [stage, setStage] = useState("all")
const total = table.getFilteredRowModel().rows.length
return (
<Card>
<CardHeader>
<CardTitle>지원자 목록</CardTitle>
<CardDescription role="status">
{recruitStatus[status].label} 지원자 {total}명
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4">
<FilterBar className="hm-list-search-basic">
<Select
value={stage}
onValueChange={(value) => {
setStage(value)
}}
>
<SelectTrigger size="medium" aria-label="전형 단계">
<SelectValue placeholder="전형 단계" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">전체 단계</SelectItem>
{recruitStages.map((item) => (
<SelectItem key={item} value={item}>
{item}
</SelectItem>
))}
</SelectContent>
</Select>
<FilterBarSearch value={search} onValueChange={setSearch} placeholder="지원자·직무 검색" />
<FilterBarActions>
<Button type="button" variant="primary" appearance="solid" onClick={() => {
table.setGlobalFilter(search || undefined)
table.getColumn("stage")?.setFilterValue(stage === "all" ? undefined : stage)
}}>조회</Button>
<Button
type="button"
variant="cancel"
appearance="outline"
onClick={() => {
setSearch("")
setStage("all")
table.resetGlobalFilter(true)
table.resetColumnFilters(true)
onReset()
}}
>
초기화
</Button>
</FilterBarActions>
</FilterBar>
<DataTable table={table} emptyMessage="조건에 맞는 지원자가 없습니다." />
</div>
</CardContent>
</Card>
)
}
export function RecruitScreen({ data = sampleCandidates }: { data?: Candidate[] }) {
const [status, setStatus] = useState<RecruitFilter>("all")
const visible = useMemo(() => data.filter((candidate) => matchesRecruitStatus(candidate, status)), [data, status])
return (
<DashboardLayout className="dashboard-production dashboard-story-compact" header={<StatusHeader data={data} status={status} onStatusChange={setStatus} />}>
<DashboardMain id="recruit-results">
<PageHeader>
<PageHeaderContent>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="#home">홈</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink href="#hr">인사</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>채용 관리</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<PageHeaderTitle>채용 관리</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>
<StageSummary data={visible} />
<CandidateList data={visible} status={status} onReset={() => setStatus("all")} />
</DashboardContent>
</DashboardMain>
</DashboardLayout>
)
}
+25 -18
View File
@@ -1,10 +1,12 @@
import { useEffect, useRef, useState, useSyncExternalStore } from "react"
import { flushSync } from "react-dom"
import type { Meta, StoryObj } from "@storybook/react-vite"
import { defaultRuntimeTheme, runtimeThemeContract, fetchRuntimeTheme, type RuntimeThemeToken } from "@hm-design/ui/theme"
import { designerThemeStore } from "./designer-preview"
import { Specimens } from "./specimens"
import { DesignerExtras } from "./designer-extras"
import { StoryPage } from "@/components/story-page"
import { VerificationStyles, waitForThemeState } from "./verification-state"
import "./designer-theme.css"
const roles = [
@@ -12,7 +14,7 @@ const roles = [
["color.state.active", "Active / Selected", "Checkbox · Radio · Switch · Calendar"],
["color.on.state.active", "On Active", "선택된 컨트롤의 아이콘과 글자"],
["color.stroke.input.focus", "Focus", "입력 테두리 · Checkbox / Calendar 포커스 링"],
["color.accent", "Accent", "메뉴 · Calendar · Table · Pagination의 45% 강조 면"],
["color.accent", "Accent", "메뉴 · Calendar · Pagination 45%, Table 행 20% 강조 면"],
["color.on.accent", "On Accent", "강조 면의 글자"],
["color.action.destructive", "Destructive", "오류 · Invalid · Destructive Button / Badge"],
["color.bg.surface", "Surface", "입력 · Card · Popup"],
@@ -110,7 +112,7 @@ function ThemeEditor() {
return <StoryPage><main className="hm-designer">
<header className="hm-designer-header">
<div><p>HM DESIGN SYSTEM / COLOR THEME</p><h1>디자인팀 Theme Playground</h1>
<p>컬러를 바꾸면 이 화면과 다른 Story의 UI에 즉시 반영됩니다. 현재 탭의 세션 동안 유지됩니다.</p></div>
<p>임시 컬러는 이 편집기와 Live Preview에만 적용됩니다. 편집값은 현재 탭의 세션 동안 보관하며 다른 Story는 HM Default로 검수합니다.</p></div>
<button type="button" onClick={reset}>Reset to HM Default</button>
</header>
<div className="hm-designer-layout">
@@ -156,21 +158,20 @@ function ThemeEditor() {
</main></StoryPage>
}
const meta = { title: "Theme/Designer Playground", parameters: { layout: "fullscreen" } } satisfies Meta
const meta = { title: "Theme/Designer Playground", tags: ["internal"], parameters: { layout: "fullscreen" } } satisfies Meta
export default meta
type Story = StoryObj<typeof meta>
export const Editor: Story = { name: "Designer Playground", render: () => <ThemeEditor /> }
export const LiveVerification: Story = {
tags: ["verification"],
render: () => <><output data-testid="designer-result">Ready</output><ThemeEditor /></>,
render: () => <div data-hm-theme-verification><VerificationStyles /><output data-testid="designer-result">Ready</output><ThemeEditor /></div>,
play: async ({ canvasElement }) => {
const result = canvasElement.querySelector<HTMLOutputElement>('[data-testid="designer-result"]')!
const preview = canvasElement.querySelector<HTMLElement>('[aria-label="Live component preview"]')!
const origin = performance.timeOrigin
const checks: string[] = []
const assert = (ok: boolean, label: string) => { if (!ok) throw Error(label); checks.push(label) }
const settle = () => new Promise(resolve => setTimeout(resolve, 250))
const variable = (property: string) => getComputedStyle(document.documentElement).getPropertyValue(property).trim().toLowerCase()
const style = (selector: string, property: "backgroundColor" | "color" | "borderTopColor") => getComputedStyle(preview.querySelector<HTMLElement>(selector)!)[property]
const geometry = () => Array.from(preview.querySelectorAll<HTMLElement>("[data-slot]")).map(element => {
@@ -180,13 +181,14 @@ export const LiveVerification: Story = {
const setInput = (label: string, value: string) => {
const input = canvasElement.querySelector<HTMLInputElement>(`[aria-label="${label}"]`)!
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, value)
input.dispatchEvent(new Event("input", { bubbles: true }))
flushSync(() => input.dispatchEvent(new Event("input", { bubbles: true })))
}
const click = (label: string) => Array.from(canvasElement.querySelectorAll<HTMLButtonElement>("button")).find(button => button.textContent === label)!.click()
const click = (label: string) => flushSync(() => Array.from(canvasElement.querySelectorAll<HTMLButtonElement>("button")).find(button => button.textContent === label)!.click())
try {
result.textContent = "Running designer workflow verification…"
await document.fonts.ready
click("Reset to HM Default"); await settle()
click("Reset to HM Default")
await waitForThemeState(() => roles.every(([token, label]) => canvasElement.querySelector<HTMLInputElement>(`[aria-label="${label} HEX"]`)?.value === defaultRuntimeTheme.tokens[token]), "Designer default inputs")
const initial = JSON.stringify(geometry())
for (const [token, label] of roles) {
assert(canvasElement.querySelector<HTMLInputElement>(`[aria-label="${label} HEX"]`)!.value === defaultRuntimeTheme.tokens[token], `${label}: auto-loaded HM Default`)
@@ -200,14 +202,17 @@ export const LiveVerification: Story = {
] as const
for (const [label, property, value] of core) {
const others = core.filter(row => row[1] !== property).map(row => [row[1], variable(row[1])])
setInput(`${label} HEX`, value); await settle()
setInput(`${label} HEX`, value)
await waitForThemeState(() => variable(property) === value, `${label}: live input`)
assert(variable(property) === value, `${label}: input applies live`)
if (label === "Primary / Brand") assert(canvasElement.querySelector<HTMLInputElement>('[aria-label="On Accent HEX"]')!.value === variable("--hm-sem-on-accent"), "On Accent displays its actual resolved role")
for (const [key, previous] of others) assert(variable(key) === previous, `${label}: leaves ${key} independent`)
assert(JSON.stringify(geometry()) === initial, `${label}: component geometry unchanged`)
assert(performance.timeOrigin === origin && preview.isConnected, `${label}: same document, reload 0`)
}
assert(style('[data-slot="checkbox"][aria-label="Checked"]', "backgroundColor") === "rgb(0, 127, 115)", "Checkbox fill uses Active")
const checked = preview.querySelector<HTMLElement>('[data-slot="checkbox"][aria-label="Checked"]')!
await waitForThemeState(() => checked.dataset.state === "checked" && checked.getAttribute("aria-checked") === "true" && style('[data-slot="checkbox"][aria-label="Checked"]', "backgroundColor") === "rgb(0, 127, 115)", "Designer checked Checkbox fill")
assert(checked.dataset.state === "checked" && checked.getAttribute("aria-checked") === "true" && style('[data-slot="checkbox"][aria-label="Checked"]', "backgroundColor") === "rgb(0, 127, 115)", "Checkbox fill uses Active")
assert(style('[data-slot="radio-group-item"]', "borderTopColor") === "rgb(0, 127, 115)", "Radio uses Active")
assert(style('[data-slot="switch"]', "backgroundColor") === "rgb(0, 127, 115)", "Switch uses Active")
assert(style('[data-slot="slider-range"]', "backgroundColor") === "rgb(0, 127, 115)", "Slider uses Active")
@@ -215,29 +220,31 @@ export const LiveVerification: Story = {
assert(style('[data-slot="badge"]', "backgroundColor") === "rgb(104, 55, 168)", "Badge uses Primary")
assert(style('[data-slot="pagination-link"][data-active="true"]', "backgroundColor") === "rgb(104, 55, 168)", "Pagination current uses Primary")
for (const property of ["--hm-select-option-state-background", "--hm-calendar-day-hover-background", "--hm-calendar-range-middle-background", "--hm-pagination-hover-background", "--hm-table-row-hover-background", "--hm-table-row-selected-background", "--hm-command-highlight-background", "--hm-dropdown-menu-highlight-background", "--hm-context-menu-highlight-background", "--hm-menubar-highlight-background"])
assert(variable(property).includes("#ffd36d 45%"), `${property}: live Accent 45%`)
assert(variable(property).includes(`#ffd36d ${property.startsWith("--hm-table-row") ? 20 : 45}%`), `${property}: live Accent at its component opacity`)
click("Show JSON")
await settle()
const transfer = canvasElement.querySelector<HTMLTextAreaElement>('[aria-label="Theme JSON"]')!
const exported = transfer.value
assert(JSON.parse(exported).tokens["color.state.active"] === "#007f73", "Export is official partial contract JSON")
click("Reset to HM Default"); await settle()
click("Reset to HM Default")
assert(JSON.stringify(geometry()) === initial && variable("--hm-sem-state-active") === "#266651", "Reset restores HM Default immediately")
const fillJson = (value: string) => {
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!.call(transfer, value)
transfer.dispatchEvent(new Event("input", { bubbles: true }))
flushSync(() => transfer.dispatchEvent(new Event("input", { bubbles: true })))
}
fillJson(exported); await settle(); click("Import JSON"); await settle()
fillJson(exported); click("Import JSON")
await waitForThemeState(() => variable("--hm-sem-state-active") === "#007f73", "Designer JSON import")
assert(variable("--hm-sem-state-active") === "#007f73" && variable("--hm-sem-stroke-input-focus") === "#c75b00", "Export -> Import round trip applies immediately")
for (const invalid of ["not json", '{"schemaVersion":1,"tokens":{"color.action.primary":"red"}}', '{"schemaVersion":1,"tokens":{"radius.input.default":"7px"}}', '{"schemaVersion":1,"tokens":{"--hm-checkbox-checked-background":"#123456"}}']) {
fillJson(invalid); await settle(); click("Import JSON"); await settle()
fillJson(invalid); click("Import JSON")
assert(variable("--hm-sem-state-active") === "#007f73", "Invalid/color-only import preserves last valid Theme")
}
fillJson('{"schemaVersion":1,"tokens":{"color.state.active":"#345678"}}'); await settle(); click("Import JSON"); await settle()
fillJson('{"schemaVersion":1,"tokens":{"color.state.active":"#345678"}}'); click("Import JSON")
await waitForThemeState(() => variable("--hm-sem-state-active") === "#345678", "Designer partial import")
assert(variable("--hm-sem-state-active") === "#345678" && variable("--hm-sem-action-primary") === "#123328" && variable("--hm-sem-stroke-input-focus") === "#266651", "Partial import replaces previous keys; unspecified roles use Default")
for (const [index, [token, label]] of roles.entries()) {
const value = `#3456${(120 + index).toString(16)}`
setInput(`${label} HEX`, value); await settle()
setInput(`${label} HEX`, value)
await waitForThemeState(() => designerThemeStore.getSnapshot().tokens[token] === value, `${label}: editor role`)
assert(designerThemeStore.getSnapshot().tokens[token] === value, `${label}: editor role works`)
}
assert(JSON.stringify(geometry()) === initial, "All color roles leave component layout unchanged")
+7 -6
View File
@@ -2,12 +2,14 @@ import type { Meta, StoryObj } from "@storybook/react-vite"
import { applyRuntimeTheme, resetRuntimeTheme, fetchRuntimeTheme, loadRuntimeTheme, resolveRuntimeTheme, defaultRuntimeTheme, runtimeThemeContract } from "@hm-design/ui/theme"
import { Specimens } from "./specimens"
import { DesignerExtras } from "./designer-extras"
import { VerificationStyles, waitForThemeState } from "./verification-state"
const meta = { title: "Theme/Runtime Contract/Loader", tags: ["verification"], parameters: { layout: "fullscreen" } } satisfies Meta
export default meta
type Story = StoryObj<typeof meta>
export const CdnVerification: Story = {
render: () => <main>
render: () => <main data-hm-theme-verification>
<VerificationStyles />
<h1>Runtime JSON Loader verification</h1>
<button onClick={event => void verifyLoader(event.currentTarget.parentElement!).catch(() => undefined)}>Run loader verification</button>
<output data-testid="loader-result">Ready</output>
@@ -28,25 +30,24 @@ async function verifyLoader(container: HTMLElement) {
const style = getComputedStyle(element)
return [element.dataset.slot, ...["width", "height", "padding", "margin", "borderRadius", "gap", "fontSize", "lineHeight", "display"].map(key => style[key as keyof CSSStyleDeclaration])]
})
const settle = () => new Promise(resolve => setTimeout(resolve, 250))
const dataUrl = (value: unknown) => `data:application/json,${encodeURIComponent(JSON.stringify(value))}`
const originalFetch = globalThis.fetch
try {
result.textContent = "Running JSON Loader verification…"
await document.fonts.ready
resetRuntimeTheme(root); await settle()
resetRuntimeTheme(root)
const initial = JSON.stringify(geometry())
const origin = performance.timeOrigin
const defaults = await fetchRuntimeTheme("/themes/default.json", { cache: "no-store" })
assert(Object.keys(defaults.tokens).length === 71, "Default JSON exposes 71 color contract entries")
for (const [key, value] of Object.entries(defaults.tokens)) assert(defaultRuntimeTheme.tokens[key as keyof typeof runtimeThemeContract] === value, `${key}: JSON equals generated package fallback`)
assert(await loadRuntimeTheme(root, "/themes/default.json", { request: { cache: "no-store" } }), "Default JSON uses official URL Loader")
await settle()
assert(JSON.stringify(geometry()) === initial, "Default JSON preserves component geometry")
assert(await loadRuntimeTheme(root, "/themes/service-example.json", { request: { cache: "no-store" } }), "External partial JSON uses same URL Loader")
await settle()
for (const [property, value] of [["--hm-sem-action-primary", "#6837a8"], ["--hm-sem-state-active", "#007f73"], ["--hm-sem-stroke-input-focus", "#c75b00"], ["--hm-sem-accent", "#ffd36d"], ["--hm-sem-action-destructive", "#d41466"]]) assert(variable(property) === value, `${property}: independent external role`)
assert(getComputedStyle(container.querySelector<HTMLElement>('[data-slot="checkbox"][aria-label="Checked"]')!).backgroundColor === "rgb(0, 127, 115)", "HTTP JSON reaches rendered Checkbox")
const checked = container.querySelector<HTMLElement>('[data-slot="checkbox"][aria-label="Checked"]')!
await waitForThemeState(() => checked.dataset.state === "checked" && checked.getAttribute("aria-checked") === "true" && getComputedStyle(checked).backgroundColor === "rgb(0, 127, 115)", "HTTP JSON reaches checked Checkbox")
assert(checked.dataset.state === "checked" && checked.getAttribute("aria-checked") === "true" && getComputedStyle(checked).backgroundColor === "rgb(0, 127, 115)", "HTTP JSON reaches rendered Checkbox")
assert(JSON.stringify(geometry()) === initial, "External JSON preserves component geometry")
const resolved = resolveRuntimeTheme({ schemaVersion: 1, tokens: { "color.state.active": "#345678" } }, defaults)
assert(resolved.tokens["color.action.primary"] === "#123328" && resolved.tokens["color.state.active"] === "#345678", "Default JSON + partial service override merge")
+28 -11
View File
@@ -66,7 +66,8 @@ const shadowHasColor = (shadow: string, hex: string) => {
const subscribeViewport = (notify: () => void) => { window.addEventListener("resize", notify); return () => window.removeEventListener("resize", notify) }
function BaselineSpecimens() {
const mobile = useSyncExternalStore(subscribeViewport, () => window.matchMedia("(max-width: 1023px)").matches)
const values = mobile ? { ...baseline, "--hm-gnb-height": "56px", "--hm-gnb-padding-x": "16px", "--hm-lnb-width": "0px", "--hm-dashboard-padding": "20px" } : baseline
const reviewed = { ...baseline, "--hm-table-row-hover-background": "color-mix(in srgb, var(--hm-sem-accent) 20%, transparent)", "--hm-table-row-selected-background": "color-mix(in srgb, var(--hm-sem-accent) 20%, transparent)", "--hm-lnb-group-open-background": "color-mix(in srgb, var(--hm-sem-palette-black) 8%, transparent)" }
const values = mobile ? { ...reviewed, "--hm-gnb-height": "56px", "--hm-gnb-padding-x": "16px", "--hm-lnb-width": "0px", "--hm-dashboard-padding": "20px" } : reviewed
return <div data-testid="before" data-hm-theme="baseline" style={values as CSSProperties}><Specimens /></div>
}
@@ -75,7 +76,8 @@ export const Verification: Story = {
{/* Compare settled state targets, independent of background-tab animation clocks. */}
<style>{'[data-testid="before"] *, [data-testid="after"] * { transition-duration: 0s !important; }'}</style>
<h1 style={{ padding: 20 }}>Runtime Theme integration verification</h1>
<button style={{ margin: "0 20px" }} onClick={event => { void verifyRuntime(event.currentTarget.parentElement!).catch(() => undefined) }}>Run verification</button>
<p style={{ padding: "0 20px" }}>키보드 Focus는 Tab으로 아래 버튼에 이동한 뒤 Enter 또는 Space로 실행하세요. 포인터·자동 실행에서는 KEYBOARD_FOCUS: NOT VERIFIED로 표시합니다.</p>
<button style={{ margin: "0 20px" }} onClick={event => { void verifyRuntime(event.currentTarget.parentElement!, event.detail === 0 && event.currentTarget.matches(":focus-visible")).catch(() => undefined) }}>Run verification</button>
<output data-testid="result" style={{ display: "block", padding: 20 }}>Ready — Run verification</output>
<BaselineSpecimens />
<div data-testid="after" data-hm-theme="preset"><Specimens /></div>
@@ -83,12 +85,17 @@ export const Verification: Story = {
play: async ({ canvasElement }) => verifyRuntime(canvasElement),
}
async function verifyRuntime(canvasElement: HTMLElement) {
async function verifyRuntime(canvasElement: HTMLElement, keyboardFocus = false) {
const before = canvasElement.querySelector<HTMLElement>('[data-testid="before"]')!
const after = canvasElement.querySelector<HTMLElement>('[data-testid="after"]')!
const result = canvasElement.querySelector<HTMLOutputElement>('output')!
if (result.dataset.running === "true") return
result.dataset.running = "true"
// Compare neutral specimens regardless of the user's previous focus or Tab
// navigation during asynchronous measurement. Focus is tested separately.
const previousInert = [before.inert, after.inert]
before.inert = true
after.inert = true
const checks: string[] = []
const assert = (condition: boolean, label: string) => { if (!condition) throw Error(label); checks.push(label) }
const variable = (name: string) => {
@@ -128,14 +135,24 @@ async function verifyRuntime(canvasElement: HTMLElement) {
assert(getComputedStyle(checked).backgroundColor === "rgb(0, 127, 115)", "Rendered Checkbox uses Active")
assert(getComputedStyle(badge).backgroundColor === "rgb(104, 55, 168)", "Rendered Badge uses Primary")
assert(getComputedStyle(selectedDay).backgroundColor === "rgb(0, 127, 115)", "Rendered Calendar selected uses Active")
checked.focus()
assert(shadowHasColor(getComputedStyle(checked).boxShadow, "#c75b00"), "Actual Checkbox focus uses independent Focus")
selectedDay.focus()
assert(shadowHasColor(getComputedStyle(selectedDay).boxShadow, "#c75b00"), "Actual Calendar focus uses independent Focus")
selectedDay.blur()
if (keyboardFocus) {
after.inert = false
checked.focus()
assert(checked.matches(":focus-visible") && shadowHasColor(getComputedStyle(checked).boxShadow, "#c75b00"), "Keyboard Checkbox focus uses independent Focus")
selectedDay.focus()
assert(selectedDay.matches(":focus-visible") && shadowHasColor(getComputedStyle(selectedDay).boxShadow, "#c75b00"), "Keyboard Calendar focus uses independent Focus")
selectedDay.blur()
after.inert = true
}
await settle(after)
assert(variable("--hm-sem-bg-surface") === "#ffffff", "Partial override preserves unspecified Default")
for (const key of ["--hm-select-option-state-background", "--hm-calendar-range-middle-background", "--hm-table-row-selected-background", "--hm-pagination-hover-background", "--hm-command-highlight-background"])
assert(variable("--hm-table-row-selected-background").includes("#ffd36d 20%"), "Table receives Accent 20% independently")
applyRuntimeTheme(after, { schemaVersion: 1, tokens: { "color.action.cancel": "#345678" } })
assert(variable("--hm-button-cancel-runtime-ink") === "#345678", "Custom public cancel role retains Button mapping")
resetRuntimeTheme(after)
assert(after.style.getPropertyValue("--hm-button-cancel-runtime-ink") === "", "Cancel mapping cleaned on Reset")
applyRuntimeTheme(after, external)
for (const key of ["--hm-select-option-state-background", "--hm-calendar-range-middle-background", "--hm-pagination-hover-background", "--hm-command-highlight-background"])
assert(variable(key).includes("#ffd36d 45%"), `${key} receives Accent 45%`)
// Exhaustively inject every public entry, independently, through the same DOM scope.
for (const [key,def] of Object.entries(runtimeThemeContract)) {
@@ -179,11 +196,11 @@ async function verifyRuntime(canvasElement: HTMLElement) {
applyRuntimeTheme(after, external); resetRuntimeTheme(after)
assert(after.style.getPropertyValue("--hm-sem-state-active") === "#112233" && after.style.getPropertyPriority("--hm-sem-state-active") === "important", "Cleanup restores previous inline value and priority")
after.style.removeProperty("--hm-sem-state-active")
result.textContent = `PASS — ${checks.length} checks; ${initial.length} rendered parts; default parity, all public tokens, independent roles, partial/empty/invalid, switching, cleanup, global/scoped, geometry.`
result.textContent = `PASS — ${checks.length} checks; ${initial.length} rendered parts; default parity, all public tokens, independent roles, partial/empty/invalid, switching, cleanup, global/scoped, geometry. KEYBOARD_FOCUS: ${keyboardFocus ? "PASS" : "NOT VERIFIED"}.`
} catch (error) {
result.textContent = `FAIL after ${checks.length} checks — ${String(error)}`
throw error
} finally { resetRuntimeTheme(after); result.dataset.running = "false" }
} finally { resetRuntimeTheme(after); before.inert = previousInert[0]; after.inert = previousInert[1]; result.dataset.running = "false" }
}
export const Dashboard: Story = { render: () => <DashboardLayout
+14
View File
@@ -0,0 +1,14 @@
// Verification-only: compare settled CSS targets even when an inactive tab's
// animation clock stops. Editor/production transitions keep their normal timing.
export function VerificationStyles() {
return <style>{'[data-hm-theme-verification] *, [data-hm-theme-verification] *::before, [data-hm-theme-verification] *::after { transition-duration: 0s !important; transition-delay: 0s !important; }'}</style>
}
export async function waitForThemeState(ready: () => boolean, label: string) {
const deadline = performance.now() + 5000
while (!ready()) {
if (performance.now() >= deadline) throw Error(`Theme state did not settle: ${label}`)
// Retry a concrete condition; elapsed time alone never makes a check pass.
await new Promise<void>(resolve => setTimeout(resolve, 25))
}
}
+205
View File
@@ -0,0 +1,205 @@
import { useMemo, useRef, useState, type FormEvent, type MouseEvent } from "react"
import { AlertCircle, ArrowUpRight, CalendarDays, CheckCheck, ClipboardList, Clock3, Plus, Search } from "lucide-react"
import {
Avatar, AvatarFallback, Badge, Button, Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle,
GNB, GNBBrand, GNBNav, GNBMobileMenu, GNBMobileLink, GNBUtilities, GNBUser,
NavigationMenu, NavigationMenuList, NavigationMenuItem, NavigationMenuLink,
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuItem,
DataTable, DataTableColumnHeader, DatePicker, dataTableSelectColumn, useDataTable,
Dialog, DialogBody, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
FilterBar, FilterBarActions, FilterBarSearch, Input, Label,
PageHeader, PageHeaderActions, PageHeaderContent, PageHeaderDescription, PageHeaderTitle,
Progress, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Switch, Textarea,
countDashboardFilter, dashboardFilterDefinitions, dashboardFilterIds, dashboardStatusDefinitions, matchesDashboardFilter,
type DashboardFilterId, type DashboardStatusId, type DataTableColumnDef,
} from "@hm-design/ui"
import { departments, loadWorks, projects, storageKey, type Work } from "./data"
// GNB_ONLY is requested by the user. Dashboard work status remains a content filter, separate from navigation.
type Conditions = { query: string; department: string }
const emptyConditions: Conditions = { query: "", department: "all" }
const sections = [
{ id: "overview", label: "대시보드" },
{ id: "work-results", label: "업무 목록" },
{ id: "project-overview", label: "프로젝트" },
{ id: "work-deadlines", label: "마감 일정" },
]
function statusBadges(work: Work) {
return <span className="work-badges">
<Badge variant="outline" data-status={work.status}>{dashboardStatusDefinitions[work.status].label}</Badge>
{work.issue && <Badge variant="outline" data-status="issue" data-issue="true">이슈</Badge>}
</span>
}
export default function WorkDashboard() {
const [works, setWorks] = useState(loadWorks)
const [status, setStatus] = useState<DashboardFilterId>("all")
const [draft, setDraft] = useState<Conditions>(emptyConditions)
const [applied, setApplied] = useState<Conditions>(emptyConditions)
const [editor, setEditor] = useState<Work | null>(null)
const [notice, setNotice] = useState("")
const [section, setSection] = useState("overview")
const mainRef = useRef<HTMLElement>(null)
const goToSection = (id: string, event?: MouseEvent<HTMLAnchorElement>) => {
event?.preventDefault()
setSection(id)
if (id === "overview") mainRef.current?.scrollTo({ top: 0, behavior: "instant" })
else document.getElementById(id)?.scrollIntoView({ block: "start", behavior: "instant" })
}
const count = (filter: DashboardFilterId) => countDashboardFilter(works, filter)
const completion = works.length ? Math.round(count("done") / works.length * 100) : 0
const today = new Intl.DateTimeFormat("sv-SE", { timeZone: "Asia/Seoul" }).format(new Date())
const dateLabel = new Intl.DateTimeFormat("ko-KR", { timeZone: "Asia/Seoul", month: "long", day: "numeric", weekday: "long" }).format(new Date())
const visibleWorks = useMemo(() => {
const query = applied.query.trim().toLocaleLowerCase()
return works.filter(work => matchesDashboardFilter(work, status)
&& (applied.department === "all" || work.department === applied.department)
&& [work.name, work.owner, work.project, work.id].join(" ").toLocaleLowerCase().includes(query))
}, [works, status, applied])
const columns = useMemo<DataTableColumnDef<Work>[]>(() => [
dataTableSelectColumn<Work>(),
{ accessorKey: "name", sortFn: "alphanumeric", header: ({ column }) => <DataTableColumnHeader column={column} title="업무명" />,
cell: ({ row }) => <div className="work-name-cell">
<Button variant="link" size="sm" className="work-name-link" onClick={() => setEditor({ ...row.original })}>{row.original.name}</Button>
<span className="work-meta">{row.original.id} · {row.original.project}</span>
</div> },
{ accessorKey: "status", header: "상태", enableSorting: false, cell: ({ row }) => statusBadges(row.original) },
{ accessorKey: "owner", header: "담당자", cell: ({ row }) => <div className="work-owner"><Avatar className="size-7"><AvatarFallback>{row.original.owner.slice(0, 1)}</AvatarFallback></Avatar><span>{row.original.owner}</span></div> },
{ accessorKey: "due", sortFn: "basic", header: ({ column }) => <DataTableColumnHeader column={column} title="마감일" />,
cell: ({ row }) => <span className={row.original.status !== "done" && row.original.due < today ? "work-overdue" : ""}>{row.original.due.slice(5).replace("-", ".")} {row.original.status !== "done" && row.original.due < today ? "지연" : ""}</span> },
{ accessorKey: "progress", header: "진행률", cell: ({ row }) => <div className="work-row-progress"><span>{row.original.progress}%</span><Progress value={row.original.progress} aria-label={`${row.original.name} 진행률`} /></div> },
], [today])
const table = useDataTable({ columns, data: visibleWorks, getRowId: work => work.id, initialState: { pagination: { pageIndex: 0, pageSize: 6 } } })
const changeStatus = (filter: DashboardFilterId) => { setStatus(filter); table.setPageIndex(0); table.resetRowSelection() }
const apply = () => { setApplied({ ...draft }); table.setPageIndex(0); table.resetRowSelection() }
const reset = () => { setDraft(emptyConditions); setApplied(emptyConditions); changeStatus("all") }
const openNew = () => setEditor({ id: "", name: "", project: projects[0], department: departments[0], owner: "", due: today, status: "active", issue: false, progress: 0, description: "" })
const save = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!editor || !editor.name.trim() || !editor.owner.trim()) return
const saved: Work = { ...editor, id: editor.id || `HM-${crypto.randomUUID().slice(0, 8)}`, name: editor.name.trim(), owner: editor.owner.trim(), progress: editor.status === "done" ? 100 : editor.progress }
const updated = editor.id ? works.map(work => work.id === saved.id ? saved : work) : [saved, ...works]
setWorks(updated)
try { localStorage.setItem(storageKey, JSON.stringify(updated)); setNotice(`“${saved.name}” ${editor.id ? "변경 사항을 저장했습니다." : "업무를 등록했습니다."}`) }
catch { setNotice("변경 내용을 화면에 반영했습니다. 브라우저 저장이 제한되어 새로고침하면 유지되지 않습니다.") }
setEditor(null)
reset()
}
const urgent = useMemo(() => works.filter(work => work.status !== "done").sort((a, b) => a.due.localeCompare(b.due)).slice(0, 3), [works])
const projectStats = useMemo(() => [...new Set(works.map(work => work.project))].map(project => {
const rows = works.filter(work => work.project === project)
return { name: project, total: rows.length, done: countDashboardFilter(rows, "done"), progress: Math.round(rows.reduce((sum, work) => sum + work.progress, 0) / rows.length) }
}), [works])
const statusButton = (filter: DashboardFilterId) => <Button key={filter} variant="primary" appearance={status === filter ? "solid" : "outline"} size="sm" data-filter={filter} aria-pressed={status === filter} aria-controls="work-results" onClick={() => changeStatus(filter)}>
{dashboardFilterDefinitions[filter].label} <strong>{count(filter)}</strong>건
</Button>
return <>
<div className="dashboard-production work-dashboard">
<GNB>
<GNBMobileMenu title="업무 메뉴">{sections.map(item => <GNBMobileLink key={item.id} href={`#${item.id}`} active={section === item.id} onClick={event => goToSection(item.id, event)}>{item.label}</GNBMobileLink>)}</GNBMobileMenu>
<GNBBrand href="#overview" onClick={event => goToSection("overview", event)}><span><small>HANMAC · WORKSPACE</small><strong>HM 업무 관리</strong></span></GNBBrand>
<GNBNav><NavigationMenu viewport={false} aria-label="주 메뉴"><NavigationMenuList>{sections.map(item => <NavigationMenuItem key={item.id}><NavigationMenuLink href={`#${item.id}`} active={section === item.id} onClick={event => goToSection(item.id, event)}>{item.label}</NavigationMenuLink></NavigationMenuItem>)}</NavigationMenuList></NavigationMenu></GNBNav>
<GNBUtilities><DropdownMenu><DropdownMenuTrigger asChild><GNBUser name="김한맥" description="프로젝트 관리자"><Avatar><AvatarFallback>김</AvatarFallback></Avatar></GNBUser></DropdownMenuTrigger><DropdownMenuContent align="end"><DropdownMenuLabel>김한맥 · 프로젝트 관리자</DropdownMenuLabel><DropdownMenuSeparator /><DropdownMenuItem onSelect={() => { const conditions = { query: "김한맥", department: "all" }; setDraft(conditions); setApplied(conditions); changeStatus("all"); setSection("work-results"); document.getElementById("work-results")?.scrollIntoView({ block: "start" }) }}>내 담당 업무</DropdownMenuItem><DropdownMenuItem onSelect={openNew}>업무 등록</DropdownMenuItem></DropdownMenuContent></DropdownMenu></GNBUtilities>
</GNB>
<main className="work-page-main" id="overview" ref={mainRef}>
<PageHeader>
<PageHeaderContent>
<p className="work-kicker">HANMAC · WORKSPACE</p>
<PageHeaderTitle>업무 현황</PageHeaderTitle>
<PageHeaderDescription>팀의 업무와 프로젝트 진행 상황을 한눈에 확인하세요.</PageHeaderDescription>
</PageHeaderContent>
<PageHeaderActions><span className="work-date"><CalendarDays size={16} aria-hidden="true" />{dateLabel}</span><Button variant="primary" appearance="brand" onClick={openNew}><Plus aria-hidden="true" />업무 등록</Button></PageHeaderActions>
</PageHeader>
<div className="work-content-stack">
<div className="work-status-filters" role="group" aria-label="업무 상태 필터"><span className="text-sm font-medium">업무 상태</span>{dashboardFilterIds.map(statusButton)}</div>
<div className="work-summary-grid">
{[
{ title: "전체 업무", value: count("all"), note: `${projectStats.length}개 프로젝트의 업무`, icon: ClipboardList },
{ title: "진행 중", value: count("active"), note: "현재 진행 중인 업무", icon: Clock3 },
{ title: "완료 업무", value: count("done"), note: `전체 업무의 ${completion}% 완료`, icon: CheckCheck },
{ title: "확인할 이슈", value: count("issue"), note: `보류 업무 ${count("hold")}건 · 이슈는 별도 표시`, icon: AlertCircle },
].map(item => <Card key={item.title}>
<CardHeader><CardDescription>{item.title}</CardDescription><CardAction><item.icon className="work-summary-icon" size={20} aria-hidden="true" /></CardAction></CardHeader>
<CardContent><p className="work-summary-value">{item.value}<span>건</span></p><p className="work-meta mt-2">{item.note}</p></CardContent>
</Card>)}
</div>
<div className="work-detail-grid">
<Card id="work-results">
<CardHeader><CardTitle>업무 목록</CardTitle><CardDescription role="status" aria-live="polite">{dashboardFilterDefinitions[status].label} · {applied.department === "all" ? "전체 부서" : applied.department} · {visibleWorks.length}건{applied.query.trim() && ` · “${applied.query.trim()}”`}</CardDescription><CardAction><Badge variant="outline">{visibleWorks.length}건</Badge></CardAction></CardHeader>
<CardContent>
<form className="mb-4" onSubmit={event => { event.preventDefault(); apply() }}>
<FilterBar className="hm-list-search-basic">
<Select value={draft.department} onValueChange={department => setDraft(previous => ({ ...previous, department }))}>
<SelectTrigger size="medium" aria-label="부서 조회"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="all">전체 부서</SelectItem>{departments.map(department => <SelectItem key={department} value={department}>{department}</SelectItem>)}</SelectContent>
</Select>
<FilterBarSearch value={draft.query} onValueChange={query => setDraft(previous => ({ ...previous, query }))} placeholder="업무명·프로젝트·담당자 검색" />
<FilterBarActions><Button type="submit" variant="primary" appearance="solid"><Search aria-hidden="true" />조회</Button><Button type="button" variant="cancel" appearance="outline" onClick={reset}>초기화</Button></FilterBarActions>
</FilterBar>
</form>
<DataTable table={table} pageSizeOptions={[6, 12, 24]} emptyMessage={<div className="grid justify-items-center gap-3"><p>조회 조건에 맞는 업무가 없습니다.</p><Button variant="cancel" appearance="outline" onClick={reset}>조건 초기화</Button></div>} />
<p className="work-meta mt-4">업무명을 선택하면 상세 내용과 상태를 수정할 수 있습니다.</p>
</CardContent>
</Card>
<div className="work-rail">
<Card id="project-overview">
<CardHeader><CardTitle>프로젝트 진행률</CardTitle><CardDescription>프로젝트별 업무 평균 진행률</CardDescription></CardHeader>
<CardContent><div className="work-project-list">{projectStats.map(project => <div key={project.name} className="work-project">
<div className="flex items-center justify-between gap-2"><strong className="text-sm">{project.name}</strong><span className="work-project-percent">{project.progress}%</span></div>
<Progress value={project.progress} aria-label={`${project.name} 평균 진행률`} />
<span className="work-meta">{project.total}건 중 {project.done}건 완료</span>
</div>)}</div></CardContent>
</Card>
<Card surface="content">
<CardHeader><CardTitle>확인이 필요한 업무</CardTitle><CardDescription>진행·보류 상태 중 이슈가 있는 업무</CardDescription></CardHeader>
<CardContent><div className="work-issue-list">{works.filter(work => work.issue && work.status !== "done").map(work => <div key={work.id} className="work-issue-item"><div className="flex items-center justify-between gap-2"><span className="work-meta">{work.project}</span>{statusBadges(work)}</div><Button className="work-name-link" variant="link" size="sm" onClick={() => setEditor({ ...work })}>{work.name}<ArrowUpRight size={14} aria-hidden="true" /></Button></div>)}
{!works.some(work => work.issue && work.status !== "done") && <p className="work-meta">현재 확인이 필요한 이슈가 없습니다.</p>}
</div></CardContent>
</Card>
</div>
</div>
<Card id="work-deadlines">
<CardHeader><CardTitle>다가오는 마감</CardTitle><CardDescription>완료되지 않은 업무를 마감일 순으로 표시합니다.</CardDescription></CardHeader>
<CardContent><div className="work-deadline-grid">{urgent.map(work => <div key={work.id} className="work-deadline-item">
<div className="work-deadline-date"><CalendarDays size={18} aria-hidden="true" /><span>{work.due.slice(5).replace("-", ".")}</span></div>
<div><Button variant="link" className="work-name-link" size="sm" onClick={() => setEditor({ ...work })}>{work.name}</Button><p className="work-meta">{work.owner} · {work.department}</p></div>
{statusBadges(work)}
</div>)}{!urgent.length && <p className="work-meta">모든 업무가 완료되었습니다.</p>}</div></CardContent>
</Card>
<footer className="work-footer"><span>HMDesign · 예시 업무 데이터 / 이 브라우저에 저장</span><span role="status" aria-live="polite">{notice}</span></footer>
</div>
</main>
</div>
<Dialog open={editor !== null} onOpenChange={open => { if (!open) setEditor(null) }}>
<DialogContent className="work-editor">
<DialogHeader><DialogTitle>{editor?.id ? "업무 상세" : "업무 등록"}</DialogTitle><DialogDescription>{editor?.id ? `${editor.id} · 업무 내용과 진행 상태를 수정할 수 있습니다.` : "새로운 업무의 담당자와 마감일을 설정하세요."}</DialogDescription></DialogHeader>
{editor && <form onSubmit={save} className="work-editor-form">
<DialogBody>
<div className="work-editor-fields">
<div className="work-field work-field-wide"><Label htmlFor="work-name">업무명 <span aria-hidden="true">*</span></Label><Input id="work-name" size="medium" required maxLength={80} value={editor.name} onChange={event => setEditor({ ...editor, name: event.target.value })} placeholder="업무명을 입력하세요" /></div>
<div className="work-field"><Label htmlFor="work-project">프로젝트</Label><Select value={editor.project} onValueChange={project => setEditor({ ...editor, project })}><SelectTrigger id="work-project" size="medium"><SelectValue /></SelectTrigger><SelectContent>{projects.map(project => <SelectItem key={project} value={project}>{project}</SelectItem>)}</SelectContent></Select></div>
<div className="work-field"><Label htmlFor="work-department">부서</Label><Select value={editor.department} onValueChange={department => setEditor({ ...editor, department })}><SelectTrigger id="work-department" size="medium"><SelectValue /></SelectTrigger><SelectContent>{departments.map(department => <SelectItem key={department} value={department}>{department}</SelectItem>)}</SelectContent></Select></div>
<div className="work-field"><Label htmlFor="work-owner">담당자 <span aria-hidden="true">*</span></Label><Input id="work-owner" size="medium" required maxLength={30} value={editor.owner} onChange={event => setEditor({ ...editor, owner: event.target.value })} placeholder="담당자 이름" /></div>
<div className="work-field"><Label htmlFor="work-due">마감일 <span aria-hidden="true">*</span></Label><DatePicker id="work-due" aria-label="마감일" controlled size="medium" value={new Date(`${editor.due}T12:00:00`)} onValueChange={date => { if (date) setEditor({ ...editor, due: `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}` }) }} /></div>
<div className="work-field"><Label htmlFor="work-status">업무 상태</Label><Select value={editor.status} onValueChange={value => setEditor({ ...editor, status: value as DashboardStatusId, progress: value === "done" ? 100 : editor.status === "done" ? 0 : editor.progress })}><SelectTrigger id="work-status" size="medium"><SelectValue /></SelectTrigger><SelectContent>{Object.values(dashboardStatusDefinitions).map(definition => <SelectItem key={definition.id} value={definition.id}>{definition.label}</SelectItem>)}</SelectContent></Select></div>
<div className="work-field"><Label htmlFor="work-progress">진행률 (%)</Label><Input id="work-progress" type="number" size="medium" min={0} max={100} required disabled={editor.status === "done"} value={editor.progress} onChange={event => setEditor({ ...editor, progress: Number(event.target.value) })} /></div>
<div className="work-field work-field-wide"><Label htmlFor="work-description">업무 설명</Label><Textarea id="work-description" maxLength={1000} rows={3} value={editor.description} onChange={event => setEditor({ ...editor, description: event.target.value })} placeholder="업무 내용과 참고 사항을 입력하세요" /></div>
<div className="work-field-wide flex items-center justify-between gap-4"><div><Label htmlFor="work-issue">이슈 표시</Label><p className="work-meta mt-1">업무 상태와 별도로 확인이 필요한 사항을 표시합니다.</p></div><Switch id="work-issue" checked={editor.issue} onCheckedChange={issue => setEditor({ ...editor, issue })} /></div>
</div>
</DialogBody>
<DialogFooter><Button type="button" variant="cancel" appearance="solid" onClick={() => setEditor(null)}>취소</Button><Button type="submit" variant="primary" appearance="solid">{editor.id ? "변경 저장" : "등록"}</Button></DialogFooter>
</form>}
</DialogContent>
</Dialog>
</>
}
+53
View File
@@ -0,0 +1,53 @@
import type { DashboardStatusId } from "@hm-design/ui"
export type Work = {
id: string
name: string
project: string
department: string
owner: string
due: string
status: DashboardStatusId
issue: boolean
progress: number
description: string
}
export const departments = ["설계팀", "개발팀", "운영팀"]
export const projects = ["통합 업무 플랫폼", "고객 포털 고도화", "디자인 시스템", "운영 프로세스 개선"]
export const storageKey = "hmdesign.work-dashboard.v1"
export const sampleWorks: Work[] = [
{ id: "HM-001", name: "업무 현황 화면 설계", project: projects[0], department: "설계팀", owner: "김한맥", due: "2026-10-12", status: "active", issue: false, progress: 75, description: "업무 흐름과 사용자 요구사항을 반영하여 현황 화면을 설계합니다." },
{ id: "HM-002", name: "권한 관리 API 연동", project: projects[0], department: "개발팀", owner: "이서준", due: "2026-10-12", status: "active", issue: true, progress: 45, description: "권한 정책 확정이 필요합니다. 운영팀과 정책을 확인한 뒤 API 연동을 진행합니다." },
{ id: "HM-003", name: "고객 포털 접근성 검토", project: projects[1], department: "설계팀", owner: "박지민", due: "2026-10-13", status: "active", issue: false, progress: 60, description: "주요 사용자 흐름의 키보드 조작과 접근성 요소를 검토합니다." },
{ id: "HM-004", name: "공통 컴포넌트 가이드 정리", project: projects[2], department: "설계팀", owner: "김한맥", due: "2026-10-14", status: "active", issue: false, progress: 80, description: "공개 컴포넌트 사용법과 화면 조합 예제를 정리합니다." },
{ id: "HM-005", name: "운영 이관 체크리스트 검토", project: projects[3], department: "운영팀", owner: "정수빈", due: "2026-10-14", status: "hold", issue: true, progress: 30, description: "담당 부서의 이관 범위 확인을 기다리고 있습니다." },
{ id: "HM-006", name: "검색 기능 통합 테스트", project: projects[1], department: "개발팀", owner: "최도윤", due: "2026-10-15", status: "active", issue: false, progress: 55, description: "검색 조건과 결과 목록의 동작을 통합 검증합니다." },
{ id: "HM-007", name: "알림 발송 정책 수립", project: projects[0], department: "운영팀", owner: "정수빈", due: "2026-10-16", status: "hold", issue: false, progress: 20, description: "업무 변경 알림의 대상과 발송 시점을 정의합니다." },
{ id: "HM-008", name: "Dashboard 상태 규칙 검토", project: projects[2], department: "설계팀", owner: "박지민", due: "2026-10-08", status: "done", issue: false, progress: 100, description: "업무 상태와 독립 이슈 표시 규칙을 검토했습니다." },
{ id: "HM-009", name: "배포 환경 설정", project: projects[0], department: "개발팀", owner: "이서준", due: "2026-10-08", status: "done", issue: false, progress: 100, description: "개발 환경의 배포 설정과 빌드 작업을 구성했습니다." },
{ id: "HM-010", name: "고객 문의 분류 체계 정리", project: projects[3], department: "운영팀", owner: "정수빈", due: "2026-10-07", status: "done", issue: false, progress: 100, description: "문의 유형별 담당 부서와 처리 기준을 정리했습니다." },
{ id: "HM-011", name: "포털 화면 사용성 테스트", project: projects[1], department: "설계팀", owner: "김한맥", due: "2026-10-19", status: "active", issue: false, progress: 35, description: "고객 포털의 주요 화면 사용성 테스트를 준비합니다." },
{ id: "HM-012", name: "업무 등록 기능 개발", project: projects[0], department: "개발팀", owner: "최도윤", due: "2026-10-20", status: "active", issue: false, progress: 65, description: "업무 등록 폼과 입력 검증을 구현합니다." },
]
function isWork(value: unknown): value is Work {
if (!value || typeof value !== "object") return false
const work = value as Record<string, unknown>
return ["id", "name", "project", "department", "owner", "description"].every(key => typeof work[key] === "string")
&& typeof work.due === "string" && /^\d{4}-\d{2}-\d{2}$/.test(work.due)
&& ["active", "hold", "done"].includes(String(work.status)) && typeof work.issue === "boolean"
&& typeof work.progress === "number" && work.progress >= 0 && work.progress <= 100
}
export function loadWorks(): Work[] {
try {
const stored = localStorage.getItem(storageKey)
if (stored) {
const parsed: unknown = JSON.parse(stored)
if (Array.isArray(parsed) && parsed.every(isWork) && new Set(parsed.map(work => work.id)).size === parsed.length) return parsed
}
} catch { /* Storage may be disabled; the screen still works in memory. */ }
return sampleWorks
}
+50
View File
@@ -0,0 +1,50 @@
@import "../list-screen.css";
/* Screen composition only. HM primitives, typography and surfaces retain their public Theme mappings. */
.work-dashboard { display: flex; flex-direction: column; height: 100dvh; min-width: 0; color: var(--hm-sem-fg-primary); background: var(--hm-sem-bg-brand); font-family: var(--hm-font-family-base); }
.work-page-main { position: relative; flex: 1; min-height: 0; overflow: auto; padding: var(--hm-gnb-padding-x); background: var(--hm-sem-bg-content); border-radius: var(--hm-gnb-join) var(--hm-gnb-join) 0 0; }
.work-content-stack { display: grid; gap: var(--hm-card-padding); min-width: 0; }
.work-status-filters { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.work-status-filters > span { margin-right: 8px; }
#work-results, #project-overview, #work-deadlines { scroll-margin-top: var(--hm-card-padding); }
.work-kicker { margin: 0; color: var(--hm-sem-fg-secondary); font-size: 12px; font-weight: 600; letter-spacing: .12em; }
.work-date { display: flex; align-items: center; gap: 8px; margin-right: 8px; color: var(--hm-sem-fg-secondary); font-size: 13px; white-space: nowrap; }
.work-summary-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--hm-card-padding); }
.work-summary-icon { color: var(--hm-sem-action-primary); }
.work-summary-value { margin: 0; font-size: 36px; font-weight: 700; line-height: 1.2; font-variant-numeric: tabular-nums; }
.work-summary-value > span { margin-left: 8px; font-size: 14px; font-weight: 400; color: var(--hm-sem-fg-secondary); }
.work-meta { color: var(--hm-sem-fg-secondary); font-size: 12px; line-height: 1.6; }
.work-detail-grid { display: grid; grid-template-columns: minmax(0, 1fr) 320px; align-items: start; gap: var(--hm-card-padding); }
.work-rail { display: grid; gap: var(--hm-card-padding); }
.work-badges { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
.work-name-cell { display: grid; gap: 4px; min-width: 210px; }
.work-name-link { justify-content: flex-start; height: auto; padding: 0; text-align: left; white-space: normal; }
.work-owner { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
.work-row-progress { display: grid; gap: 6px; min-width: 72px; font-size: 12px; font-variant-numeric: tabular-nums; }
.work-overdue { color: var(--hm-sem-action-destructive); }
.work-project-list { display: grid; gap: 24px; }
.work-project { display: grid; gap: 8px; }
.work-project-percent { color: var(--hm-sem-action-primary); font-size: 14px; font-weight: 700; font-variant-numeric: tabular-nums; }
.work-issue-list { display: grid; gap: 20px; }
.work-issue-item { display: grid; gap: 8px; }
.work-deadline-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 24px; }
.work-deadline-item { display: flex; align-items: center; gap: 16px; }
.work-deadline-date { display: grid; justify-items: center; gap: 4px; flex: none; color: var(--hm-sem-action-primary); font-size: 14px; font-weight: 600; }
.work-footer { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px; padding-bottom: 8px; color: var(--hm-sem-fg-secondary); font-size: 12px; }
.work-editor { width: min(560px, calc(100vw - 32px)); }
.work-editor-form { display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
.work-editor-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; }
.work-field { display: grid; gap: 8px; }
.work-field-wide { grid-column: 1 / -1; }
@media (max-width: 1199px) {
.work-detail-grid { grid-template-columns: minmax(0, 1fr); }
.work-rail { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.work-deadline-grid { grid-template-columns: minmax(0, 1fr); }
}
@media (max-width: 520px) {
.work-summary-grid, .work-rail { grid-template-columns: minmax(0, 1fr); }
.work-editor-fields { grid-template-columns: minmax(0, 1fr); }
.work-date { display: none; }
}
@media (min-width: 521px) and (max-width: 959px) {
.work-summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}