Initial commit: HMDesignSystem baseline
This commit is contained in:
commit
b6a0a61103
486 files changed
+68432
No files matched your search
+102
@@ -0,0 +1,102 @@
|
||||
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
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { ComponentType } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { TunnelFacility } from "@hm-design/center-graphic"
|
||||
import {
|
||||
BridgeReferenceDemo,
|
||||
IntersectionReferenceDemo,
|
||||
InterchangeReferenceDemo,
|
||||
JunctionReferenceDemo,
|
||||
LinkedFacilityReferenceDemo,
|
||||
RoadsideReferenceDemo,
|
||||
TunnelReferenceDemo,
|
||||
} from "@hm-design/center-graphic/reference"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
import { CENTER_BOUNDARY, withRepresentation } from "./center-shared"
|
||||
|
||||
// Owners: ui-viz docs/visualization/facility.md and facility-assets.md (128 approved SVG references, shipped unchanged).
|
||||
// The display scale (Q2b) is HOLD and Facility has no Selected / Hover state.
|
||||
const meta = {
|
||||
title: "Center Graphic/Facility",
|
||||
component: TunnelFacility,
|
||||
parameters: { layout: "padded" },
|
||||
} satisfies Meta<typeof TunnelFacility>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Common Story page. The CENTER representation wraps only the specimen, so the page title is never scaled with the graphic.
|
||||
const page = (name: string, Demo: ComponentType): Story => ({
|
||||
name,
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.center}
|
||||
title={name}
|
||||
description="CENTER 시설물 그래픽입니다. 승인 SVG Reference를 위치도(Position Map)와 실사화(Actual / Detail)로 표시합니다."
|
||||
/>
|
||||
<StorySection title="Overview" description={CENTER_BOUNDARY}>
|
||||
{withRepresentation(() => <Demo />)}
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
})
|
||||
|
||||
export const Tunnel: Story = page("터널", TunnelReferenceDemo)
|
||||
export const Bridge: Story = page("교량", BridgeReferenceDemo)
|
||||
export const Intersection: Story = page("교차로", IntersectionReferenceDemo)
|
||||
export const Junction: Story = page("분기점", JunctionReferenceDemo)
|
||||
export const Interchange: Story = page("나들목", InterchangeReferenceDemo)
|
||||
export const Roadside: Story = page("휴게소 · 졸음쉼터", RoadsideReferenceDemo)
|
||||
// Linked facilities are a standalone section heading followed by item cards.
|
||||
// Keep the reference data/artwork and road-class grouping owned by the package.
|
||||
export const Linked: Story = {
|
||||
...page("연계 시설물", LinkedFacilityReferenceDemo),
|
||||
render: () => (
|
||||
<StoryPage className="sb-center-linked-page">
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.center}
|
||||
title="연계 시설물"
|
||||
description={
|
||||
<>
|
||||
CENTER 시설물 그래픽입니다. 승인 SVG Reference를 위치도(Position Map)와 실사화(Actual / Detail)로 표시합니다.
|
||||
<br />
|
||||
{CENTER_BOUNDARY}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{withRepresentation(() => <LinkedFacilityReferenceDemo />)}
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const TunnelPlayground: Story = {
|
||||
tags: ["verification"],
|
||||
name: "직접 테스트 (터널)",
|
||||
decorators: [withRepresentation],
|
||||
args: { name: "대산2터널", secondaryText: "L=750m", variant: "positionMap", separated: false },
|
||||
argTypes: { variant: { control: "inline-radio", options: ["positionMap", "actualDetail"] } },
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { CenterInformationDisplayReferenceDemo } from "@hm-design/center-graphic/reference"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
import { CENTER_BOUNDARY, withRepresentation } from "./center-shared"
|
||||
|
||||
// Owner: ui-viz docs/patterns/center-information-presentation.md and center-information-{box,table,emphasis}.md.
|
||||
// Single-line ellipsis is resolved; multi-line overflow, exact placement and the presentation scale (Q3) are not.
|
||||
const meta = {
|
||||
title: "Center Graphic/Information",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
} satisfies Meta
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Common Story page. The CENTER representation wraps only the specimen, so the page title is never scaled with the graphic.
|
||||
export const Information: Story = {
|
||||
name: "Information",
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.center}
|
||||
title="Information"
|
||||
description="CENTER 정보 표시(Box · Table · Emphasis) Reference입니다. 단일행 말줄임만 해결된 상태이며 배율(Q3)은 정해지지 않았습니다."
|
||||
/>
|
||||
<StorySection title="Overview" description={CENTER_BOUNDARY}>
|
||||
{withRepresentation(() => (
|
||||
<div className="sb-center-information">
|
||||
<CenterInformationDisplayReferenceDemo />
|
||||
</div>
|
||||
))}
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
import { CENTER_BOUNDARY, CenterFrame, SCALE_NOTE, StatusTable } from "./center-shared"
|
||||
|
||||
// Mirrors the ui-viz docs/governance/center-domain.md §5 registry rows. The values stay in the owner documents.
|
||||
const STATUS: [string, string, string, string][] = [
|
||||
["Representation Scale (7680×2160 = 1.00 / 3840×2160 = 0.75)", "CONFIRMED_BY_DESIGN", "foundation/center-scale.md §3.5", "CenterRepresentation. 다른 viewport는 PREVIEW_ONLY 1.00"],
|
||||
["FACILITY_PRESENTATION_SCALE (Q2b)", "HOLD — OWNER_DECISION_REQUIRED", "visualization/facility.md §14.1", "구현하지 않음. scale 값·preset을 만들지 않음"],
|
||||
["INFORMATION_PRESENTATION_SCALE (Q3)", "HOLD — OWNER_DECISION_REQUIRED", "patterns/center-information-presentation.md §5.7", "구현하지 않음"],
|
||||
["Title Resolution B 수치 / per-Type palette", "TBD / SCOPED_DESIGN_APPROVAL", "components/center-title.md §6.7 · §8.1", "Reference 그대로"],
|
||||
["Information body-kind · padding · multi-line · KD-1/KD-4", "TBD", "patterns/center-information-presentation.md §5.3 · §8", "단일행 말줄임(해결)만 표시"],
|
||||
["Information Box badge anchor · §5.2.2 ink-position", "OWNER_DECISION_REQUIRED / Candidate (authored)", "components/center-information-box.md §5.2", "Reference 그대로. 승격 없음"],
|
||||
["Facility §12.24 rules (ink basis · LABEL_REGION …)", "OWNER_REVIEW (Q2c)", "visualization/facility.md §12.24", "Reference 동작 표시. 검증 완료로 표현하지 않음"],
|
||||
["Facility asset manifest (128 references)", "Confirmed current mapping · stable-ID/alias policy PARTIAL", "visualization/facility-assets.md", "승인 SVG를 바이트 그대로 사용"],
|
||||
["Spatial · Title · Information source SVG 65종", "CANONICAL_ASSET_CANDIDATE (64 RECHECK · 1 ADOPTED)", "visualization/spatial-title-information-assets.md", "runtime 미사용 · 규칙 기반 재구성"],
|
||||
["Route layer order Shadow → Outline → Primary Stroke", "CONFIRMED", "visualization/composition.md §5.1 · route.md §7.10", "Spatial 노선"],
|
||||
["전체 Visualization layer stack · collision algorithm", "Observed Candidate / TBD", "visualization/composition.md §6 · §7.5", "구현하지 않음"],
|
||||
["Railway outline width · line join", "TBD", "visualization/route.md §4.1 · §6.4", "Reference 값 표시, 확정 아님"],
|
||||
["Spatial §6.2 tile layer · 읍·면·동 radius · §8.1 paint order", "Candidate (authored) · OWNER_REVIEW (R2)", "visualization/spatial-map.md", "Reference 그대로"],
|
||||
["Color Candidate application", "TBD", "foundation/center-color.md §6–7", "Reference 그대로"],
|
||||
]
|
||||
|
||||
const meta = { title: "Center Graphic/Overview", parameters: { layout: "fullscreen", controls: { disable: true } } } satisfies Meta
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Overview: Story = {
|
||||
name: "Overview",
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader kicker={STORY_KICKER.center} title="Overview" description={CENTER_BOUNDARY} />
|
||||
<CenterFrame>
|
||||
<div className="sb-center-overview">
|
||||
<StorySection title="Representation Scale" description="제작 해상도별 CENTER 그래픽 표시 배율입니다.">
|
||||
<p className="sb-center-note">{SCALE_NOTE}</p>
|
||||
</StorySection>
|
||||
<StorySection title="Status" description="ui-viz center-domain.md §5 registry의 항목별 상태와 이 Storybook에서의 표현입니다.">
|
||||
<div className="sb-center-scroll">
|
||||
<StatusTable rows={STATUS} />
|
||||
</div>
|
||||
</StorySection>
|
||||
</div>
|
||||
</CenterFrame>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { ReactNode } from "react"
|
||||
import { CenterRepresentation, type CenterTargetResolution } from "@hm-design/center-graphic"
|
||||
import "./center-stories.css"
|
||||
|
||||
// CENTER is a separate domain from the UI Component System. These stories present the packaged CENTER components and the
|
||||
// reference demo pages; they do not own, create or promote CENTER contract values (see docs/center-graphic/README.md).
|
||||
export const CENTER_BOUNDARY =
|
||||
"CENTER Web Reference 렌더링입니다. Canonical 값·상태는 ui-viz CENTER owner 문서가 소유하며 HOLD / TBD / Candidate / OWNER_DECISION_REQUIRED를 이 화면이 확정하지 않습니다."
|
||||
|
||||
export const SCALE_NOTE =
|
||||
"제작 대상이 7680 × 2160이면 CENTER 그래픽을 100%, 3840 × 2160이면 75%로 표시합니다. Storybook 화면 크기는 배율을 정하지 않으며, 다른 제작 해상도의 배율은 정해지지 않았습니다. 제작 대상은 주소에 centerResolution=3840x2160 을 붙여 바꿉니다."
|
||||
|
||||
export const CenterFrame = ({ children }: { children: ReactNode }) => (
|
||||
<div className="sb-center" lang="ko">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
// A shareable Storybook URL states the production target explicitly; the default is the 7680 reference.
|
||||
export const centerTargetResolution = (): CenterTargetResolution => {
|
||||
const search = window.parent !== window ? window.parent.location.search : window.location.search
|
||||
return new URLSearchParams(search).get("centerResolution") === "3840x2160" ? "3840x2160" : "7680x2160"
|
||||
}
|
||||
|
||||
export const withRepresentation = (Story: () => ReactNode) => (
|
||||
<CenterFrame>
|
||||
<CenterRepresentation targetResolution={centerTargetResolution()}>
|
||||
<Story />
|
||||
</CenterRepresentation>
|
||||
</CenterFrame>
|
||||
)
|
||||
|
||||
export function BoundaryNote({ children }: { children?: ReactNode }) {
|
||||
return <p className="sb-center-note">{children ?? CENTER_BOUNDARY}</p>
|
||||
}
|
||||
|
||||
export function StatusTable({ rows, label = "CENTER 상태" }: { rows: [string, string, string, string][]; label?: string }) {
|
||||
return (
|
||||
<table className="sb-center-status" aria-label={label}>
|
||||
<thead>
|
||||
<tr><th>항목</th><th>상태</th><th>Owner (ui-viz)</th><th>이 Storybook에서의 표현</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(([item, status, owner, presentation]) => (
|
||||
<tr key={item} data-center-status={status}>
|
||||
<td>{item}</td><td><code>{status}</code></td><td>{owner}</td><td>{presentation}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { RouteLine } from "@hm-design/center-graphic"
|
||||
import { CenterSpatialInformationReferenceDemo } from "@hm-design/center-graphic/reference"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
import { CENTER_BOUNDARY, withRepresentation } from "./center-shared"
|
||||
|
||||
// Owners: ui-viz docs/visualization/route.md, spatial-map.md, spatial-section.md, composition.md.
|
||||
// Route layer order Shadow -> Outline -> Primary Stroke is CONFIRMED; the whole-map layer order is Candidate and line join is TBD.
|
||||
const meta = {
|
||||
title: "Center Graphic/Spatial",
|
||||
component: RouteLine,
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
} satisfies Meta<typeof RouteLine>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Common Story page. The CENTER representation wraps only the specimen, so the page title is never scaled with the graphic.
|
||||
export const Spatial: Story = {
|
||||
name: "Spatial",
|
||||
render: () => (
|
||||
<StoryPage className="sb-center-spatial-page">
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.center}
|
||||
title="Spatial"
|
||||
description="CENTER 노선·공간 정보 Reference입니다. 노선 레이어 순서(Shadow → Outline → Primary Stroke)는 확정이며, 전체 지도 레이어 순서는 Candidate, line join은 TBD입니다."
|
||||
/>
|
||||
<StorySection title="Overview" description={CENTER_BOUNDARY}>
|
||||
{withRepresentation(() => <CenterSpatialInformationReferenceDemo />)}
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
/* Storybook review chrome only; never select the CENTER artwork itself.
|
||||
Existing package demo classes are adapters to this common card frame, so
|
||||
@hm-design/center-graphic and its /reference API remain unchanged. */
|
||||
.sb-center {
|
||||
--color-fg-secondary: #6e6759;
|
||||
--color-stroke-subtle: #e0dace;
|
||||
--color-bg-content: #f5f0e5;
|
||||
--color-action-primary: #123328;
|
||||
/* Measured from TunnelReferenceDemo at 1280px before consolidation.
|
||||
Local presentation values, not new Foundation / runtime theme tokens. */
|
||||
--sb-center-card-background: rgba(217, 217, 217, .48);
|
||||
--sb-center-card-border: 1px solid rgba(64, 5, 0, .14);
|
||||
--sb-center-card-radius: 8px;
|
||||
--sb-center-header-background: rgba(255, 255, 255, .62);
|
||||
--sb-center-header-border: 1px solid rgba(64, 5, 0, .12);
|
||||
--sb-center-header-padding: 8px 14px;
|
||||
--sb-center-header-gap: 12px;
|
||||
--sb-center-content-padding: 24px;
|
||||
--sb-center-section-gap: 18px;
|
||||
--sb-center-column-gap: 14px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.sb-center-note { max-width: 880px; margin: 0; color: var(--hm-sem-fg-secondary); font-size: var(--hm-button-font-size-sm); line-height: var(--hm-button-line-height-sm); overflow-wrap: anywhere; }
|
||||
.sb-center-status { width: 100%; border-collapse: collapse; font-size: var(--hm-button-font-size-sm); line-height: var(--hm-button-line-height-sm); }
|
||||
.sb-center-status th, .sb-center-status td { border: 1px solid #e0dace; padding: 6px 10px; text-align: left; vertical-align: top; }
|
||||
.sb-center-status th { background: #f4f1ea; font-weight: 600; }
|
||||
.sb-center-status code { font-size: inherit; }
|
||||
.sb-center-scroll { min-width: 0; max-width: 100%; overflow-x: auto; }
|
||||
.sb-center-information { min-width: 0; width: 100%; }
|
||||
|
||||
/* One frame for all ordinary reference cards, including Linked item cards.
|
||||
Overview reuses StorySection rather than introducing another React helper. */
|
||||
.sb-center :is(.tunnel-reference__sample, .facility-reference__sample,
|
||||
.center-title-reference__sample, .cid-demo__block, .linked-facility__card,
|
||||
.sb-page-section, .csi-panel) {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background: var(--sb-center-card-background);
|
||||
border: var(--sb-center-card-border);
|
||||
border-radius: var(--sb-center-card-radius);
|
||||
}
|
||||
|
||||
.sb-center :is(.tunnel-reference, .facility-reference, .center-title-reference,
|
||||
.cid-demo, .linked-facility, .csi-demo, .sb-center-overview) {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: var(--sb-center-section-gap);
|
||||
}
|
||||
.sb-center-overview { margin-top: 32px; }
|
||||
.sb-center :is(.tunnel-reference__variant-grid, .facility-reference__variant-grid) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--sb-center-column-gap);
|
||||
}
|
||||
|
||||
.sb-center :is(.tunnel-reference__sample, .facility-reference__sample,
|
||||
.center-title-reference__sample, .cid-demo__block,
|
||||
.linked-facility__card, .sb-page-section) > header {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
min-height: 42px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: var(--sb-center-header-gap);
|
||||
margin: 0;
|
||||
padding: var(--sb-center-header-padding);
|
||||
background: var(--sb-center-header-background);
|
||||
border-bottom: var(--sb-center-header-border);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.sb-center .center-title-reference__sample > header > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--sb-center-header-gap);
|
||||
}
|
||||
.sb-center :is(.tunnel-reference__sample, .facility-reference__sample,
|
||||
.center-title-reference__sample, .cid-demo__block, .linked-facility__group,
|
||||
.linked-facility__card, .sb-page-section) > header :is(strong, h2, h3, .sb-page-section-title) {
|
||||
margin: 0;
|
||||
color: var(--hm-sem-fg-primary);
|
||||
font-size: var(--hm-label-font-size);
|
||||
line-height: var(--hm-label-line-height);
|
||||
font-weight: var(--hm-font-weight-bold);
|
||||
}
|
||||
.sb-center :is(.tunnel-reference__sample, .facility-reference__sample,
|
||||
.center-title-reference__sample, .cid-demo__block, .linked-facility__group,
|
||||
.linked-facility__card, .sb-page-section) > header :is(span, small, p, .sb-page-section-description) {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
color: var(--hm-sem-fg-secondary);
|
||||
font-size: var(--hm-button-font-size-sm);
|
||||
line-height: var(--hm-button-line-height-sm);
|
||||
font-weight: var(--hm-input-font-weight);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* No common height: keep each demo's content-driven height / existing minimum.
|
||||
Safe centering keeps the start of wide specimens reachable by local scroll. */
|
||||
.sb-center :is(.tunnel-reference__stage, .facility-reference__stage,
|
||||
.center-title-reference__stage, .cid-demo__stage,
|
||||
.sb-center-overview .sb-page-section-body, .linked-facility__variant) {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
padding: var(--sb-center-content-padding);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.sb-center :is(.tunnel-reference__stage, .facility-reference__stage) {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
justify-content: normal;
|
||||
justify-items: safe center;
|
||||
}
|
||||
.sb-center :is(.center-title-reference__stage, .cid-demo__stage) {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
justify-content: safe center;
|
||||
}
|
||||
.sb-center .cid-demo__stage > * { flex: 0 0 auto; }
|
||||
.sb-center .linked-facility__grid {
|
||||
min-width: 0;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr));
|
||||
gap: var(--sb-center-column-gap);
|
||||
padding: 0;
|
||||
align-items: start;
|
||||
}
|
||||
/* Linked is Section Header + Common Card List. Road-class groups and context
|
||||
are plain sections, so an item card never sits inside another visual card. */
|
||||
.sb-center-linked-page .sb-center {
|
||||
padding-top: var(--sb-center-section-gap);
|
||||
}
|
||||
.sb-center .linked-facility :is(.linked-facility__group, .linked-facility__context, .linked-facility__pending) {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: visible;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
.sb-center .linked-facility__group {
|
||||
display: grid;
|
||||
gap: var(--sb-center-section-gap);
|
||||
}
|
||||
.sb-center .linked-facility__group > header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--sb-center-header-gap);
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
.sb-center .linked-facility__context {
|
||||
gap: var(--sb-center-section-gap);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.sb-center .linked-facility__card {
|
||||
container: sb-linked-card / inline-size;
|
||||
}
|
||||
/* 56px label + 10px gap + measured 251px artwork + 48px common padding.
|
||||
Stack the label when that row cannot fit; preserve artwork size and local scroll. */
|
||||
@container sb-linked-card (max-width: 365px) {
|
||||
.sb-center .linked-facility__variant {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: auto;
|
||||
justify-items: safe center;
|
||||
gap: var(--sb-center-header-gap);
|
||||
}
|
||||
.sb-center .linked-facility__variant > small {
|
||||
grid-row: auto;
|
||||
}
|
||||
}
|
||||
.sb-center .sb-center-status { min-width: 720px; }
|
||||
|
||||
/* Spatial cards reuse the common frame above. Contrast belongs to the inner
|
||||
specimen canvas, never the card, heading, description or controls. */
|
||||
.sb-center-spatial-page .csi-panel {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
padding: var(--sb-center-content-padding);
|
||||
gap: var(--sb-center-header-gap);
|
||||
overflow: hidden;
|
||||
}
|
||||
.sb-center-spatial-page .csi-panel > h4 {
|
||||
margin: calc(-1 * var(--sb-center-content-padding)) calc(-1 * var(--sb-center-content-padding)) 0;
|
||||
padding: var(--sb-center-header-padding);
|
||||
background: var(--sb-center-header-background);
|
||||
border-bottom: var(--sb-center-header-border);
|
||||
color: var(--hm-sem-fg-primary);
|
||||
font-size: var(--hm-label-font-size);
|
||||
line-height: var(--hm-label-line-height);
|
||||
font-weight: var(--hm-font-weight-bold);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.sb-center-spatial-page .csi-panel > :is(.csi-legend, .csi-note) {
|
||||
color: var(--hm-sem-fg-primary);
|
||||
font-size: var(--hm-button-font-size-sm);
|
||||
line-height: var(--hm-button-line-height-sm);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Direct demo divs are canvas/control groups, not nested cards. Keep wide
|
||||
swatches, compass rows and the fixed 840px extent fixture locally scrollable;
|
||||
headings and explanatory text stay within the card at every viewport. */
|
||||
.sb-center-spatial-page .csi-panel > div {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
.sb-center-spatial-page .csi-panel :is(.csi-row-label, .csi-controls label) {
|
||||
color: var(--hm-sem-fg-primary);
|
||||
}
|
||||
/* These fluid-width route canvases already fit the card. Their marker/shadow
|
||||
overhang must not create a subpixel scrollbar or change the content height. */
|
||||
.sb-center-spatial-page :is(#csi-area-project-route, #csi-area-direction) > div {
|
||||
overflow: visible;
|
||||
}
|
||||
/* District and boundary canvases stay transparent on the common surface.
|
||||
Only their review labels use Foundation black; artwork colours stay intact. */
|
||||
.sb-center-spatial-page :is(#csi-area-administrative, #csi-area-zone-boundary) .csi-row-label {
|
||||
color: var(--hm-sem-palette-black);
|
||||
}
|
||||
/* Preserve the extent fixture's white terminal lines and translucent washes
|
||||
on its original light canvas. The compass needs no separate light backing. */
|
||||
.sb-center-spatial-page #csi-area-spatial-section > .csi-spatial-specimens {
|
||||
background: #efefef;
|
||||
}
|
||||
/* The reference demo authors white state captions inline for its dark canvas.
|
||||
This exact caption selector changes review text only, never semantic artwork. */
|
||||
.sb-center-spatial-page #csi-area-route .csi-swatchgrid > div > div > div:last-child {
|
||||
color: var(--hm-sem-fg-primary) !important;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.sb-center :is(.tunnel-reference__variant-grid, .facility-reference__variant-grid) {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { CenterTitle, CENTER_TITLE_COMPOSITIONS, type CenterTitleComposition } from "@hm-design/center-graphic"
|
||||
import { CenterTitleReferenceDemo } from "@hm-design/center-graphic/reference"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
import { CENTER_BOUNDARY, withRepresentation } from "./center-shared"
|
||||
|
||||
// Owner: ui-viz docs/components/center-title.md. Title Resolution B numeric output is TBD; the per-Type palette is SCOPED_DESIGN_APPROVAL.
|
||||
const meta = {
|
||||
title: "Center Graphic/Title",
|
||||
component: CenterTitle,
|
||||
parameters: { layout: "padded" },
|
||||
} satisfies Meta<typeof CenterTitle>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Representative Story: the four title types. Named after the area so Storybook shows it as a single sidebar item.
|
||||
// Common Story page. The CENTER representation wraps only the specimen, so the page title is never scaled with the graphic.
|
||||
export const Compositions: Story = {
|
||||
name: "Title",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.center}
|
||||
title="Title"
|
||||
description="CENTER 제목 그래픽의 네 가지 Type Reference입니다. Resolution B 수치는 TBD, Type별 palette는 SCOPED_DESIGN_APPROVAL입니다."
|
||||
/>
|
||||
<StorySection title="Overview" description={CENTER_BOUNDARY}>
|
||||
{withRepresentation(() => <CenterTitleReferenceDemo />)}
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const Playground: Story = {
|
||||
tags: ["verification"],
|
||||
decorators: [withRepresentation],
|
||||
args: { composition: "routeProjectNameRoute", context: "서산영덕선", projectName: "대산~당진 건설공사", routeClassification: "고속국도 제30호", projectRoute: "L=25.36km" },
|
||||
argTypes: {
|
||||
composition: {
|
||||
control: { type: "select", labels: Object.fromEntries(Object.entries(CENTER_TITLE_COMPOSITIONS).map(([key, { type, label }]) => [key, `${type} · ${label}`])) },
|
||||
options: Object.keys(CENTER_TITLE_COMPOSITIONS) as CenterTitleComposition[],
|
||||
},
|
||||
},
|
||||
render: (args) => <CenterTitle {...args} />,
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// The representative "Accordion" Story puts each Radix mode on one page with real, clickable items. The same sections also exist
|
||||
// as Stories tagged `verification`, which the sidebar hides by default (see .storybook/main.ts). Modes use the Radix API
|
||||
// (type, collapsible, defaultValue, disabled); there are no HM variant props.
|
||||
|
||||
const longText = "긴 텍스트는 줄바꿈으로 전체를 표시합니다. 공백 없는 긴 문자열도 너비를 밀어내지 않고 줄바꿈합니다. ".repeat(6)
|
||||
|
||||
const narrow = (children: ReactNode) => <div className="w-full max-w-md">{children}</div>
|
||||
|
||||
function SingleSpecimen() {
|
||||
return narrow(
|
||||
<Accordion type="single" defaultValue="a">
|
||||
<AccordionItem value="a">
|
||||
<AccordionTrigger>배송은 얼마나 걸리나요?</AccordionTrigger>
|
||||
<AccordionContent>주문 후 2~3일 안에 도착합니다.</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="b">
|
||||
<AccordionTrigger>반품은 어떻게 하나요?</AccordionTrigger>
|
||||
<AccordionContent>수령 후 7일 이내에 마이페이지에서 신청합니다.</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="c">
|
||||
<AccordionTrigger>결제 수단은 무엇이 있나요?</AccordionTrigger>
|
||||
<AccordionContent>카드와 계좌이체를 지원합니다.</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleSingleSpecimen() {
|
||||
return narrow(
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="a">
|
||||
<AccordionTrigger>열린 항목을 다시 눌러 닫을 수 있습니다</AccordionTrigger>
|
||||
<AccordionContent>collapsible 속성이 있어 모두 닫힌 상태가 가능합니다.</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="b">
|
||||
<AccordionTrigger>다른 항목</AccordionTrigger>
|
||||
<AccordionContent>한 번에 하나만 열립니다.</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
)
|
||||
}
|
||||
|
||||
function MultipleSpecimen() {
|
||||
return narrow(
|
||||
<Accordion type="multiple" defaultValue={["a", "b"]}>
|
||||
<AccordionItem value="a">
|
||||
<AccordionTrigger>기본 정보</AccordionTrigger>
|
||||
<AccordionContent>여러 항목을 동시에 열 수 있습니다.</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="b">
|
||||
<AccordionTrigger>상세 정보</AccordionTrigger>
|
||||
<AccordionContent>처음부터 열린 항목입니다.</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="c">
|
||||
<AccordionTrigger>기타</AccordionTrigger>
|
||||
<AccordionContent>접힌 항목입니다.</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
)
|
||||
}
|
||||
|
||||
function DisabledSpecimen() {
|
||||
return narrow(
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="a">
|
||||
<AccordionTrigger>사용 가능한 항목</AccordionTrigger>
|
||||
<AccordionContent>키보드 이동은 비활성 항목을 건너뜁니다.</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="b" disabled>
|
||||
<AccordionTrigger>비활성 항목</AccordionTrigger>
|
||||
<AccordionContent>열 수 없습니다.</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="c">
|
||||
<AccordionTrigger>다음 항목</AccordionTrigger>
|
||||
<AccordionContent>비활성 항목 다음입니다.</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
)
|
||||
}
|
||||
|
||||
function LongSpecimen() {
|
||||
return narrow(
|
||||
<Accordion type="single" defaultValue="a">
|
||||
<AccordionItem value="a">
|
||||
<AccordionTrigger>{`여러 줄로 이어지는 긴 Trigger 문구입니다. ${longText.slice(0, 60)}`}</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<p>{longText}</p>
|
||||
<p className="mt-2">AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA</p>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="b">
|
||||
<AccordionTrigger>두 번째 항목</AccordionTrigger>
|
||||
<AccordionContent>짧은 내용</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
)
|
||||
}
|
||||
|
||||
const sections = [
|
||||
{ id: "single", label: "Single", note: "type=\"single\": 한 번에 하나, 기본으로 첫 항목이 열림", node: <SingleSpecimen /> },
|
||||
{ id: "collapsible-single", label: "Collapsible Single", note: "type=\"single\" collapsible", node: <CollapsibleSingleSpecimen /> },
|
||||
{ id: "multiple", label: "Multiple", note: "type=\"multiple\": 동시에 여러 항목", node: <MultipleSpecimen /> },
|
||||
{ id: "disabled", label: "Disabled Item", note: "disabled 항목은 열리지 않고 키보드 이동에서 제외", node: <DisabledSpecimen /> },
|
||||
{ id: "long", label: "Long Content", note: "여러 줄 Trigger와 긴 콘텐츠", node: <LongSpecimen /> },
|
||||
]
|
||||
|
||||
// No `component` here: Accordion props are a union (single | multiple), so Stories without args would not type-check.
|
||||
const meta = { title: "Components/Disclosure/Accordion", parameters: { layout: "padded" } } satisfies Meta
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const board = (...ids: string[]) => (
|
||||
<StateBoard min={360} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, note: s.note, children: s.node }))} />
|
||||
)
|
||||
|
||||
// Representative Story: the common Story page; the sections group the same specimens.
|
||||
export const All: Story = {
|
||||
name: "Accordion",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Accordion"
|
||||
description="제목을 눌러 관련 내용을 펼치고 접는 목록입니다. 동작은 Radix API(type, collapsible, defaultValue, disabled)로 정하며 HM variant prop은 없습니다."
|
||||
/>
|
||||
<StorySection title="Overview" description="기본 Accordion입니다.">{board("single")}</StorySection>
|
||||
<StorySection title="Variants" description="모두 접을 수 있는 single과, 여러 항목을 동시에 여는 multiple입니다.">
|
||||
{board("collapsible-single", "multiple")}
|
||||
</StorySection>
|
||||
<StorySection title="States">{board("disabled")}</StorySection>
|
||||
<StorySection title="Usage">{board("long")}</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
// Verification Stories: the same specimens, hidden from the sidebar (tag `verification`).
|
||||
export const Single: Story = { tags: ["verification"], render: () => <SingleSpecimen /> }
|
||||
export const Multiple: Story = { tags: ["verification"], render: () => <MultipleSpecimen /> }
|
||||
export const Disabled: Story = { tags: ["verification"], render: () => <DisabledSpecimen /> }
|
||||
export const LongContent: Story = { tags: ["verification"], name: "Long Content", render: () => <LongSpecimen /> }
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
Button,
|
||||
} from "@hm-design/ui"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Alert Dialog is a modal Radix overlay (Portal) for a decision that must be confirmed or cancelled: no Close (X), no outside
|
||||
// dismiss. The representative Story opens the real dialogs; the same cases start open in the verification Stories.
|
||||
// Action / Cancel are the HM Button: ordinary confirmation uses a Solid pair; deletion uses an Outline pair.
|
||||
|
||||
function Confirmation({ defaultOpen }: { defaultOpen?: boolean }) {
|
||||
return (
|
||||
<AlertDialog defaultOpen={defaultOpen}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="primary" appearance="outline">
|
||||
로그아웃
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>로그아웃하시겠습니까?</AlertDialogTitle>
|
||||
<AlertDialogDescription>저장하지 않은 변경 사항은 사라집니다.</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel appearance="solid">취소</AlertDialogCancel>
|
||||
<AlertDialogAction variant="primary" appearance="solid">로그아웃</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
|
||||
function Destructive({ defaultOpen }: { defaultOpen?: boolean }) {
|
||||
return (
|
||||
<AlertDialog defaultOpen={defaultOpen}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" appearance="outline">
|
||||
삭제
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>프로젝트를 삭제하시겠습니까?</AlertDialogTitle>
|
||||
<AlertDialogDescription>삭제하면 되돌릴 수 없습니다. 프로젝트의 모든 데이터가 함께 삭제됩니다.</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel appearance="outline">취소</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" appearance="outline">
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = { title: "Components/Overlay/Alert Dialog", component: AlertDialog, parameters: { layout: "centered" } } satisfies Meta<typeof AlertDialog>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Representative Story: the common Story page; each trigger opens the real dialog.
|
||||
export const All: Story = {
|
||||
name: "Alert Dialog",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Alert Dialog"
|
||||
description="반드시 확인하거나 취소해야 하는 결정을 묻는 모달입니다. 닫기(X)와 바깥 클릭 닫기가 없습니다."
|
||||
/>
|
||||
<StorySection title="Overview" description="일반 확인창은 같은 크기의 Solid 버튼 세트입니다. 버튼을 눌러 엽니다.">
|
||||
<Confirmation />
|
||||
</StorySection>
|
||||
<StorySection title="Variants" description="삭제 확인창은 취소와 삭제 모두 같은 크기의 Outline 세트입니다.">
|
||||
<Destructive />
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const ConfirmationOpen: Story = { tags: ["verification"], name: "Confirmation", render: () => <Confirmation defaultOpen /> }
|
||||
export const DestructiveOpen: Story = { tags: ["verification"], name: "Destructive", render: () => <Destructive defaultOpen /> }
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { InfoIcon, TriangleAlertIcon } from "lucide-react"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
const meta = {
|
||||
title: "Components/Feedback/Alert",
|
||||
component: Alert,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"현재 HM 규격: 인라인 메시지(Toast 아님). padding 12/16, radius 4, border 1px, Title 14/20/500, Description 14/20/400. variant는 default(neutral)와 destructive만 둡니다. success·warning·information은 HM 상태 색이 정해지지 않아 만들지 않았습니다. 닫기 동작은 없습니다.",
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: { variant: { control: "inline-radio", options: ["default", "destructive"] } },
|
||||
} satisfies Meta<typeof Alert>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const All: Story = {
|
||||
name: "Alert",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Alert"
|
||||
description="화면 안에 놓이는 인라인 메시지입니다(Toast 아님). variant는 default(neutral)와 destructive만 두며 닫기 동작은 없습니다."
|
||||
/>
|
||||
<StorySection title="Overview">
|
||||
<StateBoard
|
||||
min={320}
|
||||
states={[
|
||||
{
|
||||
id: "default",
|
||||
label: "Default",
|
||||
children: (
|
||||
<Alert>
|
||||
<InfoIcon aria-hidden="true" />
|
||||
<AlertTitle>Heads up</AlertTitle>
|
||||
<AlertDescription>You can update your project settings at any time.</AlertDescription>
|
||||
</Alert>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Variants">
|
||||
<StateBoard
|
||||
min={320}
|
||||
states={[
|
||||
{
|
||||
id: "destructive",
|
||||
label: "Destructive",
|
||||
children: (
|
||||
<Alert variant="destructive">
|
||||
<TriangleAlertIcon aria-hidden="true" />
|
||||
<AlertTitle>Unable to save</AlertTitle>
|
||||
<AlertDescription>Check the project name and try again.</AlertDescription>
|
||||
</Alert>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Usage" description="아이콘 없음, 제목만, 긴 내용 구성입니다.">
|
||||
<StateBoard
|
||||
min={320}
|
||||
states={[
|
||||
{
|
||||
id: "no-icon",
|
||||
label: "No icon",
|
||||
children: (
|
||||
<Alert>
|
||||
<AlertTitle>Title only layout</AlertTitle>
|
||||
<AlertDescription>Without an icon the text starts at the left edge.</AlertDescription>
|
||||
</Alert>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "title-only",
|
||||
label: "Title only",
|
||||
children: (
|
||||
<Alert>
|
||||
<InfoIcon aria-hidden="true" />
|
||||
<AlertTitle>Saved</AlertTitle>
|
||||
</Alert>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "long",
|
||||
label: "Long content",
|
||||
children: (
|
||||
<div className="max-w-xs">
|
||||
<Alert>
|
||||
<InfoIcon aria-hidden="true" />
|
||||
<AlertTitle>Scheduled maintenance</AlertTitle>
|
||||
<AlertDescription>The service will be unavailable for a short time during the maintenance window. Please save your work before it starts.</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const Default: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => (
|
||||
<Alert>
|
||||
<InfoIcon aria-hidden="true" />
|
||||
<AlertTitle>Heads up</AlertTitle>
|
||||
<AlertDescription>You can update your project settings at any time.</AlertDescription>
|
||||
</Alert>
|
||||
),
|
||||
}
|
||||
export const Destructive: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => (
|
||||
<Alert variant="destructive">
|
||||
<TriangleAlertIcon aria-hidden="true" />
|
||||
<AlertTitle>Unable to save</AlertTitle>
|
||||
<AlertDescription>Check the project name and try again.</AlertDescription>
|
||||
</Alert>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { AspectRatio, Card, CardContent } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// AspectRatio is a layout primitive: it keeps the ratio and nothing else. The image styling (object-fit, radius) in these Stories is
|
||||
// the consumer's composition on the child, not part of AspectRatio. Images are inline data URIs (no network). The Card example only
|
||||
// shows how a consumer can compose it; AspectRatio does not depend on Card.
|
||||
|
||||
const svg = (label: string, fill: string) =>
|
||||
"data:image/svg+xml;utf8," +
|
||||
encodeURIComponent(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="480" height="320" viewBox="0 0 480 320"><rect width="480" height="320" fill="${fill}"/><circle cx="360" cy="90" r="40" fill="#bddfd4"/><path d="M0 320 L150 150 L260 260 L340 190 L480 320 Z" fill="#205241"/><text x="24" y="44" font-family="sans-serif" font-size="26" fill="#ffffff">${label}</text></svg>`
|
||||
)
|
||||
|
||||
function Image({ ratio, label }: { ratio: number; label: string }) {
|
||||
return (
|
||||
<div style={{ width: "100%", maxWidth: 320 }}>
|
||||
<AspectRatio ratio={ratio} data-testid={`ratio-${label}`}>
|
||||
<img src={svg(label, "#266651")} alt={`${label} 예시 이미지`} className="size-full rounded-md object-cover" />
|
||||
</AspectRatio>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Placeholder() {
|
||||
return (
|
||||
<div style={{ width: "100%", maxWidth: 320 }}>
|
||||
<AspectRatio ratio={16 / 9} data-testid="ratio-video">
|
||||
<div className="grid size-full place-items-center rounded-md border bg-muted text-sm text-muted-foreground">영상 영역 16:9</div>
|
||||
</AspectRatio>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InCard() {
|
||||
return (
|
||||
<Card style={{ width: "100%", maxWidth: 320 }}>
|
||||
<CardContent>
|
||||
<AspectRatio ratio={4 / 3} data-testid="ratio-card">
|
||||
<img src={svg("Card 4:3", "#3e3523")} alt="카드 안 4:3 이미지" className="size-full rounded-md object-cover" />
|
||||
</AspectRatio>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const sections = [
|
||||
{ id: "r169", label: "16:9", node: <Image ratio={16 / 9} label="16:9" /> },
|
||||
{ id: "r43", label: "4:3", node: <Image ratio={4 / 3} label="4:3" /> },
|
||||
{ id: "r11", label: "1:1", node: <Image ratio={1} label="1:1" /> },
|
||||
{ id: "video", label: "Video-like placeholder", node: <Placeholder /> },
|
||||
{ id: "card", label: "Composed in a Card (consumer composition)", node: <InCard /> },
|
||||
]
|
||||
|
||||
const meta = { title: "Components/Data & Display/Aspect Ratio", component: AspectRatio, parameters: { layout: "padded" } } satisfies Meta<typeof AspectRatio>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const board = (...ids: string[]) => (
|
||||
<StateBoard min={340} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} />
|
||||
)
|
||||
|
||||
// Representative Story: the common Story page; the sections group the same specimens.
|
||||
export const All: Story = {
|
||||
name: "Aspect Ratio",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Aspect Ratio"
|
||||
description="콘텐츠를 지정한 가로세로 비율로 유지하는 컨테이너입니다. 폭이 바뀌어도 비율대로 높이가 정해집니다."
|
||||
/>
|
||||
<StorySection title="Overview" description="ratio = 16 / 9.">{board("r169")}</StorySection>
|
||||
<StorySection title="Variants" description="ratio = 4 / 3, 1과 영상 자리 표시입니다.">{board("r43", "r11", "video")}</StorySection>
|
||||
<StorySection title="Usage" description="Card 안의 이미지 비율 조합입니다.">{board("card")}</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const Wide: Story = { tags: ["verification"], name: "16:9", render: () => <Image ratio={16 / 9} label="16:9" /> }
|
||||
export const Square: Story = { tags: ["verification"], name: "1:1", render: () => <Image ratio={1} label="1:1" /> }
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Images are inline data URIs so the Story does not depend on the network. "Broken" uses an address that cannot load so the
|
||||
// Radix fallback shows. Radix renders the image only after it loads.
|
||||
|
||||
const photo =
|
||||
"data:image/svg+xml;utf8," +
|
||||
encodeURIComponent(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80"><rect width="80" height="80" fill="#266651"/><circle cx="40" cy="31" r="14" fill="#bddfd4"/><rect x="14" y="50" width="52" height="40" rx="22" fill="#bddfd4"/></svg>'
|
||||
)
|
||||
|
||||
function Sizes() {
|
||||
return (
|
||||
<div className="flex items-end gap-4">
|
||||
{(["sm", "default", "lg"] as const).map((size) => (
|
||||
<div key={size} className="grid justify-items-center gap-2">
|
||||
<Avatar size={size}>
|
||||
<AvatarImage src={photo} alt="" />
|
||||
<AvatarFallback>한맥</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-xs text-muted-foreground">{size}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Fallbacks() {
|
||||
const items = [
|
||||
{ label: "한글 두 글자", text: "김한" },
|
||||
{ label: "영문 두 글자", text: "HM" },
|
||||
{ label: "한 글자", text: "한" },
|
||||
{ label: "긴 fallback", text: "ABCDEFGHIJ" },
|
||||
]
|
||||
return (
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
{items.map((i) => (
|
||||
<div key={i.label} className="grid justify-items-center gap-2">
|
||||
<Avatar>
|
||||
<AvatarImage src="/broken-avatar-image.png" alt="" />
|
||||
<AvatarFallback>{i.text}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-xs text-muted-foreground">{i.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Group() {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar>
|
||||
<AvatarImage src={photo} alt="김한맥" />
|
||||
<AvatarFallback>김한</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid text-sm">
|
||||
<span className="font-medium">김한맥</span>
|
||||
<span className="text-muted-foreground">노선 설계팀</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const sections = [
|
||||
{ id: "image", label: "Image", note: "이미지가 로드되면 fallback은 나타나지 않습니다", node: <Sizes /> },
|
||||
{ id: "fallback", label: "Fallback (image error)", note: "이미지 로드 실패 시 initials", node: <Fallbacks /> },
|
||||
{ id: "composition", label: "Composition", node: <Group /> },
|
||||
]
|
||||
|
||||
const meta = { title: "Components/Data & Display/Avatar", component: Avatar, parameters: { layout: "padded" } } satisfies Meta<typeof Avatar>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const board = (...ids: string[]) => (
|
||||
<StateBoard min={320} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, note: s.note, children: s.node }))} />
|
||||
)
|
||||
|
||||
// Representative Story: the common Story page; the sections group the same specimens.
|
||||
export const All: Story = {
|
||||
name: "Avatar",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Avatar"
|
||||
description="사용자를 나타내는 원형 이미지입니다. 이미지를 불러오지 못하면 fallback 글자를 표시합니다."
|
||||
/>
|
||||
<StorySection title="Overview">{board("image")}</StorySection>
|
||||
<StorySection title="States">{board("fallback")}</StorySection>
|
||||
<StorySection title="Usage" description="이름·소속과 함께 쓰는 소비 조합입니다.">{board("composition")}</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const Image: Story = { tags: ["verification"], render: () => <Sizes /> }
|
||||
export const FallbackError: Story = { tags: ["verification"], name: "Fallback", render: () => <Fallbacks /> }
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
import { Badge } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
const variants = ["default", "secondary", "destructive", "outline"] as const
|
||||
|
||||
const meta = {
|
||||
title: "Components/Data & Display/Badge",
|
||||
component: Badge,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"현재 HM 규격: 12/18/500, padding 2/8, radius 4, border 1px. variant는 현재 HM Theme에 의미가 있는 default(primary), secondary(neutral), destructive, outline만 둡니다. success·warning·information은 상태 색이 정해지지 않아 만들지 않았습니다. Button variant를 재사용하지 않는 별도 컴포넌트입니다.",
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: { variant: { control: "inline-radio", options: variants } },
|
||||
} satisfies Meta<typeof Badge>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const All: Story = {
|
||||
name: "Badge",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Badge"
|
||||
description="상태나 분류를 짧게 표시하는 라벨입니다. Button variant를 재사용하지 않는 별도 컴포넌트입니다."
|
||||
/>
|
||||
<StorySection title="Overview" description="variant를 생략한 기본 Badge(primary)입니다.">
|
||||
<Badge>Badge</Badge>
|
||||
</StorySection>
|
||||
<StorySection title="Variants" description="default(primary) / secondary(neutral) / destructive / outline.">
|
||||
<StateBoard
|
||||
min={280}
|
||||
states={[
|
||||
{
|
||||
id: "variants",
|
||||
label: "Text",
|
||||
children: (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{variants.map((v) => (
|
||||
<Badge key={v} variant={v}>
|
||||
{v}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "icon",
|
||||
label: "With icon",
|
||||
children: (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{variants.map((v) => (
|
||||
<Badge key={v} variant={v}>
|
||||
<CheckIcon aria-hidden="true" />
|
||||
{v}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Usage" description="긴 텍스트와 링크(asChild)입니다.">
|
||||
<StateBoard
|
||||
min={280}
|
||||
states={[
|
||||
{ id: "long", label: "Long text", children: <div className="max-w-[160px]"><Badge>A very long badge label</Badge></div> },
|
||||
{
|
||||
id: "link",
|
||||
label: "asChild (link)",
|
||||
children: (
|
||||
<Badge asChild variant="outline">
|
||||
<a href="#badge">Link badge</a>
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const Variants: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{variants.map((v) => (
|
||||
<Badge key={v} variant={v}>
|
||||
{v}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}
|
||||
export const Playground: Story = { tags: ["verification"], args: { variant: "default", children: "Badge" } }
|
||||
@@ -0,0 +1,149 @@
|
||||
import { Fragment, type ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbEllipsis,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// The representative "Breadcrumb" Story shows each case on one page (real links with sample hrefs); the same specimens also exist
|
||||
// as Stories tagged `verification` (hidden from the sidebar). BreadcrumbEllipsis is presentational: the Dropdown Menu example is
|
||||
// a consumer composition, not part of Breadcrumb.
|
||||
|
||||
const narrow = (children: ReactNode) => <div className="w-full max-w-xl">{children}</div>
|
||||
|
||||
function Basic() {
|
||||
return (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="#home">홈</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="#users">사용자</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>상세</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
)
|
||||
}
|
||||
|
||||
function Ellipsis() {
|
||||
return (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="#home">홈</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger aria-label="중간 경로 보기">
|
||||
<BreadcrumbEllipsis />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem>문서</DropdownMenuItem>
|
||||
<DropdownMenuItem>설정</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="#project">프로젝트</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>상세</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
)
|
||||
}
|
||||
|
||||
function Long() {
|
||||
const labels = ["홈", "프로젝트", "한맥 프로젝트 관리 시스템", "노선 정보", "시설물 목록", "교량", "상세 정보 및 점검 이력"]
|
||||
return (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{labels.map((label, i) => (
|
||||
<Fragment key={label}>
|
||||
<BreadcrumbItem>
|
||||
{i === labels.length - 1 ? <BreadcrumbPage>{label}</BreadcrumbPage> : <BreadcrumbLink href={`#${i}`}>{label}</BreadcrumbLink>}
|
||||
</BreadcrumbItem>
|
||||
{i < labels.length - 1 ? <BreadcrumbSeparator /> : null}
|
||||
</Fragment>
|
||||
))}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
)
|
||||
}
|
||||
|
||||
// asChild: the link is any element (here a plain <a> standing in for a router Link) and keeps the Breadcrumb look.
|
||||
function CustomLink() {
|
||||
return (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<a href="#router-home">홈 (custom link)</a>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator>/</BreadcrumbSeparator>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>현재 페이지</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
)
|
||||
}
|
||||
|
||||
const sections = [
|
||||
{ id: "basic", label: "Basic (3 depth)", node: <Basic /> },
|
||||
{ id: "ellipsis", label: "Ellipsis", note: "BreadcrumbEllipsis는 표시용입니다. 메뉴 연결은 소비 조합(Dropdown Menu)", node: <Ellipsis /> },
|
||||
{ id: "long", label: "Long path", note: "좁은 폭에서 줄바꿈", node: <Long /> },
|
||||
{ id: "custom", label: "Custom link (asChild) and separator", node: <CustomLink /> },
|
||||
]
|
||||
|
||||
const meta = { title: "Components/Navigation/Breadcrumb", component: Breadcrumb, parameters: { layout: "padded" } } satisfies Meta<typeof Breadcrumb>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const board = (...ids: string[]) => (
|
||||
<StateBoard min={420} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, note: s.note, children: narrow(s.node) }))} />
|
||||
)
|
||||
|
||||
// Representative Story: the common Story page; the sections group the same specimens.
|
||||
export const All: Story = {
|
||||
name: "Breadcrumb",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Breadcrumb"
|
||||
description="현재 페이지까지의 상위 경로를 보여주는 내비게이션입니다. 마지막 항목은 현재 페이지입니다."
|
||||
/>
|
||||
<StorySection title="Overview">{board("basic")}</StorySection>
|
||||
<StorySection title="Variants" description="중간 경로 생략(Ellipsis)과 사용자 링크 · 구분자입니다.">{board("ellipsis", "custom")}</StorySection>
|
||||
<StorySection title="Usage">{board("long")}</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const Basic3: Story = { tags: ["verification"], name: "Basic", render: () => <Basic /> }
|
||||
export const WithEllipsis: Story = { tags: ["verification"], name: "Ellipsis", render: () => <Ellipsis /> }
|
||||
export const LongPath: Story = { tags: ["verification"], name: "Long Path", render: () => narrow(<Long />) }
|
||||
@@ -0,0 +1,320 @@
|
||||
import { useState } from "react"
|
||||
import { DownloadIcon } from "lucide-react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Button, type ButtonAppearance } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Story structure follows ui_viz (implementations/web/storybook/Button.stories.jsx):
|
||||
// one representative "Button" Story shows kinds, sizes and states on a single screen; the same sections also exist
|
||||
// as separate Stories tagged `verification`, which the sidebar hides by default (see .storybook/main.ts).
|
||||
// Playground and Compatibility are HM-only and come after the ui_viz Stories.
|
||||
|
||||
type Role = "primary" | "secondary" | "modify" | "destructive" | "cancel"
|
||||
|
||||
// [role, label]. The gallery shows supported role/treatment combinations; usage sets are documented separately.
|
||||
const roles: [Role, string][] = [
|
||||
["primary", "Green"],
|
||||
["secondary", "Brown"],
|
||||
["modify", "Orange"],
|
||||
["cancel", "Gray"],
|
||||
["destructive", "Red"],
|
||||
]
|
||||
const rolesFor = (appearance: ButtonAppearance) =>
|
||||
roles.filter(([role]) =>
|
||||
appearance === "brand"
|
||||
? ["primary", "secondary", "modify"].includes(role)
|
||||
: appearance === "solid"
|
||||
? role !== "destructive"
|
||||
: ["primary", "secondary", "destructive", "cancel"].includes(role),
|
||||
)
|
||||
|
||||
const meta = {
|
||||
title: "Components/Form/Button",
|
||||
component: Button,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"ui_viz docs/components/button.md D-087: Brand Gradient와 Standard Solid/Outline. 크기 28/34/42, 글자 12/18 · 13/20 · 15/22, Brand 700 · Standard 500. Focus 시각 값은 ui_viz에서 전역 TBD이므로 shadcn 기본 focus 동작을 그대로 사용하며, Tab 키로 확인합니다.",
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
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 참조.",
|
||||
},
|
||||
appearance: {
|
||||
control: "inline-radio",
|
||||
options: ["brand", "solid", "outline"],
|
||||
description: "Treatment 기본값은 brand. Controls는 Role별 허용 조합을 제한하지 않습니다. destructive/cancel은 허용 appearance를 명시합니다. docs/components/button.md §6.1~6.2 참조.",
|
||||
},
|
||||
size: {
|
||||
control: "inline-radio",
|
||||
options: ["sm", "default", "lg"],
|
||||
description: "sm은 Small 28px, default는 Medium 34px, lg는 Large 42px.",
|
||||
},
|
||||
disabled: { control: "boolean" },
|
||||
children: { control: "text" },
|
||||
asChild: { table: { disable: true } },
|
||||
className: { table: { disable: true } },
|
||||
},
|
||||
} satisfies Meta<typeof Button>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const row = "flex flex-wrap items-center gap-4"
|
||||
const stack = "grid gap-6"
|
||||
const heading = "text-base font-semibold"
|
||||
const note = "text-sm text-muted-foreground"
|
||||
|
||||
function RolesBoard() {
|
||||
return (
|
||||
<div className={stack}>
|
||||
{(
|
||||
[
|
||||
["brand", "Brand"],
|
||||
["solid", "Standard Solid"],
|
||||
["outline", "Standard Outline"],
|
||||
] as const
|
||||
).map(([appearance, title]) => (
|
||||
<section key={appearance}>
|
||||
<h3 className={heading}>{title}</h3>
|
||||
<div className={`${row} mt-3`}>
|
||||
{rolesFor(appearance).map(([variant, label]) => (
|
||||
<Button key={variant} variant={variant} appearance={appearance}>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const sizes = [
|
||||
["sm", "Small"],
|
||||
["default", "Medium"],
|
||||
["lg", "Large"],
|
||||
] as const
|
||||
|
||||
function SizesBoard() {
|
||||
return (
|
||||
<div className={stack}>
|
||||
{(["brand", "solid"] as const).map((appearance) => (
|
||||
<div className={row} key={appearance}>
|
||||
{sizes.map(([size, label]) => (
|
||||
<Button key={size} appearance={appearance} size={size}>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BrandStates() {
|
||||
const [count, setCount] = useState(0)
|
||||
return (
|
||||
<div className={stack}>
|
||||
<div className={row}>
|
||||
<Button>Default</Button>
|
||||
<Button data-sb-hover="">Hover</Button>
|
||||
<Button data-sb-active="">Pressed</Button>
|
||||
<Button onClick={() => setCount((n) => n + 1)}>Interact</Button>
|
||||
<Button disabled onClick={() => setCount((n) => n + 1)}>
|
||||
Disabled
|
||||
</Button>
|
||||
</div>
|
||||
<output aria-live="polite">Activated: {count}</output>
|
||||
<p className={note}>
|
||||
Hover와 Pressed는 Storybook이 고정한 상태입니다. Interact 위에서 실제 Hover·Pressed·포인터 이탈을 확인합니다. Disabled는 실행되지 않습니다.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const standardStates = [
|
||||
["Default", {}],
|
||||
["Hover", { "data-sb-hover": "" }],
|
||||
["Pressed", { "data-sb-active": "" }],
|
||||
["Disabled", { disabled: true }],
|
||||
] as const
|
||||
|
||||
function StandardStateTable({ appearance, title }: { appearance: "solid" | "outline"; title: string }) {
|
||||
return (
|
||||
<section className="min-w-0">
|
||||
<h3 className={heading}>{title}</h3>
|
||||
<div className="mt-3 min-w-0 max-w-full overflow-x-auto">
|
||||
<table className="w-full border-collapse [&_td]:p-3 [&_th]:p-3 [&_th]:text-left">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Role</th>
|
||||
{standardStates.map(([state]) => (
|
||||
<th scope="col" key={state}>
|
||||
{state}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rolesFor(appearance).map(([variant, label]) => (
|
||||
<tr key={variant}>
|
||||
<th scope="row">{label}</th>
|
||||
{standardStates.map(([state, props]) => (
|
||||
<td key={state}>
|
||||
<Button variant={variant} appearance={appearance} {...props}>
|
||||
{label}
|
||||
</Button>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function StandardStatesBoard() {
|
||||
return (
|
||||
<div className={stack}>
|
||||
<StandardStateTable appearance="solid" title="Standard Solid" />
|
||||
<StandardStateTable appearance="outline" title="Standard Outline" />
|
||||
<p className={note}>
|
||||
Standard Solid의 Hover·Pressed는 역할 면 위에 Black 20%를 합성합니다(Pressed = Hover). Green·Brown Outline은 Hover에서 역할색 면 + White 라벨, Pressed에서 White 면 + Black 20%입니다. Red Outline은 Hover·Pressed 모두 Red 8% 면이며 Red 라벨·보더를 유지합니다. Disabled는 Default 모양 그대로 opacity 60%이고 상태 면을 적용하지 않습니다. Default 열의 버튼으로 실제 포인터 상태도 확인할 수 있습니다. Brand 상태는 Brand States에서 확인합니다.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LongLabelBoard() {
|
||||
return (
|
||||
<div className="w-[180px]">
|
||||
<Button className="whitespace-normal">Apply changes to all selected project records</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DestructiveBoard() {
|
||||
return (
|
||||
<div className={row}>
|
||||
<Button variant="cancel" appearance="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" appearance="outline">
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Representative Story: the common Story page (Overview → Sizes → Variants → States) with the ui_viz Button "All" boards.
|
||||
// Named after the component, as in ui_viz. It is the only Button entry in the default sidebar, so Storybook shows Button as a single 1-depth item.
|
||||
export const All: Story = {
|
||||
name: "Button",
|
||||
parameters: { layout: "fullscreen" },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Button"
|
||||
description="사용자가 동작을 실행하는 기본 컨트롤입니다. variant는 역할(색), appearance는 Brand / Standard Solid / Standard Outline, size는 높이를 정합니다."
|
||||
/>
|
||||
<StorySection title="Overview" description="props를 생략한 기본 Button: Brand · Primary(Green) · Medium 34px.">
|
||||
<Button>Button</Button>
|
||||
</StorySection>
|
||||
<StorySection title="Sizes" description="Small 28 / Medium 34 / Large 42px · 글자 12/18 · 13/20 · 15/22. 위는 Brand, 아래는 Standard Solid입니다.">
|
||||
<SizesBoard />
|
||||
</StorySection>
|
||||
<StorySection title="Variants" description="appearance별로 허용된 역할 조합입니다.">
|
||||
<RolesBoard />
|
||||
</StorySection>
|
||||
<StorySection title="States" description="Default / Hover / Pressed / Disabled. Hover·Pressed는 Storybook이 고정한 상태입니다.">
|
||||
<StateBoard
|
||||
min={1200}
|
||||
states={[
|
||||
{ id: "brand-states", label: "Brand", children: <BrandStates /> },
|
||||
{ id: "standard-states", label: "Standard Solid · Standard Outline", children: <StandardStatesBoard /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
// Verification Stories: the same sections as separate Stories, hidden from the sidebar (tag `verification`), reachable by URL or the sidebar tag filter.
|
||||
export const Roles: Story = { tags: ["verification"], render: () => <RolesBoard /> }
|
||||
export const Sizes: Story = { tags: ["verification"], render: () => <SizesBoard /> }
|
||||
export const States: Story = { tags: ["verification"], name: "Brand States", render: () => <BrandStates /> }
|
||||
export const StandardStates: Story = { tags: ["verification"], name: "Standard States", render: () => <StandardStatesBoard /> }
|
||||
export const LongLabel: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => <LongLabelBoard />,
|
||||
parameters: {
|
||||
docs: { description: { story: "일반 Label은 한 줄입니다. 긴 Label은 기존 className API의 whitespace-normal로 줄바꿈을 명시하며, 폭이 부족하면 높이가 늘어나고 각 줄은 가운데 정렬됩니다." } },
|
||||
},
|
||||
}
|
||||
|
||||
export const KoreanLabels: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => (
|
||||
<div style={{ display: "grid", gap: 16, width: 88 }}>
|
||||
{["저장", "변경사항 적용", "설정 저장", "파일 다운로드"].map((label) => <div key={label}><Button>{label}</Button></div>)}
|
||||
</div>
|
||||
),
|
||||
}
|
||||
export const IconTextNarrow: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => (
|
||||
<div style={{ display: "grid", gap: 16, width: 88 }}>
|
||||
{(["sm", "default", "lg"] as const).map((size) => <div key={size}><Button size={size}><DownloadIcon />파일 다운로드</Button></div>)}
|
||||
</div>
|
||||
),
|
||||
}
|
||||
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%입니다." } },
|
||||
},
|
||||
}
|
||||
|
||||
// HM only: not part of the ui_viz Button Stories. Tagged verification so the default sidebar keeps only the representative Story.
|
||||
export const Playground: Story = {
|
||||
tags: ["verification"],
|
||||
args: { children: "저장", variant: "primary", appearance: "brand", size: "default", disabled: false },
|
||||
parameters: {
|
||||
docs: { description: { story: "검증 전용 Controls는 역할과 Treatment를 독립적으로 선택하므로 미지원 조합도 선택할 수 있습니다. 대표 Button의 Variants와 docs/components/button.md §6.1의 허용 조합만 사용하세요. Focus는 ui_viz에서 TBD이므로 Tab 키로 shadcn 기본 focus 동작을 확인합니다." } },
|
||||
},
|
||||
}
|
||||
|
||||
export const Compatibility: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => (
|
||||
<div className={stack}>
|
||||
<section>
|
||||
<h2 className={heading}>shadcn compatibility</h2>
|
||||
<p className={`${note} mt-1`}>ui_viz 공식 Button 규격이 아닙니다. shadcn 기본 스타일을 그대로 씁니다.</p>
|
||||
<div className={`${row} mt-3`}>
|
||||
<Button variant="ghost">ghost</Button>
|
||||
<Button variant="link">link</Button>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h2 className={heading}>alias</h2>
|
||||
<p className={`${note} mt-1`}>기존 shadcn 사용 코드를 위한 별칭입니다. default는 primary, outline은 primary + outline과 같습니다.</p>
|
||||
<div className={`${row} mt-3`}>
|
||||
<Button variant="default">default</Button>
|
||||
<Button variant="outline">outline</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useState, type ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import type { DateRange } from "react-day-picker"
|
||||
import { Calendar } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// The representative "Calendar" Story shows each mode with real, clickable calendars; the same specimens also exist as Stories
|
||||
// tagged `verification` (hidden from the sidebar). `today` is pinned to 2026-10-02 so the Story looks the same on any day; this
|
||||
// uses react-day-picker's own `today` prop, not an HM state. Korean is the Calendar default locale.
|
||||
|
||||
const today = new Date(2026, 9, 2)
|
||||
const month = new Date(2026, 9, 1)
|
||||
|
||||
const box = (node: ReactNode) => <div className="rounded-md border bg-card w-fit">{node}</div>
|
||||
|
||||
function Single() {
|
||||
const [date, setDate] = useState<Date | undefined>(new Date(2026, 9, 15))
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
{box(<Calendar mode="single" selected={date} onSelect={setDate} defaultMonth={month} today={today} />)}
|
||||
<span className="text-sm text-muted-foreground" data-testid="single">{`선택: ${date ? date.toLocaleDateString("ko-KR") : "없음"}`}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DisabledDates() {
|
||||
const [date, setDate] = useState<Date | undefined>()
|
||||
return box(
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={date}
|
||||
onSelect={setDate}
|
||||
defaultMonth={month}
|
||||
today={today}
|
||||
disabled={[{ before: new Date(2026, 9, 5) }, { dayOfWeek: [0] }]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Range() {
|
||||
const [range, setRange] = useState<DateRange | undefined>({ from: new Date(2026, 9, 8), to: new Date(2026, 9, 14) })
|
||||
return box(<Calendar mode="range" selected={range} onSelect={setRange} defaultMonth={month} today={today} />)
|
||||
}
|
||||
|
||||
function TwoMonths() {
|
||||
return box(<Calendar mode="single" numberOfMonths={2} defaultMonth={month} today={today} />)
|
||||
}
|
||||
|
||||
const sections = [
|
||||
{ id: "single", label: "Single (today = 10/2, selected = 10/15)", node: <Single /> },
|
||||
{ id: "disabled", label: "Disabled dates (before 10/5, Sundays)", node: <DisabledDates /> },
|
||||
{ id: "range", label: "Range", node: <Range /> },
|
||||
{ id: "months", label: "Two months", node: <TwoMonths /> },
|
||||
]
|
||||
|
||||
// No `component`: Calendar props are a union of selection modes, so Stories without args would not type-check.
|
||||
const meta = { title: "Components/Search & Date/Calendar", parameters: { layout: "padded" } } satisfies Meta
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const board = (...ids: string[]) => (
|
||||
<StateBoard min={300} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, wide: s.id === "months", children: s.node }))} />
|
||||
)
|
||||
|
||||
// Representative Story: the common Story page; the sections group the same specimens.
|
||||
export const All: Story = {
|
||||
name: "Calendar",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Calendar"
|
||||
description="날짜를 고르는 달력입니다. 기본 locale은 한국어이며, 이 화면의 오늘은 2026-10-02로 고정되어 있습니다."
|
||||
/>
|
||||
<StorySection title="Overview" description='mode="single"'>{board("single")}</StorySection>
|
||||
<StorySection title="Variants" description='mode="range"와 여러 달 표시(numberOfMonths=2)입니다.'>{board("range", "months")}</StorySection>
|
||||
<StorySection title="States" description="선택할 수 없는 날짜(disabled)입니다.">{board("disabled")}</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const SingleSelection: Story = { tags: ["verification"], name: "Single", render: () => <Single /> }
|
||||
export const DisabledSelection: Story = { tags: ["verification"], name: "Disabled Dates", render: () => <DisabledDates /> }
|
||||
export const RangeSelection: Story = { tags: ["verification"], name: "Range", render: () => <Range /> }
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Button } from "@hm-design/ui"
|
||||
import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, type CardSurface } from "@hm-design/ui"
|
||||
import { Input } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Story structure follows Button / Input / Select: one representative "Card" Story shows the ui_viz cases on one page,
|
||||
// the same specimens also exist as separate Stories tagged `verification` (hidden from the sidebar, see .storybook/main.ts).
|
||||
// ui_viz (content-container.md Type B Content Card) defines only the Default state, two surfaces, a Title with an optional
|
||||
// Meta / Action, and a body. Hover / Selected / Clickable, Footer and media are not defined, so they are not shown here
|
||||
// except in the Compatibility Story.
|
||||
|
||||
function Body({ children }: { children?: ReactNode }) {
|
||||
return <p className="text-sm">{children ?? "본문은 카드 안쪽 여백 24px 안에 놓입니다. 내용물의 모양은 각 컴포넌트가 정합니다."}</p>
|
||||
}
|
||||
|
||||
function Specimen({ surface = "surface", action, title = "프로젝트 정보", children }: { surface?: CardSurface; action?: boolean; title?: string; children?: ReactNode }) {
|
||||
return (
|
||||
<Card surface={surface}>
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
{action ? (
|
||||
<CardAction>
|
||||
<Button variant="primary" appearance="solid" size="sm">
|
||||
자세히 보기
|
||||
</Button>
|
||||
</CardAction>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent>{children ?? <Body />}</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function FormSpecimen() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>입력 폼</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-2">
|
||||
<label htmlFor="card-form-name" className="text-sm font-medium text-muted-foreground">
|
||||
프로젝트명
|
||||
</label>
|
||||
<Input id="card-form-name" size="medium" placeholder="프로젝트명을 입력하세요" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Components/Data & Display/Card",
|
||||
component: Card,
|
||||
parameters: { layout: "padded" },
|
||||
argTypes: {
|
||||
surface: { control: "inline-radio", options: ["surface", "content"], description: "ui_viz surface 축: White(기본) / Beige." },
|
||||
},
|
||||
} satisfies Meta<typeof Card>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Representative Story: the common Story page (Overview → Variants → Usage) with every ui_viz case. Named after the component, as in ui_viz.
|
||||
// It is the only Card entry in the default sidebar, so Storybook shows Card as a single 1-depth item.
|
||||
export const All: Story = {
|
||||
name: "Card",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Card"
|
||||
description="제목과 본문을 하나의 면으로 묶는 콘텐츠 컨테이너입니다(ui_viz Content Card). Default 상태만 정의되어 있습니다."
|
||||
/>
|
||||
<StorySection title="Overview" description="surface를 생략한 기본 Card(White)입니다.">
|
||||
<StateBoard min={360} states={[{ id: "surface", label: "Surface · White (기본)", children: <Specimen /> }]} />
|
||||
</StorySection>
|
||||
<StorySection title="Variants" description="surface: content(Beige)와 제목 옆 Action입니다.">
|
||||
<StateBoard
|
||||
min={360}
|
||||
states={[
|
||||
{ id: "content", label: "Surface · Content (Beige)", children: <Specimen surface="content" /> },
|
||||
{ id: "action", label: "Title + Action", noteAfter: true, note: "Action은 Title 반대쪽 끝, 교차축 중앙 정렬. Title과 20px 이상 간격.", children: <Specimen action /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Usage">
|
||||
<StateBoard min={360} states={[{ id: "form", label: "본문 조합 · Input", children: <FormSpecimen /> }]} />
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
// Verification Stories: the same specimens as separate Stories, hidden from the sidebar (tag `verification`).
|
||||
export const Surfaces: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => (
|
||||
<div className="grid max-w-md gap-6">
|
||||
<Specimen />
|
||||
<Specimen surface="content" />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
export const Action: Story = { tags: ["verification"], render: () => <div className="max-w-md"><Specimen action /></div> }
|
||||
|
||||
// HM only: not part of the ui_viz Card spec. Footer, Description and size="sm" are shadcn defaults that ui_viz does not define.
|
||||
export const Compatibility: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => (
|
||||
<div className="grid max-w-md gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>shadcn 호환</CardTitle>
|
||||
<CardDescription>ui_viz에 없는 Description 입니다. shadcn 기본 스타일을 씁니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Body>Footer도 ui_viz 규격이 없어 shadcn 기본 표면을 씁니다.</Body>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button variant="cancel" appearance="solid" size="sm">
|
||||
닫기
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle>size="sm"</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Body>shadcn 작은 크기(여백 12px)입니다. ui_viz에 없습니다.</Body>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useEffect, useState, type ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { AspectRatio, Card, CardContent, Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious, type CarouselApi } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// The representative Story shows real, draggable carousels; the same specimens also exist as Stories tagged `verification`
|
||||
// (hidden from the sidebar). Slide widths (`basis-*`) are the consumer's `className` on CarouselItem; Card and AspectRatio are
|
||||
// Story composition (Carousel imports neither). `setApi` feeds the "slide n / total" readout.
|
||||
|
||||
const frame = (node: ReactNode) => <div style={{ width: "100%", maxWidth: 420, minWidth: 0 }}>{node}</div>
|
||||
|
||||
const svg = (n: number) =>
|
||||
"data:image/svg+xml;utf8," +
|
||||
encodeURIComponent(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="480" height="270"><rect width="480" height="270" fill="${["#266651", "#3e3523", "#205241", "#604f32", "#123328"][n % 5]}"/><text x="24" y="52" font-family="sans-serif" font-size="34" fill="#ffffff">Slide ${n + 1}</text></svg>`
|
||||
)
|
||||
|
||||
function SlideCard({ n }: { n: number }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="grid h-24 place-items-center text-2xl font-semibold">{n + 1}</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function useReadout() {
|
||||
const [api, setApi] = useState<CarouselApi>()
|
||||
const [current, setCurrent] = useState(0)
|
||||
const [count, setCount] = useState(0)
|
||||
useEffect(() => {
|
||||
if (!api) return
|
||||
setCount(api.scrollSnapList().length)
|
||||
setCurrent(api.selectedScrollSnap() + 1)
|
||||
api.on("select", () => setCurrent(api.selectedScrollSnap() + 1))
|
||||
}, [api])
|
||||
return { setApi, current, count }
|
||||
}
|
||||
|
||||
function Basic() {
|
||||
const { setApi, current, count } = useReadout()
|
||||
return frame(
|
||||
<div className="grid gap-2">
|
||||
<Carousel setApi={setApi} aria-label="기본 carousel">
|
||||
<CarouselContent>
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<CarouselItem key={i}>
|
||||
<SlideCard n={i} />
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
<CarouselPrevious />
|
||||
<CarouselNext />
|
||||
</Carousel>
|
||||
<span className="text-center text-sm text-muted-foreground" data-testid="readout">{`slide ${current} / ${count}`}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Multiple() {
|
||||
return frame(
|
||||
<Carousel opts={{ align: "start" }} aria-label="여러 항목 carousel">
|
||||
<CarouselContent>
|
||||
{Array.from({ length: 8 }, (_, i) => (
|
||||
<CarouselItem key={i} className="basis-1/2">
|
||||
<SlideCard n={i} />
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
<CarouselPrevious />
|
||||
<CarouselNext />
|
||||
</Carousel>
|
||||
)
|
||||
}
|
||||
|
||||
function Loop() {
|
||||
const { setApi, current, count } = useReadout()
|
||||
return frame(
|
||||
<div className="grid gap-2">
|
||||
<Carousel opts={{ loop: true }} setApi={setApi} aria-label="loop carousel">
|
||||
<CarouselContent>
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<CarouselItem key={i}>
|
||||
<SlideCard n={i} />
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
<CarouselPrevious />
|
||||
<CarouselNext />
|
||||
</Carousel>
|
||||
<span className="text-center text-sm text-muted-foreground" data-testid="loop-readout">{`slide ${current} / ${count}`}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Vertical() {
|
||||
return (
|
||||
<div style={{ width: 220, height: 260 }}>
|
||||
<Carousel orientation="vertical" opts={{ align: "start" }} aria-label="세로 carousel" className="h-full">
|
||||
<CarouselContent style={{ height: 260 }}>
|
||||
{Array.from({ length: 4 }, (_, i) => (
|
||||
<CarouselItem key={i} className="basis-1/2">
|
||||
<SlideCard n={i} />
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
<CarouselPrevious />
|
||||
<CarouselNext />
|
||||
</Carousel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Media() {
|
||||
return frame(
|
||||
<Carousel aria-label="미디어 carousel">
|
||||
<CarouselContent>
|
||||
{Array.from({ length: 4 }, (_, i) => (
|
||||
<CarouselItem key={i}>
|
||||
<AspectRatio ratio={16 / 9}>
|
||||
<img src={svg(i)} alt={`슬라이드 ${i + 1}`} className="size-full rounded-md object-cover" />
|
||||
</AspectRatio>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
<CarouselPrevious />
|
||||
<CarouselNext />
|
||||
</Carousel>
|
||||
)
|
||||
}
|
||||
|
||||
const sections = [
|
||||
{ id: "basic", label: "Basic (single item) + setApi readout", node: <Basic /> },
|
||||
{ id: "multiple", label: "Multiple items (basis-1/2)", node: <Multiple /> },
|
||||
{ id: "loop", label: "Loop (controls never disable)", node: <Loop /> },
|
||||
{ id: "vertical", label: "Vertical", node: <Vertical /> },
|
||||
{ id: "media", label: "Media (AspectRatio composition)", node: <Media /> },
|
||||
]
|
||||
|
||||
const meta = { title: "Components/Data & Display/Carousel", component: Carousel, parameters: { layout: "padded" } } satisfies Meta<typeof Carousel>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const board = (...ids: string[]) => (
|
||||
<StateBoard min={460} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} />
|
||||
)
|
||||
|
||||
// Representative Story: the common Story page; the sections group the same specimens.
|
||||
export const All: Story = {
|
||||
name: "Carousel",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Carousel"
|
||||
description="여러 슬라이드를 끌기·버튼으로 넘겨 보는 영역입니다. 슬라이드 폭(basis-*)은 CarouselItem의 className으로 정합니다."
|
||||
/>
|
||||
<StorySection title="Overview">{board("basic")}</StorySection>
|
||||
<StorySection title="Variants" description="여러 항목 표시, 반복(loop), 세로 방향입니다.">{board("multiple", "loop", "vertical")}</StorySection>
|
||||
<StorySection title="Usage" description="AspectRatio와 조합한 이미지 슬라이드입니다.">{board("media")}</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const BasicCarousel: Story = { tags: ["verification"], name: "Basic", render: () => <Basic /> }
|
||||
export const MultipleItems: Story = { tags: ["verification"], name: "Multiple Items", render: () => <Multiple /> }
|
||||
export const LoopCarousel: Story = { tags: ["verification"], name: "Loop", render: () => <Loop /> }
|
||||
export const VerticalCarousel: Story = { tags: ["verification"], name: "Vertical", render: () => <Vertical /> }
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useId, useState, type ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Checkbox, Label } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Current HM Checkbox. ui-viz has no Checkbox spec, so it was reference only.
|
||||
// The option label is the HM Label; its foreground role for Checkbox options is still TBD in ui_viz.
|
||||
|
||||
function Option({ label, children }: { label: string; children: (id: string) => ReactNode }) {
|
||||
const id = useId()
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{children(id)}
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Controlled() {
|
||||
const [checked, setChecked] = useState<boolean | "indeterminate">("indeterminate")
|
||||
return (
|
||||
<Option label={`Select all (${String(checked)})`}>
|
||||
{(id) => <Checkbox id={id} checked={checked} onCheckedChange={setChecked} />}
|
||||
</Option>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Components/Form/Checkbox",
|
||||
component: Checkbox,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"현재 HM 규격: 18px 박스, radius 4, border 1px(stroke), Checked/Indeterminate는 state-active 테두리·면 + on-state-active 아이콘. Radix Checkbox의 data-state, indeterminate, 키보드(Space)와 접근성을 유지합니다. ui-viz에는 Checkbox 규격이 없어 참고만 했습니다.",
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Checkbox>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const All: Story = {
|
||||
name: "Checkbox",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Checkbox"
|
||||
description="항목을 선택하거나 해제하는 컨트롤입니다. 18px 박스이며 Checked·Indeterminate는 state-active 색입니다."
|
||||
/>
|
||||
<StorySection title="Overview">
|
||||
<StateBoard
|
||||
min={240}
|
||||
states={[
|
||||
{ id: "unchecked", label: "Unchecked", children: <Option label="Option">{(id) => <Checkbox id={id} />}</Option> },
|
||||
{ id: "checked", label: "Checked", children: <Option label="Option">{(id) => <Checkbox id={id} defaultChecked />}</Option> },
|
||||
{ id: "indeterminate", label: "Indeterminate", children: <Option label="Option">{(id) => <Checkbox id={id} checked="indeterminate" />}</Option> },
|
||||
{ id: "interactive", label: "Controlled · click toggles", noteAfter: true, note: "Indeterminate에서 클릭하면 checked가 됩니다(Radix).", children: <Controlled /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="States">
|
||||
<StateBoard
|
||||
min={240}
|
||||
states={[
|
||||
{ id: "hover", label: "Hover", noteAfter: true, note: "Unchecked는 테두리가 primary 색, Checked는 Black 20% overlay. 포인터를 올려 확인합니다.", children: <Option label="Option">{(id) => <Checkbox id={id} />}</Option> },
|
||||
{ id: "focus", label: "Focus", children: <Option label="Option">{(id) => <Checkbox id={id} />}</Option> },
|
||||
{
|
||||
id: "disabled",
|
||||
label: "Disabled",
|
||||
children: (
|
||||
<div className="grid gap-3">
|
||||
<Option label="Unchecked">{(id) => <Checkbox id={id} disabled />}</Option>
|
||||
<Option label="Checked">{(id) => <Checkbox id={id} disabled defaultChecked />}</Option>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Validation" description="aria-invalid로 Error 테두리를 표시합니다.">
|
||||
<StateBoard min={240} states={[{ id: "error", label: "Error", children: <Option label="Option">{(id) => <Checkbox id={id} aria-invalid />}</Option> }]} />
|
||||
</StorySection>
|
||||
<StorySection title="Usage">
|
||||
<StateBoard
|
||||
min={240}
|
||||
states={[
|
||||
{
|
||||
id: "long",
|
||||
label: "Long Label",
|
||||
children: (
|
||||
<div className="max-w-[220px]">
|
||||
<div className="flex items-start gap-2">
|
||||
<Checkbox id="cb-long" className="mt-0.5" />
|
||||
<Label htmlFor="cb-long">A very long checkbox label that wraps onto multiple lines</Label>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
// preventScroll: the page opens at its header, not at the States section.
|
||||
canvasElement.querySelector<HTMLButtonElement>('[data-overview-state="focus"] [role="checkbox"]')?.focus({ preventScroll: true })
|
||||
},
|
||||
}
|
||||
|
||||
export const Unchecked: Story = { tags: ["verification"], render: () => <Option label="Option">{(id) => <Checkbox id={id} />}</Option> }
|
||||
export const Checked: Story = { tags: ["verification"], render: () => <Option label="Option">{(id) => <Checkbox id={id} defaultChecked />}</Option> }
|
||||
export const Indeterminate: Story = { tags: ["verification"], render: () => <Option label="Option">{(id) => <Checkbox id={id} checked="indeterminate" />}</Option> }
|
||||
export const Disabled: Story = { tags: ["verification"], render: () => <Option label="Option">{(id) => <Checkbox id={id} disabled defaultChecked />}</Option> }
|
||||
export const Error: Story = { tags: ["verification"], render: () => <Option label="Option">{(id) => <Checkbox id={id} aria-invalid />}</Option> }
|
||||
export const Playground: Story = {
|
||||
tags: ["verification"],
|
||||
args: { disabled: false, defaultChecked: false },
|
||||
render: (args) => <Option label="Option">{(id) => <Checkbox id={id} {...args} />}</Option>,
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useState, type ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
import { Button, Collapsible, CollapsibleContent, CollapsibleTrigger } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// The representative "Collapsible" Story shows one independent disclosure per state with real, clickable triggers. The same
|
||||
// sections also exist as Stories tagged `verification` (hidden from the sidebar). Collapsible is a single disclosure: no divider
|
||||
// system and no animation.
|
||||
|
||||
const longText = "긴 텍스트는 줄바꿈으로 전체를 표시합니다. 공백 없는 긴 문자열도 너비를 밀어내지 않고 줄바꿈합니다. ".repeat(6)
|
||||
|
||||
const narrow = (children: ReactNode) => <div className="w-full max-w-md">{children}</div>
|
||||
|
||||
function Frame({ label, defaultOpen, disabled, children }: { label: string; defaultOpen?: boolean; disabled?: boolean; children: ReactNode }) {
|
||||
return narrow(
|
||||
<Collapsible defaultOpen={defaultOpen} disabled={disabled}>
|
||||
<CollapsibleTrigger>
|
||||
{label}
|
||||
<ChevronDownIcon aria-hidden />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>{children}</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
|
||||
const ClosedSpecimen = () => <Frame label="고급 설정 보기">추가 옵션이 여기에 표시됩니다.</Frame>
|
||||
const OpenSpecimen = () => (
|
||||
<Frame label="고급 설정 보기" defaultOpen>
|
||||
처음부터 열려 있는 영역입니다.
|
||||
</Frame>
|
||||
)
|
||||
const DisabledSpecimen = () => (
|
||||
<Frame label="비활성 영역" disabled>
|
||||
열 수 없습니다.
|
||||
</Frame>
|
||||
)
|
||||
const LongSpecimen = () => (
|
||||
<Frame label="긴 콘텐츠" defaultOpen>
|
||||
<p>{longText}</p>
|
||||
<p className="mt-2">AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA</p>
|
||||
</Frame>
|
||||
)
|
||||
|
||||
// Controlled: the parent owns `open`; an HM Button is the trigger through asChild and keeps its style.
|
||||
function ControlledSpecimen() {
|
||||
const [open, setOpen] = useState(false)
|
||||
return narrow(
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="primary" appearance="outline">
|
||||
{open ? "접기" : "펼치기"}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>{`controlled: open = ${String(open)}`}</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
|
||||
const sections = [
|
||||
{ id: "closed", label: "Closed", node: <ClosedSpecimen /> },
|
||||
{ id: "open", label: "Open", node: <OpenSpecimen /> },
|
||||
{ id: "disabled", label: "Disabled", node: <DisabledSpecimen /> },
|
||||
{ id: "long", label: "Long Content", node: <LongSpecimen /> },
|
||||
{ id: "controlled", label: "Controlled + Button trigger", note: "asChild: Button이 trigger이며 HM Button 스타일 유지", node: <ControlledSpecimen /> },
|
||||
]
|
||||
|
||||
const meta = { title: "Components/Disclosure/Collapsible", component: Collapsible, parameters: { layout: "padded" } } satisfies Meta<typeof Collapsible>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const board = (...ids: string[]) => (
|
||||
<StateBoard min={360} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, note: s.note, children: s.node }))} />
|
||||
)
|
||||
|
||||
// Representative Story: the common Story page; the sections group the same specimens.
|
||||
export const All: Story = {
|
||||
name: "Collapsible",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Collapsible"
|
||||
description="하나의 영역을 펼치고 접는 단일 disclosure입니다. 구분선 체계와 애니메이션은 없습니다."
|
||||
/>
|
||||
<StorySection title="Overview">{board("closed")}</StorySection>
|
||||
<StorySection title="States">{board("open", "disabled")}</StorySection>
|
||||
<StorySection title="Usage" description="긴 콘텐츠와, Button을 trigger로 쓰는 제어형 조합입니다.">{board("long", "controlled")}</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const Closed: Story = { tags: ["verification"], render: () => <ClosedSpecimen /> }
|
||||
export const Open: Story = { tags: ["verification"], render: () => <OpenSpecimen /> }
|
||||
export const Disabled: Story = { tags: ["verification"], render: () => <DisabledSpecimen /> }
|
||||
export const LongContent: Story = { tags: ["verification"], name: "Long Content", render: () => <LongSpecimen /> }
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useState, type ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Combobox, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, type ComboboxOption } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Combobox is a Popover + Command composition (search + single choice); Select stays for short lists without search.
|
||||
// The representative Story shows real comboboxes; the same specimens also exist as Stories tagged `verification` (hidden from the
|
||||
// sidebar). The Label is Story composition.
|
||||
|
||||
const frameworks: ComboboxOption[] = [
|
||||
{ value: "next", label: "Next.js" },
|
||||
{ value: "sveltekit", label: "SvelteKit" },
|
||||
{ value: "nuxt", label: "Nuxt" },
|
||||
{ value: "remix", label: "Remix", disabled: true },
|
||||
{ value: "astro", label: "Astro" },
|
||||
]
|
||||
|
||||
const longOptions: ComboboxOption[] = [
|
||||
{ value: "a", label: "긴 옵션 이름은 한 줄에서 말줄임으로 잘리고 trigger 폭을 넘지 않습니다. 끝까지 읽을 수 있어야 하면 폭을 넓히세요" },
|
||||
{ value: "b", label: "짧은 옵션" },
|
||||
]
|
||||
|
||||
function Field({ label, id, children }: { label: string; id: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="grid max-w-xs gap-2">
|
||||
<label htmlFor={id} className="justify-self-start text-sm font-medium text-muted-foreground">
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Empty() {
|
||||
return (
|
||||
<Field label="프레임워크" id="cb-empty">
|
||||
<Combobox id="cb-empty" options={frameworks} placeholder="프레임워크 선택" searchPlaceholder="프레임워크 검색..." />
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
function Controlled() {
|
||||
const [value, setValue] = useState("nuxt")
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<Field label="선택됨 (controlled)" id="cb-controlled">
|
||||
<Combobox id="cb-controlled" controlled options={frameworks} value={value} onValueChange={setValue} />
|
||||
</Field>
|
||||
<span className="text-sm text-muted-foreground" data-testid="controlled">{`value = ${value}`}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Medium() {
|
||||
return (
|
||||
<Field label="Medium" id="cb-medium">
|
||||
<Combobox id="cb-medium" size="medium" options={frameworks} defaultValue="astro" />
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
function LongLabel() {
|
||||
return (
|
||||
<Field label="긴 옵션" id="cb-long">
|
||||
<Combobox id="cb-long" options={longOptions} defaultValue="a" />
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
function Disabled() {
|
||||
return (
|
||||
<Field label="비활성" id="cb-disabled">
|
||||
<Combobox id="cb-disabled" options={frameworks} defaultValue="next" disabled />
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectComparison() {
|
||||
return (
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
{(["medium", "large"] as const).map((size) => (
|
||||
<div key={size} className="grid content-start gap-3" data-comparison-size={size}>
|
||||
<Field label={`Select · ${size}`} id={`compare-select-${size}`}>
|
||||
<Select defaultValue="nuxt">
|
||||
<SelectTrigger id={`compare-select-${size}`} size={size} className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{frameworks.map((option) => <SelectItem key={option.value} value={option.value} disabled={option.disabled}>{option.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label={`Combobox · ${size}`} id={`compare-combobox-${size}`}>
|
||||
<Combobox id={`compare-combobox-${size}`} size={size} options={frameworks} defaultValue="nuxt" />
|
||||
</Field>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const sections = [
|
||||
{ id: "empty", label: "Empty placeholder + search (Remix is a disabled option)", node: <Empty /> },
|
||||
{ id: "controlled", label: "Selected, controlled", node: <Controlled /> },
|
||||
{ id: "medium", label: "Medium size", node: <Medium /> },
|
||||
{ id: "long", label: "Long option", node: <LongLabel /> },
|
||||
{ id: "disabled", label: "Disabled", node: <Disabled /> },
|
||||
]
|
||||
|
||||
// No `component`: Combobox has a required `options` prop, so Stories without args would not type-check.
|
||||
const meta = { title: "Components/Search & Date/Combobox", parameters: { layout: "padded" } } satisfies Meta
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const board = (...ids: string[]) => (
|
||||
<StateBoard min={320} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} />
|
||||
)
|
||||
|
||||
// Representative Story: the common Story page; the sections group the same specimens.
|
||||
export const All: Story = {
|
||||
name: "Combobox",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Combobox"
|
||||
description="검색해서 하나를 고르는 Popover + Command 조합입니다. 검색이 필요 없는 짧은 목록은 Select를 씁니다."
|
||||
/>
|
||||
<StorySection title="Overview">{board("empty")}</StorySection>
|
||||
<StorySection title="Sizes" description="size를 생략하면 Large입니다. 같은 size의 Select와 나란히 비교합니다.">
|
||||
<StateBoard
|
||||
min={320}
|
||||
states={[
|
||||
...sections.filter((s) => s.id === "medium").map((s) => ({ id: s.id, label: s.label, children: s.node })),
|
||||
{ id: "select-comparison", label: "Select / Combobox visual comparison", wide: true, children: <SelectComparison /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="States">{board("controlled", "disabled")}</StorySection>
|
||||
<StorySection title="Usage">{board("long")}</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const EmptyCombobox: Story = { tags: ["verification"], name: "Empty", render: () => <Empty /> }
|
||||
export const ControlledCombobox: Story = { tags: ["verification"], name: "Controlled", render: () => <Controlled /> }
|
||||
export const LongOption: Story = { tags: ["verification"], name: "Long Option", render: () => <LongLabel /> }
|
||||
|
||||
export const OpenBottom: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => <div style={{ width: 280, maxWidth: "100%", minHeight: "calc(100dvh - 32px)" }}><Controlled /></div>,
|
||||
}
|
||||
export const OpenTop: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => <div style={{ width: 280, maxWidth: "100%", minHeight: "calc(100dvh - 32px)", display: "grid", alignItems: "end" }}><Controlled /></div>,
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useState, type ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { CalendarIcon, SettingsIcon, UserIcon } from "lucide-react"
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, CommandShortcut } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// The representative "Command" Story shows real, typeable lists; the same specimens also exist as Stories tagged `verification`
|
||||
// (hidden from the sidebar). Filtering is cmdk's. The bordered box is Story composition (Command draws no border of its own).
|
||||
// A selection log shows that Enter / click reach `onSelect`.
|
||||
|
||||
const box = (node: ReactNode) => <div className="w-full max-w-sm rounded-md border">{node}</div>
|
||||
|
||||
function Basic() {
|
||||
const [last, setLast] = useState("-")
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
{box(
|
||||
<Command>
|
||||
<CommandInput placeholder="검색..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>결과가 없습니다.</CommandEmpty>
|
||||
<CommandItem onSelect={setLast}>사과</CommandItem>
|
||||
<CommandItem onSelect={setLast}>바나나</CommandItem>
|
||||
<CommandItem onSelect={setLast} disabled>
|
||||
체리 (품절)
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={setLast}>포도</CommandItem>
|
||||
</CommandList>
|
||||
</Command>
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground" data-testid="last">{`선택: ${last}`}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Grouped() {
|
||||
return box(
|
||||
<Command>
|
||||
<CommandInput placeholder="명령 검색..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>결과가 없습니다.</CommandEmpty>
|
||||
<CommandGroup heading="제안">
|
||||
<CommandItem>
|
||||
<CalendarIcon />
|
||||
일정 보기
|
||||
</CommandItem>
|
||||
<CommandItem>
|
||||
<UserIcon />
|
||||
프로필
|
||||
<CommandShortcut>⌘P</CommandShortcut>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="설정">
|
||||
<CommandItem>
|
||||
<SettingsIcon />
|
||||
환경설정
|
||||
<CommandShortcut>⌘,</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem disabled>결제 (사용 불가)</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
)
|
||||
}
|
||||
|
||||
function Long() {
|
||||
return box(
|
||||
<Command>
|
||||
<CommandInput placeholder="항목 검색..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>결과가 없습니다.</CommandEmpty>
|
||||
{Array.from({ length: 30 }, (_, i) => (
|
||||
<CommandItem key={i}>{`항목 ${i + 1} — 긴 항목 이름도 줄바꿈 없이 한 줄로 이어질 수 있습니다`}</CommandItem>
|
||||
))}
|
||||
</CommandList>
|
||||
</Command>
|
||||
)
|
||||
}
|
||||
|
||||
const sections = [
|
||||
{ id: "basic", label: "Search + disabled item", node: <Basic /> },
|
||||
{ id: "grouped", label: "Groups, icons, shortcut", node: <Grouped /> },
|
||||
{ id: "long", label: "Long list (list scrolls)", node: <Long /> },
|
||||
]
|
||||
|
||||
const meta = { title: "Components/Search & Date/Command", component: Command, parameters: { layout: "padded" } } satisfies Meta<typeof Command>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const board = (...ids: string[]) => (
|
||||
<StateBoard min={360} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} />
|
||||
)
|
||||
|
||||
// Representative Story: the common Story page; the sections group the same specimens.
|
||||
export const All: Story = {
|
||||
name: "Command",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Command"
|
||||
description="입력한 검색어로 항목을 걸러 고르는 목록입니다. Combobox와 명령 팔레트의 기반입니다."
|
||||
/>
|
||||
<StorySection title="Overview">{board("basic")}</StorySection>
|
||||
<StorySection title="Variants">{board("grouped")}</StorySection>
|
||||
<StorySection title="Usage">{board("long")}</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const Search: Story = { tags: ["verification"], render: () => <Basic /> }
|
||||
export const GroupedList: Story = { tags: ["verification"], name: "Grouped", render: () => <Grouped /> }
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useState } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuContent,
|
||||
ContextMenuGroup,
|
||||
ContextMenuItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuRadioGroup,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuTrigger,
|
||||
} from "@hm-design/ui"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Context Menu opens from pointer context (right click, the context-menu key / Shift+F10, long press) on its trigger area.
|
||||
// The representative Story is a target area: right click it. Radix owns keyboard navigation, focus return and Escape.
|
||||
|
||||
function Full() {
|
||||
const [showStatus, setShowStatus] = useState(true)
|
||||
const [showPanel, setShowPanel] = useState(false)
|
||||
const [position, setPosition] = useState("bottom")
|
||||
return (
|
||||
<ContextMenu>
|
||||
{/* tabIndex lets keyboard users focus the area and open the menu with Shift+F10 / the context-menu key; Radix Trigger is a plain span. */}
|
||||
<ContextMenuTrigger tabIndex={0} className="flex h-40 w-80 items-center justify-center rounded-md border border-dashed text-sm text-muted-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring">
|
||||
이 영역에서 오른쪽 클릭
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-56">
|
||||
<ContextMenuLabel>파일</ContextMenuLabel>
|
||||
<ContextMenuGroup>
|
||||
<ContextMenuItem>
|
||||
열기 <ContextMenuShortcut>⌘O</ContextMenuShortcut>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem>
|
||||
이름 바꾸기 <ContextMenuShortcut>F2</ContextMenuShortcut>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled>붙여넣기 (사용 불가)</ContextMenuItem>
|
||||
</ContextMenuGroup>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuLabel>보기</ContextMenuLabel>
|
||||
<ContextMenuCheckboxItem checked={showStatus} onCheckedChange={setShowStatus}>
|
||||
상태 표시줄
|
||||
</ContextMenuCheckboxItem>
|
||||
<ContextMenuCheckboxItem checked={showPanel} onCheckedChange={setShowPanel}>
|
||||
패널
|
||||
</ContextMenuCheckboxItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuLabel>위치</ContextMenuLabel>
|
||||
<ContextMenuRadioGroup value={position} onValueChange={setPosition}>
|
||||
<ContextMenuRadioItem value="top">상단</ContextMenuRadioItem>
|
||||
<ContextMenuRadioItem value="bottom">하단</ContextMenuRadioItem>
|
||||
<ContextMenuRadioItem value="right">오른쪽</ContextMenuRadioItem>
|
||||
</ContextMenuRadioGroup>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>더 보기</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent>
|
||||
<ContextMenuItem>이메일로 공유</ContextMenuItem>
|
||||
<ContextMenuItem>링크 복사</ContextMenuItem>
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem variant="destructive">삭제</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = { title: "Components/Overlay/Context Menu", component: ContextMenu, parameters: { layout: "centered" } } satisfies Meta<typeof ContextMenu>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Representative Story: right click the area to open the full menu (item, disabled, separator, shortcut, checkbox, radio group, sub menu, destructive).
|
||||
export const All: Story = {
|
||||
name: "Context Menu",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Context Menu"
|
||||
description="영역에서 오른쪽 클릭, 컨텍스트 메뉴 키 / Shift+F10, 길게 누르기로 여는 메뉴입니다."
|
||||
/>
|
||||
<StorySection title="Overview" description="항목 · Disabled · 단축키 · Checkbox · Radio Group · 하위 메뉴 · 위험 항목을 한 메뉴에서 확인합니다. 점선 영역에서 오른쪽 클릭합니다.">
|
||||
<Full />
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
// Verification Story: opens the menu at the area center on load (synthetic contextmenu event), hidden from the sidebar (tag `verification`).
|
||||
export const Open: Story = {
|
||||
tags: ["verification"],
|
||||
parameters: { layout: "fullscreen" },
|
||||
render: () => (
|
||||
<div className="flex min-h-[560px] justify-center p-8">
|
||||
<Full />
|
||||
</div>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
const area = canvasElement.querySelector<HTMLElement>("[data-slot='context-menu-trigger']")
|
||||
if (!area) return
|
||||
const r = area.getBoundingClientRect()
|
||||
area.dispatchEvent(
|
||||
new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: r.left + r.width / 2, clientY: r.top + r.height / 2, button: 2 }),
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useState, type ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { DatePicker } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// DatePicker is a Popover + Calendar composition packaged as a component (value handling, trigger text, close on select).
|
||||
// The representative Story shows real pickers; the same specimens also exist as Stories tagged `verification` (hidden from the
|
||||
// sidebar). The Label is Story composition (the way a consumer would); `today` is pinned through Calendar's own prop so the
|
||||
// Story looks the same on any day.
|
||||
|
||||
const today = new Date(2026, 9, 2)
|
||||
const calendarProps = { today, startMonth: new Date(2026, 0), endMonth: new Date(2027, 11) }
|
||||
|
||||
function Field({ label, id, children }: { label: string; id: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="grid max-w-xs gap-2">
|
||||
<label htmlFor={id} className="justify-self-start text-sm font-medium text-muted-foreground">
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Empty() {
|
||||
return (
|
||||
<Field label="시작일" id="dp-empty">
|
||||
<DatePicker id="dp-empty" calendarProps={calendarProps} />
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
function Selected() {
|
||||
return (
|
||||
<Field label="시작일" id="dp-selected">
|
||||
<DatePicker id="dp-selected" defaultValue={new Date(2026, 9, 6)} calendarProps={calendarProps} />
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
function FormatExamples() {
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<Field label="기본 형식 · YYYY.MM.DD" id="dp-padded">
|
||||
<DatePicker id="dp-padded" defaultValue={new Date(2026, 0, 9)} calendarProps={calendarProps} />
|
||||
</Field>
|
||||
<Field label="Custom format override" id="dp-custom">
|
||||
<DatePicker id="dp-custom" defaultValue={new Date(2026, 9, 6)} format={(date) => date.toLocaleDateString("ko-KR")} calendarProps={calendarProps} />
|
||||
</Field>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Controlled() {
|
||||
const [date, setDate] = useState<Date | undefined>()
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<Field label="마감일 (controlled)" id="dp-controlled">
|
||||
<DatePicker id="dp-controlled" controlled value={date} onValueChange={setDate} calendarProps={calendarProps} />
|
||||
</Field>
|
||||
<span className="text-sm text-muted-foreground" data-testid="controlled">{`value = ${date ? date.toLocaleDateString("ko-KR") : "undefined"}`}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Medium() {
|
||||
return (
|
||||
<Field label="Medium" id="dp-medium">
|
||||
<DatePicker id="dp-medium" size="medium" defaultValue={new Date(2026, 9, 15)} calendarProps={calendarProps} />
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
function DisabledDates() {
|
||||
return (
|
||||
<Field label="5일 이전과 일요일은 선택 불가" id="dp-disabled-dates">
|
||||
<DatePicker
|
||||
id="dp-disabled-dates"
|
||||
calendarProps={{ ...calendarProps, disabled: [{ before: new Date(2026, 9, 5) }, { dayOfWeek: [0] }] }}
|
||||
/>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
function Disabled() {
|
||||
return (
|
||||
<Field label="비활성" id="dp-disabled">
|
||||
<DatePicker id="dp-disabled" disabled defaultValue={new Date(2026, 9, 15)} />
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
const sections = [
|
||||
{ id: "empty", label: "Empty (placeholder)", node: <Empty /> },
|
||||
{ id: "selected", label: "Selected", node: <Selected /> },
|
||||
{ id: "format", label: "Default format / custom override", node: <FormatExamples /> },
|
||||
{ id: "controlled", label: "Controlled", node: <Controlled /> },
|
||||
{ id: "medium", label: "Medium size", node: <Medium /> },
|
||||
{ id: "disabled-dates", label: "Disabled dates", node: <DisabledDates /> },
|
||||
{ id: "disabled", label: "Disabled", node: <Disabled /> },
|
||||
]
|
||||
|
||||
const meta = { title: "Components/Search & Date/Date Picker", component: DatePicker, parameters: { layout: "padded" } } satisfies Meta<typeof DatePicker>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const board = (...ids: string[]) => (
|
||||
<StateBoard min={320} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} />
|
||||
)
|
||||
|
||||
// Representative Story: the common Story page; the sections group the same specimens.
|
||||
export const All: Story = {
|
||||
name: "Date Picker",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Date Picker"
|
||||
description="Trigger를 눌러 달력에서 날짜를 고르는 Popover + Calendar 조합 컴포넌트입니다. 날짜를 고르면 닫힙니다."
|
||||
/>
|
||||
<StorySection title="Overview">{board("empty")}</StorySection>
|
||||
<StorySection title="Sizes" description="size를 생략하면 Large입니다.">{board("medium")}</StorySection>
|
||||
<StorySection title="States">{board("selected", "controlled", "disabled-dates", "disabled")}</StorySection>
|
||||
<StorySection title="Usage" description="기본 표시 형식과 사용자 형식 지정입니다.">{board("format")}</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const EmptyPicker: Story = { tags: ["verification"], name: "Empty", render: () => <Empty /> }
|
||||
export const InvalidFocus: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => (
|
||||
<div className="grid max-w-xs gap-6">
|
||||
<button type="button">Before field</button>
|
||||
<Field label="Invalid Date Picker" id="dp-invalid">
|
||||
<DatePicker id="dp-invalid" aria-invalid defaultValue={new Date(2026, 9, 6)} calendarProps={calendarProps} />
|
||||
</Field>
|
||||
<button type="button">After field</button>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
export const SelectedPicker: Story = { tags: ["verification"], name: "Selected", render: () => <Selected /> }
|
||||
export const ControlledPicker: Story = { tags: ["verification"], name: "Controlled", render: () => <Controlled /> }
|
||||
@@ -0,0 +1,212 @@
|
||||
import type { CSSProperties, ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Button } 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@hm-design/ui"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// 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.
|
||||
|
||||
const longText = "긴 텍스트는 줄바꿈으로 전체를 표시합니다. 공백 없는 긴 문자열도 Dialog 너비를 밀어내지 않고 줄바꿈합니다. ".repeat(8)
|
||||
|
||||
function Frame({
|
||||
trigger,
|
||||
defaultOpen,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
actions,
|
||||
contentStyle,
|
||||
}: {
|
||||
trigger: string
|
||||
defaultOpen?: boolean
|
||||
title: string
|
||||
description?: string
|
||||
children: ReactNode
|
||||
actions?: ReactNode
|
||||
contentStyle?: CSSProperties
|
||||
}) {
|
||||
return (
|
||||
<Dialog defaultOpen={defaultOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="primary" appearance="outline">
|
||||
{trigger}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent style={contentStyle}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
{description ? <DialogDescription>{description}</DialogDescription> : null}
|
||||
</DialogHeader>
|
||||
<DialogBody>{children}</DialogBody>
|
||||
<DialogFooter>{actions}</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
const confirmActions = (
|
||||
<>
|
||||
<DialogClose asChild>
|
||||
<Button variant="cancel" appearance="solid">
|
||||
취소
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<DialogClose asChild>
|
||||
<Button variant="primary" appearance="solid">
|
||||
확인
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</>
|
||||
)
|
||||
|
||||
const deleteActions = (
|
||||
<>
|
||||
<DialogClose asChild>
|
||||
<Button variant="cancel" appearance="outline">
|
||||
취소
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<DialogClose asChild>
|
||||
<Button variant="destructive" appearance="outline">
|
||||
삭제
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</>
|
||||
)
|
||||
|
||||
function Basic({ defaultOpen }: { defaultOpen?: boolean }) {
|
||||
return (
|
||||
<Frame trigger="기본" defaultOpen={defaultOpen} title="작업 확인" description="현재 작업을 계속 진행하시겠습니까?" actions={confirmActions}>
|
||||
<p>짧은 내용의 Dialog는 내용 기준 크기입니다.</p>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
function LongContent({ defaultOpen }: { defaultOpen?: boolean }) {
|
||||
return (
|
||||
<Frame trigger="긴 내용" defaultOpen={defaultOpen} title="긴 내용" description="Body만 세로로 scroll 합니다." actions={confirmActions}>
|
||||
{Array.from({ length: 6 }, (_, i) => (
|
||||
<p key={i} className="mb-4">
|
||||
{longText}
|
||||
</p>
|
||||
))}
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
// ui_viz "세로 스크롤 · 본문만": the max height also leaves out the app common Header 70px / Footer 48px. The Dialog renders in a Portal,
|
||||
// so the app heights are passed on DialogContent as Dialog Tokens, not on a wrapper around the trigger.
|
||||
const appBars = { "--hm-dialog-app-header-height": "70px", "--hm-dialog-app-footer-height": "48px" } as CSSProperties
|
||||
|
||||
function BodyScroll({ defaultOpen }: { defaultOpen?: boolean }) {
|
||||
return (
|
||||
<Frame
|
||||
trigger="본문 스크롤"
|
||||
defaultOpen={defaultOpen}
|
||||
title="본문 스크롤"
|
||||
description="공통 Header 70px / Footer 48px를 뺀 높이 안에서 Body만 세로로 scroll 합니다."
|
||||
actions={confirmActions}
|
||||
contentStyle={appBars}
|
||||
>
|
||||
{Array.from({ length: 60 }, (_, i) => (
|
||||
<p key={i}>검토 기록 {i + 1}. 현장 조사와 부재별 검토 내용을 확인하고 후속 조치를 기록합니다.</p>
|
||||
))}
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
// The wide case composes the HM Table primitive (same as the Table Story), so it carries the Table look inside the Dialog Portal.
|
||||
const wideColumns = Array.from({ length: 10 }, (_, i) => `컬럼 ${i + 1}`)
|
||||
|
||||
function WideContent({ defaultOpen }: { defaultOpen?: boolean }) {
|
||||
return (
|
||||
<Frame trigger="넓은 표" defaultOpen={defaultOpen} title="넓은 내용" description="표처럼 줄이기 어려운 내용은 Body 안에서 가로 scroll 합니다." actions={confirmActions}>
|
||||
<Table style={{ minWidth: 1600 }}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{wideColumns.map((c) => (
|
||||
<TableHead key={c}>{c}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{Array.from({ length: 4 }, (_, r) => (
|
||||
<TableRow key={r}>
|
||||
{wideColumns.map((_, c) => (
|
||||
<TableCell key={c}>{`행 ${r + 1} · 열 ${c + 1}`}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
function DeleteConfirmation({ defaultOpen }: { defaultOpen?: boolean }) {
|
||||
return (
|
||||
<Frame trigger="삭제 확인" defaultOpen={defaultOpen} title="항목 삭제" description="삭제하면 되돌릴 수 없습니다." actions={deleteActions}>
|
||||
<p>선택한 항목을 삭제합니다. 취소와 삭제 실행은 같은 크기의 Outline 세트입니다.</p>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
function FormContent({ defaultOpen }: { defaultOpen?: boolean }) {
|
||||
return (
|
||||
<Frame trigger="폼" defaultOpen={defaultOpen} title="프로젝트 정보" actions={confirmActions}>
|
||||
<div className="grid gap-2">
|
||||
<label htmlFor="dialog-form-name" className="text-sm font-medium text-muted-foreground">
|
||||
프로젝트명
|
||||
</label>
|
||||
<Input id="dialog-form-name" size="medium" placeholder="프로젝트명을 입력하세요" />
|
||||
</div>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = { title: "Components/Overlay/Dialog", component: Dialog, parameters: { layout: "centered" } } satisfies Meta<typeof Dialog>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Representative Story: the common Story page; every case opens from its trigger. Named after the component, as in ui_viz.
|
||||
// It is the only Dialog entry in the default sidebar, so Storybook shows Dialog as a single 1-depth item.
|
||||
export const All: Story = {
|
||||
name: "Dialog",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Dialog"
|
||||
description="현재 화면 위에 내용을 띄워 확인·입력을 받는 모달입니다. 각 버튼을 눌러 실제 Dialog를 엽니다."
|
||||
/>
|
||||
<StorySection title="Overview" description="짧은 내용의 Dialog는 내용 기준 크기입니다.">
|
||||
<Basic />
|
||||
</StorySection>
|
||||
<StorySection title="Variants" description="내용이 길거나 넓으면 Body 안에서만 scroll 합니다. 본문 스크롤은 공통 Header 70px / Footer 48px를 뺀 높이를 씁니다.">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<LongContent />
|
||||
<WideContent />
|
||||
<BodyScroll />
|
||||
</div>
|
||||
</StorySection>
|
||||
<StorySection title="Usage" description="일반 확인은 Solid 세트, 삭제 확인은 취소·삭제 모두 Outline 세트입니다.">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<DeleteConfirmation />
|
||||
<FormContent />
|
||||
</div>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
// Verification Stories: the same cases starting open, hidden from the sidebar (tag `verification`).
|
||||
export const Open: Story = { tags: ["verification"], render: () => <Basic defaultOpen /> }
|
||||
export const LongContentOpen: Story = { tags: ["verification"], name: "Long Content", render: () => <LongContent defaultOpen /> }
|
||||
export const WideContentOpen: Story = { tags: ["verification"], name: "Wide Content", render: () => <WideContent defaultOpen /> }
|
||||
export const BodyScrollOpen: Story = { tags: ["verification"], name: "Body Scroll", render: () => <BodyScroll defaultOpen /> }
|
||||
export const DeleteConfirmationOpen: Story = { tags: ["verification"], name: "Delete Confirmation", render: () => <DeleteConfirmation defaultOpen /> }
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
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"
|
||||
|
||||
// Drawer is a modal vaul overlay (Portal). The representative Story opens the real Drawer from its triggers; drag the handle or
|
||||
// the panel down to dismiss. The same cases start open in the verification Stories. vaul owns the drag, Radix the focus and ARIA.
|
||||
|
||||
const longText = "긴 내용은 Body 영역만 세로로 scroll 하고 Header와 Footer는 그대로 보입니다. "
|
||||
|
||||
function Frame({
|
||||
trigger,
|
||||
defaultOpen,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
actions,
|
||||
}: {
|
||||
trigger: string
|
||||
defaultOpen?: boolean
|
||||
title: string
|
||||
description?: string
|
||||
children: React.ReactNode
|
||||
actions?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Drawer defaultOpen={defaultOpen}>
|
||||
<DrawerTrigger asChild>
|
||||
<Button variant="primary" appearance="outline">
|
||||
{trigger}
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>{title}</DrawerTitle>
|
||||
{description ? <DrawerDescription>{description}</DrawerDescription> : null}
|
||||
</DrawerHeader>
|
||||
<DrawerBody>{children}</DrawerBody>
|
||||
<DrawerFooter>{actions}</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
const actions = (
|
||||
<>
|
||||
<DrawerClose asChild>
|
||||
<Button variant="primary" appearance="solid">
|
||||
확인
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
<DrawerClose asChild>
|
||||
<Button variant="cancel" appearance="solid">
|
||||
취소
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
</>
|
||||
)
|
||||
|
||||
function Basic({ defaultOpen }: { defaultOpen?: boolean }) {
|
||||
return (
|
||||
<Frame trigger="기본" defaultOpen={defaultOpen} title="작업 선택" description="아래에서 올라오는 패널입니다. 핸들을 아래로 끌어 닫을 수 있습니다." actions={actions}>
|
||||
<p>짧은 내용은 내용 높이만큼만 차지합니다.</p>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
function FormCase({ defaultOpen }: { defaultOpen?: boolean }) {
|
||||
return (
|
||||
<Frame trigger="폼" defaultOpen={defaultOpen} title="프로젝트 정보" actions={actions}>
|
||||
<div className="grid gap-2">
|
||||
<label htmlFor="drawer-form-name" className="text-sm font-medium text-muted-foreground">
|
||||
프로젝트명
|
||||
</label>
|
||||
<Input id="drawer-form-name" size="medium" placeholder="프로젝트명을 입력하세요" />
|
||||
</div>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
function LongCase({ defaultOpen }: { defaultOpen?: boolean }) {
|
||||
return (
|
||||
<Frame trigger="긴 내용" defaultOpen={defaultOpen} title="긴 내용" description="Body만 scroll 합니다." actions={actions}>
|
||||
{Array.from({ length: 30 }, (_, i) => (
|
||||
<p key={i} className="mb-3">
|
||||
{longText}
|
||||
</p>
|
||||
))}
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = { title: "Components/Overlay/Drawer", component: Drawer, parameters: { layout: "centered" } } satisfies Meta<typeof Drawer>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const All: Story = {
|
||||
name: "Drawer",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Drawer"
|
||||
description="화면 아래에서 올라오는 모달 패널입니다. 핸들이나 패널을 아래로 끌어 닫습니다."
|
||||
/>
|
||||
<StorySection title="Overview" description="기본 Drawer입니다. 버튼을 눌러 엽니다.">
|
||||
<Basic />
|
||||
</StorySection>
|
||||
<StorySection title="Usage" description="폼 입력 조합과, 내용이 길 때 Body만 scroll 하는 경우입니다.">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<FormCase />
|
||||
<LongCase />
|
||||
</div>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const Open: Story = { tags: ["verification"], render: () => <Basic defaultOpen /> }
|
||||
export const Form: Story = { tags: ["verification"], render: () => <FormCase defaultOpen /> }
|
||||
export const LongContent: Story = { tags: ["verification"], name: "Long Content", render: () => <LongCase defaultOpen /> }
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useState } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@hm-design/ui"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Dropdown Menu is a Radix overlay (Portal), so the representative Story is a trigger that opens the real menu, the same way as
|
||||
// Dialog. The verification Story starts open. Radix owns keyboard navigation, focus return and Escape.
|
||||
|
||||
function Full({ defaultOpen }: { defaultOpen?: boolean }) {
|
||||
const [showStatus, setShowStatus] = useState(true)
|
||||
const [showPanel, setShowPanel] = useState(false)
|
||||
const [position, setPosition] = useState("bottom")
|
||||
return (
|
||||
<DropdownMenu defaultOpen={defaultOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="primary" appearance="outline">
|
||||
메뉴 열기
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" align="start">
|
||||
<DropdownMenuLabel>내 계정</DropdownMenuLabel>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
프로필 <DropdownMenuShortcut>⇧⌘P</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
설정 <DropdownMenuShortcut>⌘,</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled>결제 (사용 불가)</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>보기</DropdownMenuLabel>
|
||||
<DropdownMenuCheckboxItem checked={showStatus} onCheckedChange={setShowStatus}>
|
||||
상태 표시줄
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem checked={showPanel} onCheckedChange={setShowPanel}>
|
||||
패널
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>위치</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup value={position} onValueChange={setPosition}>
|
||||
<DropdownMenuRadioItem value="top">상단</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="bottom">하단</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="right">오른쪽</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>더 보기</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem>이메일로 공유</DropdownMenuItem>
|
||||
<DropdownMenuItem>링크 복사</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive">로그아웃</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = { title: "Components/Overlay/Dropdown Menu", component: DropdownMenu, parameters: { layout: "centered" } } satisfies Meta<typeof DropdownMenu>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Representative Story: the trigger opens the full menu (item, disabled, separator, checkbox, radio group, sub menu, destructive).
|
||||
export const All: Story = {
|
||||
name: "Dropdown Menu",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Dropdown Menu"
|
||||
description="버튼으로 여는 작업 메뉴입니다. 키보드 이동, Esc 닫기, 포커스 복귀는 Radix가 담당합니다."
|
||||
/>
|
||||
<StorySection title="Overview" description="항목 · Disabled · 구분선 · Checkbox · Radio Group · 하위 메뉴 · 위험 항목을 한 메뉴에서 확인합니다. 버튼을 눌러 엽니다.">
|
||||
<Full />
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
// Verification Story: the same menu starting open, hidden from the sidebar (tag `verification`).
|
||||
export const Open: Story = {
|
||||
tags: ["verification"],
|
||||
parameters: { layout: "fullscreen" },
|
||||
render: () => (
|
||||
<div className="flex min-h-[560px] justify-center p-8">
|
||||
<Full defaultOpen />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@hm-design/ui"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Hover Card is a Radix overlay (Portal) for a richer preview than Tooltip. The representative Story shows real triggers
|
||||
// (hover or focus to open); the verification Story starts one open. Radix owns the open / close delay and the hover bridge.
|
||||
|
||||
const longText = "긴 콘텐츠는 카드 폭에서 줄바꿈되고 카드 높이만 늘어납니다. 공백 없는 긴 문자열도 폭을 밀어내지 않습니다. ".repeat(4)
|
||||
|
||||
function Preview({ defaultOpen }: { defaultOpen?: boolean }) {
|
||||
return (
|
||||
<HoverCard defaultOpen={defaultOpen}>
|
||||
<HoverCardTrigger asChild>
|
||||
<a href="#preview" className="text-sm font-medium text-primary underline underline-offset-4">
|
||||
한맥 프로젝트
|
||||
</a>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent>
|
||||
<div className="grid gap-1">
|
||||
<p className="font-bold">한맥 프로젝트</p>
|
||||
<p>노선과 시설물 정보를 관리하는 프로젝트입니다.</p>
|
||||
<p className="pt-1 text-xs text-muted-foreground">담당 김한맥 · 최근 수정 2026-10-02</p>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)
|
||||
}
|
||||
|
||||
function LongContent() {
|
||||
return (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<a href="#long" className="text-sm font-medium text-primary underline underline-offset-4">
|
||||
긴 콘텐츠
|
||||
</a>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent>
|
||||
<div className="grid gap-1">
|
||||
<p className="font-bold">긴 콘텐츠</p>
|
||||
<p>{longText}</p>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = { title: "Components/Overlay/Hover Card", component: HoverCard, parameters: { layout: "centered" } } satisfies Meta<typeof HoverCard>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const All: Story = {
|
||||
name: "Hover Card",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Hover Card"
|
||||
description="링크에 포인터를 올리거나 포커스하면 Tooltip보다 풍부한 미리보기를 띄우는 overlay입니다."
|
||||
/>
|
||||
<StorySection title="Overview" description="링크 위에 포인터를 올려 미리보기를 엽니다.">
|
||||
<Preview />
|
||||
</StorySection>
|
||||
<StorySection title="Usage" description="긴 콘텐츠는 카드 폭에서 줄바꿈되고 높이만 늘어납니다.">
|
||||
<LongContent />
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const Open: Story = {
|
||||
tags: ["verification"],
|
||||
parameters: { layout: "fullscreen" },
|
||||
render: () => (
|
||||
<div className="flex min-h-[260px] justify-center p-8">
|
||||
<Preview defaultOpen />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { useId, type ComponentProps, type ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Input, type InputSize } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Story structure follows ui_viz (implementations/web/storybook/Input.stories.jsx and InputSizes.stories.jsx):
|
||||
// one representative "Input" Story shows the sizes and every state on one page; the same specimens also exist as
|
||||
// separate Stories tagged `verification`, which the sidebar hides by default (see .storybook/main.ts).
|
||||
//
|
||||
// Label, Required star and Helper/Error message are not part of the Input component. Specimen below composes them with
|
||||
// plain elements and shadcn Token utilities, the way a consumer would: Label 14/20/500 and Helper 12/18/400 in the
|
||||
// secondary text color, Error message 11/18 in the destructive color, field gap 8.
|
||||
|
||||
function Specimen({
|
||||
label = "Project name",
|
||||
message,
|
||||
error,
|
||||
required,
|
||||
id,
|
||||
...input
|
||||
}: ComponentProps<typeof Input> & { label?: string; message?: string; error?: boolean }) {
|
||||
const uid = useId()
|
||||
const fieldId = id ?? `input-${uid}`
|
||||
const messageId = `${fieldId}-message`
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<label htmlFor={fieldId} className="justify-self-start text-sm font-medium text-muted-foreground">
|
||||
{label}
|
||||
{required ? (
|
||||
<span aria-hidden="true" className="text-destructive">
|
||||
{" *"}
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
<Input
|
||||
id={fieldId}
|
||||
required={required}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={message ? messageId : undefined}
|
||||
{...input}
|
||||
/>
|
||||
{message ? (
|
||||
<p id={messageId} className={error ? "text-[11px] leading-[18px] text-destructive" : "text-xs leading-[18px] text-muted-foreground"}>
|
||||
{message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const narrow = (children: ReactNode) => <div className="max-w-sm">{children}</div>
|
||||
const placeholder = "Enter a project name"
|
||||
|
||||
// ui_viz docs/components/input.md §9, §24: display metadata only, never sets control geometry.
|
||||
const sizes: { size: InputSize; name: string; height: number; padding: number; typography: string; usage: string }[] = [
|
||||
{ size: "small", name: "Small", height: 28, padding: 10, typography: "12 / 18 / 400", usage: "Small control" },
|
||||
{ size: "medium", name: "Medium", height: 34, padding: 12, typography: "14 / 20 / 400", usage: "Business forms / Search" },
|
||||
{ size: "large", name: "Large", height: 42, padding: 12, typography: "16 / 24 / 400", usage: "Component default" },
|
||||
]
|
||||
|
||||
function SizeCard({ spec }: { spec: (typeof sizes)[number] }) {
|
||||
return (
|
||||
<section className="grid content-start gap-4" data-input-size={spec.size}>
|
||||
<Specimen size={spec.size} label={spec.name} defaultValue={`${spec.name} ${spec.height}px`} />
|
||||
<dl className="m-0 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm">
|
||||
<dt className="text-muted-foreground">Height</dt>
|
||||
<dd className="m-0">{spec.height}px</dd>
|
||||
<dt className="text-muted-foreground">Padding</dt>
|
||||
<dd className="m-0">0 / {spec.padding}px</dd>
|
||||
<dt className="text-muted-foreground">Typography</dt>
|
||||
<dd className="m-0">{spec.typography}</dd>
|
||||
<dt className="text-muted-foreground">Usage</dt>
|
||||
<dd className="m-0">{spec.usage}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function SizeComparison() {
|
||||
return (
|
||||
<div className="grid max-w-[1120px] gap-6">
|
||||
<div className="grid gap-6 [grid-template-columns:repeat(auto-fit,minmax(240px,1fr))]">
|
||||
{sizes.map((spec) => (
|
||||
<SizeCard key={spec.size} spec={spec} />
|
||||
))}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
높이는 입력 컨트롤의 외곽 높이입니다. 값·Placeholder 글자는 size를 따르고 Label 14/20/500은 공통입니다. 업무 폼·조회 조합은 Medium을 쓰며, size를 생략하면 Large입니다.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const readonlyNote =
|
||||
"Readonly는 입력 상자 없이 확정된 텍스트만 표시합니다. size와 무관하게 Large 기준 typography와 42px 정렬 높이를 유지하며, Disabled처럼 흐리게 표시하지 않습니다."
|
||||
|
||||
const meta = {
|
||||
title: "Components/Form/Input",
|
||||
component: Input,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"ui_viz docs/components/input.md: Small 28 / Medium 34 / Large 42px, size를 생략하면 Large. Focus와 Error는 1.5px 테두리로 구분하며 shadow·glow를 쓰지 않습니다. Hover는 없습니다. Label·Helper·Error 문구는 Input이 아니라 소비 조합입니다. Disabled는 흰 면을 유지하고 opacity 0.6으로 흐립니다(2026-10-02 사용자 결정, Input·Select 한정).",
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
size: {
|
||||
control: "inline-radio",
|
||||
options: ["small", "medium", "large"],
|
||||
description: "무지정 기본값 Large. 업무 폼·조회 소비 역할은 Medium.",
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Input>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Representative Story: the common Story page (Overview → Sizes → States → Validation). Focus is placed by the play function.
|
||||
// Named after the component, as in ui_viz.
|
||||
export const All: Story = {
|
||||
name: "Input",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Input"
|
||||
description="한 줄 텍스트를 입력하는 컨트롤입니다. Label·Helper·Error 문구는 Input이 아니라 소비 조합입니다."
|
||||
/>
|
||||
<StorySection title="Overview" description="size를 생략한 기본 Input: Large 42px.">
|
||||
<StateBoard states={[{ id: "default", label: "Default · size 생략(Large)", children: <Specimen placeholder={placeholder} /> }]} />
|
||||
</StorySection>
|
||||
<StorySection title="Sizes" description="Small 28 / Medium 34 / Large 42px. 업무 폼·조회 조합은 Medium을 씁니다.">
|
||||
<SizeComparison />
|
||||
</StorySection>
|
||||
<StorySection title="States" description="Focus와 Error는 1.5px 테두리로 구분하며 Hover는 없습니다. Disabled는 흰 면을 유지하고 흐리게 표시합니다.">
|
||||
<StateBoard
|
||||
states={[
|
||||
{ id: "focus", label: "Focus", children: <Specimen size="medium" placeholder={placeholder} /> },
|
||||
{ id: "disabled", label: "Disabled", children: <Specimen disabled defaultValue="Unavailable project" /> },
|
||||
{ id: "readonly", label: "Readonly", note: readonlyNote, noteAfter: true, children: <Specimen readOnly value="Confirmed project" /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Validation" description="aria-invalid로 Error 테두리를 표시하고, 오류 문구는 소비 조합으로 연결합니다.">
|
||||
<StateBoard
|
||||
states={[
|
||||
{ id: "error", label: "Error", children: <Specimen size="medium" error defaultValue="Invalid value" /> },
|
||||
{ id: "error-message", label: "Error Message", children: <Specimen size="medium" placeholder={placeholder} error message="프로젝트명을 입력하세요." /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
// preventScroll: the page opens at its header, not at the States section.
|
||||
canvasElement.querySelector<HTMLInputElement>('[data-overview-state="focus"] input')?.focus({ preventScroll: true })
|
||||
},
|
||||
}
|
||||
|
||||
// Verification Stories: the same specimens as separate Stories, hidden from the sidebar (tag `verification`), reachable by URL or the sidebar tag filter.
|
||||
export const Default: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => narrow(<Specimen placeholder={placeholder} />),
|
||||
parameters: { docs: { description: { story: "size를 생략한 실제 기본값(Large 42px)입니다. 업무 폼·조회 역할의 Medium 34px과 구분합니다." } } },
|
||||
}
|
||||
export const Focus: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => narrow(<Specimen size="medium" placeholder={placeholder} />),
|
||||
play: async ({ canvasElement }) => canvasElement.querySelector("input")?.focus(),
|
||||
parameters: { docs: { description: { story: "Focus는 실제로 focus를 받은 컨트롤에서만 보입니다. play 함수가 focus를 줍니다. Tab 또는 클릭으로도 확인합니다." } } },
|
||||
}
|
||||
export const Error: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => narrow(<Specimen size="medium" error defaultValue="Invalid value" />),
|
||||
}
|
||||
export const InvalidFocus: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => narrow(<Specimen size="medium" error defaultValue="Invalid value" />),
|
||||
play: async ({ canvasElement }) => canvasElement.querySelector("input")?.focus(),
|
||||
}
|
||||
export const ErrorMessage: Story = {
|
||||
tags: ["verification"],
|
||||
name: "Error Message",
|
||||
render: () => narrow(<Specimen size="medium" placeholder={placeholder} error message="프로젝트명을 입력하세요." />),
|
||||
}
|
||||
export const Required: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => narrow(<Specimen size="medium" placeholder={placeholder} required />),
|
||||
}
|
||||
export const Disabled: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => narrow(<Specimen disabled defaultValue="Unavailable project" />),
|
||||
}
|
||||
export const Readonly: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => narrow(<Specimen readOnly value="Confirmed project" />),
|
||||
parameters: { docs: { description: { story: readonlyNote } } },
|
||||
}
|
||||
export const Sizes: Story = { tags: ["verification"], name: "Compare Sizes", render: () => <SizeComparison /> }
|
||||
export const Small: Story = { tags: ["verification"], render: () => narrow(<SizeCard spec={sizes[0]} />) }
|
||||
export const Medium: Story = { tags: ["verification"], render: () => narrow(<SizeCard spec={sizes[1]} />) }
|
||||
export const Large: Story = { tags: ["verification"], render: () => narrow(<SizeCard spec={sizes[2]} />) }
|
||||
|
||||
// HM only: not part of the ui_viz Input Stories. Tagged verification so the default sidebar keeps only the representative Story.
|
||||
export const Playground: Story = {
|
||||
tags: ["verification"],
|
||||
args: { size: "medium", type: "text", placeholder, disabled: false, readOnly: false },
|
||||
argTypes: {
|
||||
type: { control: "select", options: ["text", "email", "password", "search", "number", "tel", "url"] },
|
||||
placeholder: { control: "text" },
|
||||
disabled: { control: "boolean" },
|
||||
readOnly: { control: "boolean" },
|
||||
"aria-invalid": { control: "boolean" },
|
||||
className: { table: { disable: true } },
|
||||
},
|
||||
render: (args) => narrow(<Specimen {...args} />),
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Input, Label } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Label has no standalone ui_viz spec. Its typography and color are the Field Label role owned by Input / Textarea
|
||||
// (14/20/500, color.fg.secondary). Required star and messages are composed here as a consumer would (not part of Label).
|
||||
|
||||
const meta = {
|
||||
title: "Components/Form/Label",
|
||||
component: Label,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Field Label 14/20/500, 색 color.fg.secondary (ui_viz input.md §9, §10). Radix Label의 htmlFor·클릭 시 컨트롤 focus 동작을 유지합니다. Required 별표·문구는 Label이 아닌 소비 조합입니다.",
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Label>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const All: Story = {
|
||||
name: "Label",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Label"
|
||||
description="입력 컨트롤의 이름입니다. Field Label 14/20/500을 쓰며, htmlFor로 연결하면 라벨 클릭 시 컨트롤로 focus가 이동합니다."
|
||||
/>
|
||||
<StorySection title="Overview">
|
||||
<StateBoard
|
||||
states={[
|
||||
{
|
||||
id: "default",
|
||||
label: "Default · htmlFor",
|
||||
noteAfter: true,
|
||||
note: "라벨을 클릭하면 연결된 입력으로 focus가 이동합니다.",
|
||||
children: (
|
||||
<div className="grid max-w-sm gap-2">
|
||||
<Label htmlFor="label-default">Project name</Label>
|
||||
<Input id="label-default" size="medium" placeholder="Enter a project name" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="States">
|
||||
<StateBoard
|
||||
states={[
|
||||
{
|
||||
id: "disabled",
|
||||
label: "Disabled (peer-disabled, shadcn 기본)",
|
||||
noteAfter: true,
|
||||
note: "Disabled 연동은 ui_viz Label에 정의가 없어 shadcn 기본(peer-disabled)을 유지합니다.",
|
||||
children: (
|
||||
<div className="grid max-w-sm gap-2">
|
||||
<Input id="label-disabled" size="medium" disabled defaultValue="Unavailable" className="peer order-2" />
|
||||
<Label htmlFor="label-disabled" className="order-1">
|
||||
Project name
|
||||
</Label>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Usage" description="Required 별표는 Label이 아닌 소비 조합입니다. 긴 라벨은 줄바꿈됩니다.">
|
||||
<StateBoard
|
||||
states={[
|
||||
{
|
||||
id: "required",
|
||||
label: "Required (소비 조합)",
|
||||
children: (
|
||||
<div className="grid max-w-sm gap-2">
|
||||
<Label htmlFor="label-required">
|
||||
Project name
|
||||
<span aria-hidden="true" className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input id="label-required" size="medium" required />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "long",
|
||||
label: "Long Label",
|
||||
children: (
|
||||
<div className="grid max-w-[220px] gap-2">
|
||||
<Label htmlFor="label-long">A very long label text that wraps to multiple lines</Label>
|
||||
<Input id="label-long" size="medium" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useState } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import {
|
||||
Menubar,
|
||||
MenubarCheckboxItem,
|
||||
MenubarContent,
|
||||
MenubarGroup,
|
||||
MenubarItem,
|
||||
MenubarLabel,
|
||||
MenubarMenu,
|
||||
MenubarRadioGroup,
|
||||
MenubarRadioItem,
|
||||
MenubarSeparator,
|
||||
MenubarShortcut,
|
||||
MenubarSub,
|
||||
MenubarSubContent,
|
||||
MenubarSubTrigger,
|
||||
MenubarTrigger,
|
||||
} from "@hm-design/ui"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Menubar is a desktop menu bar (Left / Right between top menus). The representative Story is the real bar and opens the real
|
||||
// menus; Radix owns the keyboard model and focus. Hidden verification Stories isolate trigger focus and open + focus.
|
||||
|
||||
function Specimen() {
|
||||
const [grid, setGrid] = useState(true)
|
||||
const [ruler, setRuler] = useState(false)
|
||||
const [zoom, setZoom] = useState("100")
|
||||
return (
|
||||
<Menubar>
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger>파일</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem>
|
||||
새로 만들기 <MenubarShortcut>⌘N</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarItem>
|
||||
열기 <MenubarShortcut>⌘O</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarItem disabled>저장 (사용 불가)</MenubarItem>
|
||||
<MenubarSeparator />
|
||||
<MenubarSub>
|
||||
<MenubarSubTrigger>내보내기</MenubarSubTrigger>
|
||||
<MenubarSubContent>
|
||||
<MenubarItem>PDF</MenubarItem>
|
||||
<MenubarItem>이미지</MenubarItem>
|
||||
</MenubarSubContent>
|
||||
</MenubarSub>
|
||||
<MenubarSeparator />
|
||||
<MenubarItem variant="destructive">종료</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger>편집</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem>
|
||||
실행 취소 <MenubarShortcut>⌘Z</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarItem>
|
||||
다시 실행 <MenubarShortcut>⇧⌘Z</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger>보기</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarGroup>
|
||||
<MenubarLabel>표시</MenubarLabel>
|
||||
<MenubarCheckboxItem checked={grid} onCheckedChange={setGrid}>
|
||||
격자
|
||||
</MenubarCheckboxItem>
|
||||
<MenubarCheckboxItem checked={ruler} onCheckedChange={setRuler}>
|
||||
눈금자
|
||||
</MenubarCheckboxItem>
|
||||
</MenubarGroup>
|
||||
<MenubarSeparator />
|
||||
<MenubarLabel>확대</MenubarLabel>
|
||||
<MenubarRadioGroup value={zoom} onValueChange={setZoom}>
|
||||
<MenubarRadioItem value="75">75%</MenubarRadioItem>
|
||||
<MenubarRadioItem value="100">100%</MenubarRadioItem>
|
||||
<MenubarRadioItem value="125">125%</MenubarRadioItem>
|
||||
</MenubarRadioGroup>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
</Menubar>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = { title: "Components/Navigation/Menubar", component: Menubar, parameters: { layout: "padded" } } satisfies Meta<typeof Menubar>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Representative Story: File (item, shortcut, disabled, separator, sub menu, destructive), Edit, View (label, checkbox, radio).
|
||||
export const All: Story = {
|
||||
name: "Menubar",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Menubar"
|
||||
description="데스크톱 앱 형태의 상단 메뉴 막대입니다. 메뉴가 열린 상태에서 좌우 방향키로 이웃 메뉴로 이동합니다."
|
||||
/>
|
||||
<StorySection title="Overview" description="파일(단축키 · Disabled · 하위 메뉴 · 위험 항목), 편집, 보기(Label · Checkbox · Radio)입니다. 메뉴를 눌러 엽니다.">
|
||||
<div style={{ minHeight: 384 }}>
|
||||
<Specimen />
|
||||
</div>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
function FocusSpecimen({ open = false }: { open?: boolean }) {
|
||||
return (
|
||||
<div style={{ minHeight: 160 }}>
|
||||
<Menubar defaultValue={open ? "file" : undefined}>
|
||||
<MenubarMenu value="file">
|
||||
<MenubarTrigger>파일</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem>새로 만들기</MenubarItem>
|
||||
<MenubarItem disabled>저장 (사용 불가)</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
<MenubarMenu value="disabled">
|
||||
<MenubarTrigger disabled>사용 불가</MenubarTrigger>
|
||||
</MenubarMenu>
|
||||
</Menubar>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const TriggerFocus: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => <FocusSpecimen />,
|
||||
play: async ({ canvasElement }) => {
|
||||
canvasElement.querySelector<HTMLElement>('[data-slot="menubar-trigger"]')?.focus()
|
||||
},
|
||||
}
|
||||
|
||||
export const OpenFocus: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => <FocusSpecimen open />,
|
||||
play: async ({ canvasElement }) => {
|
||||
// Verification only: actual trigger focus while its real menu is open.
|
||||
// Production keyboard opening still moves focus into the menu (Radix default).
|
||||
canvasElement.querySelector<HTMLElement>('[data-slot="menubar-trigger"]')?.focus()
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import type { ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import {
|
||||
NavigationMenu,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuIndicator,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuList,
|
||||
NavigationMenuTrigger,
|
||||
} from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Navigation Menu is for destinations (links), not commands. The representative Story opens the real menus with hover, click or
|
||||
// keyboard; the same specimens also exist as Stories tagged `verification` (hidden from the sidebar). Title + description children
|
||||
// inside a content link are marked with `data-description` for the secondary text.
|
||||
|
||||
function Destination({ href, title, description, active }: { href: string; title: string; description?: string; active?: boolean }) {
|
||||
return (
|
||||
<NavigationMenuLink href={href} active={active}>
|
||||
<span>{title}</span>
|
||||
{description ? <span data-description="">{description}</span> : null}
|
||||
</NavigationMenuLink>
|
||||
)
|
||||
}
|
||||
|
||||
function Specimen({ viewport = true }: { viewport?: boolean }) {
|
||||
return (
|
||||
<NavigationMenu viewport={viewport}>
|
||||
<NavigationMenuList>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuTrigger>제품</NavigationMenuTrigger>
|
||||
<NavigationMenuContent>
|
||||
<ul className="sm:w-[min(360px,calc(100cqw-18px))]" style={{ display: "grid", gap: 4 }}>
|
||||
<li>
|
||||
<Destination href="#analytics" title="분석" description="노선과 시설물 데이터를 한눈에 확인합니다." />
|
||||
</li>
|
||||
<li>
|
||||
<Destination href="#maps" title="지도" description="공간 정보를 지도 위에서 관리합니다." />
|
||||
</li>
|
||||
<li>
|
||||
<Destination href="#reports" title="리포트" description="점검 결과를 보고서로 만듭니다." />
|
||||
</li>
|
||||
</ul>
|
||||
</NavigationMenuContent>
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuTrigger>자료</NavigationMenuTrigger>
|
||||
<NavigationMenuContent>
|
||||
<ul className="sm:w-[min(300px,calc(100cqw-18px))]" style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 4 }}>
|
||||
<li>
|
||||
<Destination href="#docs" title="문서" />
|
||||
</li>
|
||||
<li>
|
||||
<Destination href="#guides" title="가이드" />
|
||||
</li>
|
||||
<li>
|
||||
<Destination href="#api" title="API" />
|
||||
</li>
|
||||
<li>
|
||||
<Destination href="#blog" title="블로그" />
|
||||
</li>
|
||||
</ul>
|
||||
</NavigationMenuContent>
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuLink href="#company" active>
|
||||
회사
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuLink href="#contact">문의</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuTrigger disabled>준비 중</NavigationMenuTrigger>
|
||||
</NavigationMenuItem>
|
||||
</NavigationMenuList>
|
||||
{viewport ? <NavigationMenuIndicator /> : null}
|
||||
</NavigationMenu>
|
||||
)
|
||||
}
|
||||
|
||||
// Size specimens to their review area (including the panel's 8px padding + 1px border on each side).
|
||||
// Mobile uses the content's natural minimum width instead of a fixed desktop specimen width.
|
||||
const frame = (node: ReactNode) => (
|
||||
<div
|
||||
style={{ minHeight: 256, width: "100%", containerType: "inline-size" }}
|
||||
className="max-md:[&_[data-viewport=false]_[data-slot=navigation-menu-content]]:absolute"
|
||||
>
|
||||
{node}
|
||||
</div>
|
||||
)
|
||||
|
||||
const meta = { title: "Components/Navigation/Navigation Menu", component: NavigationMenu, parameters: { layout: "padded" } } satisfies Meta<typeof NavigationMenu>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const All: Story = {
|
||||
name: "Navigation Menu",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Navigation Menu"
|
||||
description="목적지(링크)로 이동하는 메뉴입니다. 명령 실행에는 쓰지 않습니다. hover, 클릭 또는 키보드로 엽니다."
|
||||
/>
|
||||
<StorySection title="Overview">
|
||||
<StateBoard
|
||||
min={520}
|
||||
states={[
|
||||
{ id: "viewport", label: "Viewport (default)", note: "제품 / 자료는 content, 회사는 현재 위치(active), 문의는 일반 링크, 준비 중은 disabled trigger", children: frame(<Specimen />) },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Variants">
|
||||
<StateBoard
|
||||
min={520}
|
||||
states={[
|
||||
{ id: "no-viewport", label: "viewport={false}", note: "각 content가 자기 trigger 아래에 열립니다", children: frame(<Specimen viewport={false} />) },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const Default: Story = { tags: ["verification"], render: () => frame(<Specimen />) }
|
||||
export const NoViewport: Story = { tags: ["verification"], name: "No Viewport", render: () => frame(<Specimen viewport={false} />) }
|
||||
|
||||
// Three enabled triggers with different widths exercise first / middle / last offsets in both positioning modes.
|
||||
export const IndicatorAlignment: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => (
|
||||
<StateBoard min={520} states={[true, false].map((viewport) => ({
|
||||
id: viewport ? "viewport" : "no-viewport",
|
||||
label: `Indicator alignment · viewport=${viewport}`,
|
||||
children: frame(
|
||||
<NavigationMenu viewport={viewport}>
|
||||
<NavigationMenuList>
|
||||
{["제품", "기술 자료", "지원 센터"].map((label) => (
|
||||
<NavigationMenuItem key={label}>
|
||||
<NavigationMenuTrigger>{label}</NavigationMenuTrigger>
|
||||
<NavigationMenuContent><Destination href="#verification" title={`${label} 안내`} /></NavigationMenuContent>
|
||||
</NavigationMenuItem>
|
||||
))}
|
||||
</NavigationMenuList>
|
||||
{viewport ? <NavigationMenuIndicator /> : null}
|
||||
</NavigationMenu>
|
||||
),
|
||||
}))} />
|
||||
),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useState, type ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Pagination is presentational: the consumer owns the page and the hrefs. The Story keeps the page in local state and uses
|
||||
// `#page-N` hrefs with preventDefault. The representative Story shows each case on one page; the same specimens also exist as
|
||||
// Stories tagged `verification` (hidden from the sidebar).
|
||||
|
||||
// Page list with ellipses: 1 … (p-1 p p+1) … total.
|
||||
function pageRange(page: number, total: number): (number | "start" | "end")[] {
|
||||
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1)
|
||||
const out: (number | "start" | "end")[] = [1]
|
||||
const from = Math.max(2, Math.min(page - 1, total - 4))
|
||||
const to = Math.min(total - 1, Math.max(page + 1, 5))
|
||||
if (from > 2) out.push("start")
|
||||
for (let p = from; p <= to; p++) out.push(p)
|
||||
if (to < total - 1) out.push("end")
|
||||
out.push(total)
|
||||
return out
|
||||
}
|
||||
|
||||
function Specimen({ total, initial }: { total: number; initial: number }) {
|
||||
const [page, setPage] = useState(initial)
|
||||
const go = (p: number) => (event: React.MouseEvent) => {
|
||||
event.preventDefault()
|
||||
setPage(p)
|
||||
}
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious href={`#page-${page - 1}`} aria-disabled={page === 1} onClick={go(page - 1)} />
|
||||
</PaginationItem>
|
||||
{pageRange(page, total).map((p) =>
|
||||
typeof p === "number" ? (
|
||||
<PaginationItem key={p}>
|
||||
<PaginationLink href={`#page-${p}`} isActive={p === page} onClick={go(p)}>
|
||||
{p}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
) : (
|
||||
<PaginationItem key={p}>
|
||||
<PaginationEllipsis />
|
||||
</PaginationItem>
|
||||
)
|
||||
)}
|
||||
<PaginationItem>
|
||||
<PaginationNext href={`#page-${page + 1}`} aria-disabled={page === total} onClick={go(page + 1)} />
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
<p className="text-center text-sm text-muted-foreground" data-testid="page-state">{`현재 ${page} / ${total}`}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const wrap = (node: ReactNode) => <div className="w-full max-w-xl">{node}</div>
|
||||
|
||||
const sections = [
|
||||
{ id: "first", label: "First page", note: "Previous가 aria-disabled", node: <Specimen total={5} initial={1} /> },
|
||||
{ id: "middle", label: "Middle page", node: <Specimen total={5} initial={3} /> },
|
||||
{ id: "last", label: "Last page", note: "Next가 aria-disabled", node: <Specimen total={5} initial={5} /> },
|
||||
{ id: "ellipsis", label: "Long range with ellipsis", note: "클릭하면 실제로 현재 페이지가 바뀝니다", node: <Specimen total={20} initial={10} /> },
|
||||
]
|
||||
|
||||
const meta = { title: "Components/Navigation/Pagination", component: Pagination, parameters: { layout: "padded" } } satisfies Meta<typeof Pagination>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const board = (...ids: string[]) => (
|
||||
<StateBoard min={420} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, note: s.note, children: wrap(s.node) }))} />
|
||||
)
|
||||
|
||||
// Representative Story: the common Story page; the sections group the same specimens.
|
||||
export const All: Story = {
|
||||
name: "Pagination",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Pagination"
|
||||
description="여러 페이지로 나뉜 목록을 이동하는 내비게이션입니다. 현재 페이지와 href는 소비 코드가 소유합니다."
|
||||
/>
|
||||
<StorySection title="Overview">{board("middle")}</StorySection>
|
||||
<StorySection title="States" description="첫 페이지와 마지막 페이지에서는 이전·다음이 aria-disabled입니다.">{board("first", "last")}</StorySection>
|
||||
<StorySection title="Usage" description="페이지가 많으면 생략 부호로 줄입니다.">{board("ellipsis")}</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const FirstPage: Story = { tags: ["verification"], name: "First Page", render: () => wrap(<Specimen total={5} initial={1} />) }
|
||||
export const LastPage: Story = { tags: ["verification"], name: "Last Page", render: () => wrap(<Specimen total={5} initial={5} />) }
|
||||
export const LongRange: Story = { tags: ["verification"], name: "Long Range", render: () => wrap(<Specimen total={20} initial={10} />) }
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Button, Input, Popover, PopoverClose, PopoverContent, PopoverTrigger } from "@hm-design/ui"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Popover is a non-modal Radix overlay (Portal). The representative Story opens the real panels from their triggers;
|
||||
// the verification Story starts one open. Radix owns outside click / Escape close and focus return.
|
||||
|
||||
function Info({ defaultOpen }: { defaultOpen?: boolean }) {
|
||||
return (
|
||||
<Popover defaultOpen={defaultOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="primary" appearance="outline">
|
||||
정보 보기
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent aria-label="프로젝트 안내">
|
||||
<div className="grid gap-1">
|
||||
<p className="font-bold">프로젝트 안내</p>
|
||||
<p>일정과 담당자 정보를 현재 화면 위에서 확인합니다. 바깥을 누르거나 Esc를 누르면 닫힙니다.</p>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
function FormPanel() {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="primary" appearance="outline">
|
||||
이름 변경
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" aria-label="프로젝트 이름 변경">
|
||||
<div className="grid gap-3">
|
||||
<label htmlFor="popover-name" className="text-sm font-medium text-muted-foreground">
|
||||
프로젝트명
|
||||
</label>
|
||||
<Input id="popover-name" size="medium" placeholder="프로젝트명을 입력하세요" />
|
||||
<div className="flex justify-end gap-2">
|
||||
<PopoverClose asChild>
|
||||
<Button variant="cancel" appearance="solid" size="sm">
|
||||
취소
|
||||
</Button>
|
||||
</PopoverClose>
|
||||
<PopoverClose asChild>
|
||||
<Button variant="primary" appearance="solid" size="sm">
|
||||
저장
|
||||
</Button>
|
||||
</PopoverClose>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
function Aligned() {
|
||||
return (
|
||||
<div className="flex w-full max-w-md justify-between gap-3">
|
||||
{(["start", "center", "end"] as const).map((align) => (
|
||||
<Popover key={align}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="primary" appearance="outline" size="sm">
|
||||
{`align ${align}`}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align={align} side="bottom" aria-label={`정렬 예제: ${align}`}>
|
||||
<p>{`align="${align}"`}</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = { title: "Components/Overlay/Popover", component: Popover, parameters: { layout: "centered" } } satisfies Meta<typeof Popover>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const All: Story = {
|
||||
name: "Popover",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Popover"
|
||||
description="Trigger 옆에 상호작용 가능한 패널을 띄우는 비모달 overlay입니다. 바깥 클릭이나 Esc로 닫힙니다."
|
||||
/>
|
||||
<StorySection title="Overview" description="버튼을 눌러 안내 패널을 엽니다.">
|
||||
<Info />
|
||||
</StorySection>
|
||||
<StorySection title="Variants" description="align: start / center / end.">
|
||||
<Aligned />
|
||||
</StorySection>
|
||||
<StorySection title="Usage" description="패널 안의 입력 폼 조합입니다. 취소·저장은 PopoverClose로 닫습니다.">
|
||||
<FormPanel />
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const Open: Story = {
|
||||
tags: ["verification"],
|
||||
parameters: { layout: "fullscreen" },
|
||||
render: () => (
|
||||
<div className="flex min-h-[320px] justify-center p-8">
|
||||
<Info defaultOpen />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useState } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Button, Progress } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Progress follows `value` (0..max). The representative Story shows fixed values plus one interactive value; the same specimens
|
||||
// also exist as Stories tagged `verification` (hidden from the sidebar). No status colors are defined, so there is one look.
|
||||
|
||||
function Interactive() {
|
||||
const [value, setValue] = useState(30)
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<Progress value={value} aria-label="진행률" />
|
||||
<p className="text-sm text-muted-foreground" data-testid="value">{`value = ${value}`}</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="cancel" appearance="solid" size="sm" onClick={() => setValue((v) => Math.max(0, v - 25))}>
|
||||
-25
|
||||
</Button>
|
||||
<Button variant="primary" appearance="solid" size="sm" onClick={() => setValue((v) => Math.min(100, v + 25))}>
|
||||
+25
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CustomMaximum() {
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<Progress value={0} max={200} aria-label="0 of 200" />
|
||||
<Progress value={50} max={200} aria-label="50 of 200" />
|
||||
<Progress value={200} max={200} aria-label="200 of 200" />
|
||||
<Progress max={200} aria-label="Indeterminate" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const sections = [
|
||||
{ id: "zero", label: "0%", node: <Progress value={0} aria-label="0%" /> },
|
||||
{ id: "middle", label: "50%", node: <Progress value={50} aria-label="50%" /> },
|
||||
{ id: "full", label: "100%", node: <Progress value={100} aria-label="100%" /> },
|
||||
{ id: "custom-maximum", label: "max = 200", note: "0 / 50 / 200과 값 없음: ARIA 범위와 실제 채움 비율 확인", node: <CustomMaximum /> },
|
||||
{ id: "interactive", label: "Interactive value", note: "버튼으로 value를 바꾸면 indicator 폭과 aria-valuenow가 함께 변합니다", node: <Interactive /> },
|
||||
]
|
||||
|
||||
const meta = { title: "Components/Data & Display/Progress", component: Progress, parameters: { layout: "padded" } } satisfies Meta<typeof Progress>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const board = (...ids: string[]) => (
|
||||
<StateBoard min={320} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, note: s.note, noteAfter: true, children: s.node }))} />
|
||||
)
|
||||
|
||||
// Representative Story: the common Story page; the sections group the same specimens.
|
||||
export const All: Story = {
|
||||
name: "Progress",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Progress"
|
||||
description="작업 진행률을 막대로 표시합니다. value(0..max)를 따라 채워집니다."
|
||||
/>
|
||||
<StorySection title="Overview">{board("middle")}</StorySection>
|
||||
<StorySection title="States" description="value 0과 100입니다.">{board("zero", "full")}</StorySection>
|
||||
<StorySection title="Usage" description="max를 바꾼 범위와, value를 바꾸는 실제 조작입니다.">{board("custom-maximum", "interactive")}</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const Zero: Story = { tags: ["verification"], render: () => <Progress value={0} aria-label="0%" /> }
|
||||
export const Middle: Story = { tags: ["verification"], render: () => <Progress value={50} aria-label="50%" /> }
|
||||
export const Full: Story = { tags: ["verification"], render: () => <Progress value={100} aria-label="100%" /> }
|
||||
|
||||
export const NonDefaultMax: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => <CustomMaximum />,
|
||||
play: async ({ canvasElement }) => {
|
||||
const bars = canvasElement.querySelectorAll('[role="progressbar"]')
|
||||
const expected = ["translateX(-100%)", "translateX(-75%)", "translateX(0%)", "translateX(-100%)"]
|
||||
bars.forEach((bar, index) => {
|
||||
const indicator = bar.querySelector<HTMLElement>('[data-slot="progress-indicator"]')
|
||||
if (bar.getAttribute("aria-valuemax") !== "200" || indicator?.style.transform !== expected[index]) {
|
||||
throw new Error(`Progress max=200: ARIA/fill mismatch in specimen ${index}`)
|
||||
}
|
||||
})
|
||||
if (bars.length !== 4 || bars[1]?.getAttribute("aria-valuenow") !== "50" || bars[3]?.hasAttribute("aria-valuenow")) {
|
||||
throw new Error("Progress max=200: incorrect determinate/indeterminate ARIA state")
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Label, RadioGroup, RadioGroupItem } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Current HM Radio Group. ui-viz has no Radio spec, so it was reference only.
|
||||
|
||||
function Group({
|
||||
prefix,
|
||||
defaultValue = "a",
|
||||
disabled,
|
||||
disabledItem,
|
||||
invalid,
|
||||
orientation,
|
||||
}: {
|
||||
prefix: string
|
||||
defaultValue?: string
|
||||
disabled?: boolean
|
||||
disabledItem?: string
|
||||
invalid?: boolean
|
||||
orientation?: "horizontal" | "vertical"
|
||||
}) {
|
||||
return (
|
||||
<RadioGroup defaultValue={defaultValue} disabled={disabled} orientation={orientation} aria-label={prefix}>
|
||||
{["a", "b", "c"].map((v) => (
|
||||
<div key={v} className="flex items-center gap-2">
|
||||
<RadioGroupItem value={v} id={`${prefix}-${v}`} disabled={disabledItem === v} aria-invalid={invalid ? true : undefined} />
|
||||
<Label htmlFor={`${prefix}-${v}`}>Option {v.toUpperCase()}</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Components/Form/Radio Group",
|
||||
component: RadioGroup,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"현재 HM 규격: 18px 원, border 1px(stroke), Checked/Selected는 state-active 테두리 + 8px state-active dot, group gap 12(horizontal 16). Radix RadioGroup의 roving focus, 방향키 선택, ARIA를 유지합니다. ui-viz에는 Radio 규격이 없어 참고만 했습니다.",
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof RadioGroup>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const All: Story = {
|
||||
name: "Radio Group",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Radio Group"
|
||||
description="여러 선택지 중 하나를 고르는 컨트롤입니다. 그룹 안에서는 방향키로 이동·선택합니다(Radix roving focus)."
|
||||
/>
|
||||
<StorySection title="Overview">
|
||||
<StateBoard min={240} states={[{ id: "vertical", label: "Vertical (default) · Checked / Unchecked", children: <Group prefix="v" defaultValue="b" /> }]} />
|
||||
</StorySection>
|
||||
<StorySection title="Variants">
|
||||
<StateBoard min={240} states={[{ id: "horizontal", label: "Horizontal", children: <Group prefix="h" orientation="horizontal" /> }]} />
|
||||
</StorySection>
|
||||
<StorySection title="States">
|
||||
<StateBoard
|
||||
min={240}
|
||||
states={[
|
||||
{ id: "hover", label: "Hover", noteAfter: true, note: "Hover한 항목의 테두리가 primary 색이 됩니다.", children: <Group prefix="hv" defaultValue="" /> },
|
||||
{ id: "focus", label: "Focus", noteAfter: true, note: "그룹에 Tab으로 들어가면 선택된 항목에 focus ring이 표시됩니다.", children: <Group prefix="f" /> },
|
||||
{ id: "disabled-item", label: "Disabled · 항목", children: <Group prefix="di" disabledItem="b" /> },
|
||||
{ id: "disabled-group", label: "Disabled · 그룹", children: <Group prefix="dg" disabled /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Validation" description="aria-invalid로 Error 테두리를 표시합니다.">
|
||||
<StateBoard min={240} states={[{ id: "error", label: "Error", children: <Group prefix="e" invalid /> }]} />
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
// preventScroll: the page opens at its header, not at the States section.
|
||||
canvasElement.querySelector<HTMLButtonElement>('[data-overview-state="focus"] [role="radio"][data-state="checked"]')?.focus({ preventScroll: true })
|
||||
},
|
||||
}
|
||||
|
||||
export const Vertical: Story = { tags: ["verification"], render: () => <Group prefix="sv" /> }
|
||||
export const Horizontal: Story = { tags: ["verification"], render: () => <Group prefix="sh" orientation="horizontal" /> }
|
||||
export const Disabled: Story = { tags: ["verification"], render: () => <Group prefix="sd" disabled /> }
|
||||
export const Error: Story = { tags: ["verification"], render: () => <Group prefix="se" invalid /> }
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// The representative Story shows real, draggable layouts; the same specimens also exist as Stories tagged `verification` (hidden
|
||||
// from the sidebar). react-resizable-panels v4 sizes: a string without a unit ("30") or with "%" is a percentage, a number is
|
||||
// pixels. The frames (size, border) and panel content are Story layout. These are not Dashboard layouts.
|
||||
|
||||
const frame = (node: ReactNode, height = 200) => (
|
||||
<div className="rounded-md border" style={{ width: "100%", maxWidth: 560, height }}>
|
||||
{node}
|
||||
</div>
|
||||
)
|
||||
|
||||
const Pane = ({ children }: { children: ReactNode }) => <div className="grid size-full place-items-center p-3 text-sm">{children}</div>
|
||||
|
||||
function Horizontal() {
|
||||
return frame(
|
||||
<ResizablePanelGroup orientation="horizontal" aria-label="가로 분할" id="h">
|
||||
<ResizablePanel defaultSize="30" minSize="15" id="h-list">
|
||||
<Pane>목록 (min 15%)</Pane>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle aria-label="목록 폭 조절" />
|
||||
<ResizablePanel defaultSize="70" minSize="20" id="h-detail">
|
||||
<Pane>상세</Pane>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
)
|
||||
}
|
||||
|
||||
function Vertical() {
|
||||
return frame(
|
||||
<ResizablePanelGroup orientation="vertical" aria-label="세로 분할" id="v">
|
||||
<ResizablePanel defaultSize="40" minSize="20" id="v-top">
|
||||
<Pane>위</Pane>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle aria-label="높이 조절" />
|
||||
<ResizablePanel defaultSize="60" minSize="20" id="v-bottom">
|
||||
<Pane>아래</Pane>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>,
|
||||
240
|
||||
)
|
||||
}
|
||||
|
||||
function WithHandle() {
|
||||
return frame(
|
||||
<ResizablePanelGroup orientation="horizontal" aria-label="handle 분할" id="g">
|
||||
<ResizablePanel defaultSize="50" minSize="20" id="g-a">
|
||||
<Pane>A</Pane>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle aria-label="A와 B 사이 조절" />
|
||||
<ResizablePanel defaultSize="50" minSize="20" id="g-b">
|
||||
<Pane>B</Pane>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
)
|
||||
}
|
||||
|
||||
function MinMax() {
|
||||
return frame(
|
||||
<ResizablePanelGroup orientation="horizontal" aria-label="min max 분할" id="m">
|
||||
<ResizablePanel defaultSize="40" minSize="25" maxSize="60" id="m-a">
|
||||
<Pane>25% – 60%</Pane>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle aria-label="min max 조절" />
|
||||
<ResizablePanel defaultSize="60" minSize="20" id="m-b">
|
||||
<Pane>나머지</Pane>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
)
|
||||
}
|
||||
|
||||
function Collapsible() {
|
||||
return frame(
|
||||
<ResizablePanelGroup orientation="horizontal" aria-label="접힘 분할" id="c">
|
||||
<ResizablePanel defaultSize="30" minSize="15" collapsible collapsedSize="0" id="c-side">
|
||||
<Pane>사이드 (끌면 접힘)</Pane>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle aria-label="사이드 폭 조절" />
|
||||
<ResizablePanel defaultSize="70" minSize="30" id="c-main">
|
||||
<Pane>본문</Pane>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
)
|
||||
}
|
||||
|
||||
const sections = [
|
||||
{ id: "horizontal", label: "Horizontal (list min 15%)", node: <Horizontal /> },
|
||||
{ id: "vertical", label: "Vertical with grip", node: <Vertical /> },
|
||||
{ id: "handle", label: "Horizontal with grip", node: <WithHandle /> },
|
||||
{ id: "minmax", label: "Min / max (25%–60%)", node: <MinMax /> },
|
||||
{ id: "collapsible", label: "Collapsible panel", node: <Collapsible /> },
|
||||
]
|
||||
|
||||
const meta = { title: "Components/Disclosure/Resizable", component: ResizablePanelGroup, parameters: { layout: "padded" } } satisfies Meta<typeof ResizablePanelGroup>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const board = (...ids: string[]) => (
|
||||
<StateBoard min={580} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} />
|
||||
)
|
||||
|
||||
// Representative Story: the common Story page; the sections group the same specimens.
|
||||
export const All: Story = {
|
||||
name: "Resizable",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Resizable"
|
||||
description="경계선을 끌어 인접한 패널의 크기를 조절하는 레이아웃입니다."
|
||||
/>
|
||||
<StorySection title="Overview">{board("horizontal")}</StorySection>
|
||||
<StorySection title="Variants" description="세로 방향과 grip 표시입니다.">{board("vertical", "handle")}</StorySection>
|
||||
<StorySection title="Usage" description="최소·최대 크기와 접을 수 있는 패널입니다.">{board("minmax", "collapsible")}</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const HorizontalLayout: Story = { tags: ["verification"], name: "Horizontal", render: () => <Horizontal /> }
|
||||
export const VerticalLayout: Story = { tags: ["verification"], name: "Vertical", render: () => <Vertical /> }
|
||||
export const MinMaxLayout: Story = { tags: ["verification"], name: "Min Max", render: () => <MinMax /> }
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { ScrollArea, ScrollBar } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Scroll Area is Radix scrolling with a styled scrollbar (type "hover": the bar shows while hovering or scrolling). Sizes below
|
||||
// are Story layout. The representative Story shows vertical, horizontal, both axes and a table-like content.
|
||||
|
||||
const items = Array.from({ length: 30 }, (_, i) => `항목 ${i + 1}`)
|
||||
|
||||
function Vertical() {
|
||||
return (
|
||||
<ScrollArea style={{ height: 200, width: 280 }} className="rounded-md border">
|
||||
<div className="p-3 text-sm">
|
||||
{items.map((t) => (
|
||||
<div key={t} className="py-1.5">
|
||||
{t}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
|
||||
function Horizontal() {
|
||||
return (
|
||||
<ScrollArea style={{ width: 280 }} className="rounded-md border whitespace-nowrap">
|
||||
<div className="flex w-max gap-3 p-3">
|
||||
{Array.from({ length: 12 }, (_, i) => (
|
||||
<div key={i} className="grid h-16 w-24 place-items-center rounded-md bg-muted text-sm">{`카드 ${i + 1}`}</div>
|
||||
))}
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
|
||||
function Both() {
|
||||
return (
|
||||
<ScrollArea style={{ height: 200, width: 280 }} className="rounded-md border">
|
||||
<div className="p-3 text-sm" style={{ width: 600 }}>
|
||||
{items.map((t) => (
|
||||
<div key={t} className="py-1.5">{`${t} — 가로로도 길어지는 내용입니다. 가로 scroll로 끝까지 볼 수 있습니다.`}</div>
|
||||
))}
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
|
||||
function TableLike() {
|
||||
return (
|
||||
<ScrollArea style={{ height: 180, width: 300 }} className="rounded-md border">
|
||||
<div className="grid text-sm" style={{ width: 520 }}>
|
||||
{Array.from({ length: 12 }, (_, r) => (
|
||||
<div key={r} className="grid grid-cols-4 border-b p-2">
|
||||
{["번호", "이름", "상태", "금액"].map((c) => (
|
||||
<span key={c}>{`${c} ${r + 1}`}</span>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
|
||||
const sections: { id: string; label: string; node: ReactNode }[] = [
|
||||
{ id: "vertical", label: "Vertical", node: <Vertical /> },
|
||||
{ id: "horizontal", label: "Horizontal", node: <Horizontal /> },
|
||||
{ id: "both", label: "Both axes", node: <Both /> },
|
||||
{ id: "table-like", label: "Table-like content", node: <TableLike /> },
|
||||
]
|
||||
|
||||
const meta = { title: "Components/Data & Display/Scroll Area", component: ScrollArea, parameters: { layout: "padded" } } satisfies Meta<typeof ScrollArea>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const board = (...ids: string[]) => (
|
||||
<StateBoard min={320} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} />
|
||||
)
|
||||
|
||||
// Representative Story: the common Story page; the sections group the same specimens.
|
||||
export const All: Story = {
|
||||
name: "Scroll Area",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Scroll Area"
|
||||
description="정해진 크기 안에서 내용을 스크롤하는 영역입니다. 스크롤바는 포인터를 올리거나 스크롤할 때 나타납니다."
|
||||
/>
|
||||
<StorySection title="Overview">{board("vertical")}</StorySection>
|
||||
<StorySection title="Variants" description="가로와 양방향 스크롤입니다.">{board("horizontal", "both")}</StorySection>
|
||||
<StorySection title="Usage">{board("table-like")}</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const VerticalScroll: Story = { tags: ["verification"], name: "Vertical", render: () => <Vertical /> }
|
||||
export const HorizontalScroll: Story = { tags: ["verification"], name: "Horizontal", render: () => <Horizontal /> }
|
||||
@@ -0,0 +1,312 @@
|
||||
import { useId, type ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, type SelectSize } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Story structure follows ui_viz (implementations/web/storybook/Select.stories.jsx and SelectSizes.stories.jsx):
|
||||
// one representative "Select" Story shows the default, selected, disabled and open states on one page; the same specimens
|
||||
// also exist as separate Stories tagged `verification`, which the sidebar hides by default (see .storybook/main.ts).
|
||||
//
|
||||
// The Label is not part of the Select component. SelectSpecimen composes it with a plain element and shadcn Token
|
||||
// utilities, the way a consumer would: Label 14/20/500 in the secondary text color, field gap 8.
|
||||
|
||||
const statusOptions = [
|
||||
{ value: "all", label: "All statuses" },
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "planned", label: "Planned" },
|
||||
{ value: "completed", label: "Completed" },
|
||||
]
|
||||
|
||||
const longOptions = [
|
||||
{ value: "short", label: "Short option" },
|
||||
{ value: "long", label: "A very long selected value that must truncate without moving the chevron" },
|
||||
]
|
||||
|
||||
const scrollOptions = Array.from({ length: 32 }, (_, index) => ({
|
||||
value: `option-${index + 1}`,
|
||||
label: `Option ${index + 1}`,
|
||||
}))
|
||||
|
||||
type SpecimenProps = {
|
||||
listSize?: SelectSize
|
||||
label?: string
|
||||
placeholder?: string
|
||||
size?: SelectSize
|
||||
defaultValue?: string
|
||||
defaultOpen?: boolean
|
||||
disabled?: boolean
|
||||
"aria-invalid"?: boolean
|
||||
options?: ReadonlyArray<{ value: string; label: string }>
|
||||
}
|
||||
|
||||
function SelectSpecimen({ listSize, label = "Select", placeholder = "Choose a status", size, defaultValue, defaultOpen, disabled, "aria-invalid": ariaInvalid, options = statusOptions }: SpecimenProps) {
|
||||
const id = useId()
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<label htmlFor={id} className={`justify-self-start text-sm font-medium text-muted-foreground${disabled ? " opacity-60" : ""}`}>
|
||||
{label}
|
||||
</label>
|
||||
<Select defaultValue={defaultValue} defaultOpen={defaultOpen} disabled={disabled}>
|
||||
<SelectTrigger id={id} size={size} aria-invalid={ariaInvalid} className="w-full">
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent {...(listSize ? { "data-size": listSize } : {})}>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const narrow = (children: ReactNode) => <div className="max-w-sm">{children}</div>
|
||||
|
||||
// ui_viz docs/components/select.md §1.1, §9: display metadata only, never sets control geometry.
|
||||
const sizes = [
|
||||
{ size: "medium" as const, name: "Medium", outer: "34px / 32px", typography: "14 / 20 / 400", option: "42px minimum · 14 / 20 / 400", usage: "Business forms / Search" },
|
||||
{ size: "large" as const, name: "Large", outer: "44px / 42px", typography: "16 / 24 / 400", option: "42px minimum · 16 / 24 / 400", usage: "Component default" },
|
||||
]
|
||||
|
||||
function SizeCard({ spec }: { spec: (typeof sizes)[number] }) {
|
||||
return (
|
||||
<section className="grid content-start gap-4" data-select-size={spec.size}>
|
||||
<SelectSpecimen size={spec.size} label={spec.name} placeholder="Choose a status" />
|
||||
<dl className="m-0 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm">
|
||||
<dt className="text-muted-foreground">Outer Height / Trigger</dt>
|
||||
<dd className="m-0">{spec.outer}</dd>
|
||||
<dt className="text-muted-foreground">Trigger Typography</dt>
|
||||
<dd className="m-0">{spec.typography}</dd>
|
||||
<dt className="text-muted-foreground">Option</dt>
|
||||
<dd className="m-0">{spec.option}</dd>
|
||||
<dt className="text-muted-foreground">Usage</dt>
|
||||
<dd className="m-0">{spec.usage}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function SizeComparison() {
|
||||
return (
|
||||
<div className="grid max-w-[1120px] gap-6">
|
||||
<div className="grid gap-6 [grid-template-columns:repeat(auto-fit,minmax(240px,1fr))]">
|
||||
{sizes.map((spec) => (
|
||||
<SizeCard key={spec.size} spec={spec} />
|
||||
))}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select는 Input과 독립된 두 크기(Medium 34 / Large 44)입니다. 각 Trigger를 클릭해 연결된 목록과 선택 상태를 확인하세요. 목록의 Option은 Trigger의 size를 그대로 따르며(최소 높이 42px, Medium 14/20/400, Large 16/24/400) 폭은 항상 Trigger와 같습니다.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DisabledStates() {
|
||||
return (
|
||||
<div className="grid max-w-[480px] gap-6">
|
||||
<section className="grid gap-2">
|
||||
<h3 className="text-base font-semibold">Selected Value</h3>
|
||||
<SelectSpecimen size="medium" label="Status" defaultValue="active" disabled />
|
||||
</section>
|
||||
<section className="grid gap-2">
|
||||
<h3 className="text-base font-semibold">Placeholder</h3>
|
||||
<SelectSpecimen size="medium" label="Status" placeholder="Choose a status" disabled />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Components/Form/Select",
|
||||
component: SelectSpecimen,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"ui_viz docs/components/select.md: Medium 외곽 34px(Trigger 32), Large 외곽 44px(Trigger 42, 기본값). Trigger는 Medium 14/20/400, Large 16/24/400이고 화살표는 18×18입니다. 목록은 Trigger 폭에 연결되는 Overlay이며 Option은 Trigger의 size를 따릅니다(최소 42px, Medium 14/20/400, Large 16/24/400). Option 상태 면(.45)·글자색과 열린 구분선, 그림자는 ui_viz에서 Temporary/EXPERIMENTAL이라 shadcn 기본값을 유지합니다. Error는 ui_viz에 정의가 없습니다.",
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
size: { control: "inline-radio", options: ["medium", "large"], description: "무지정 기본값 Large. 업무 폼·조회 소비 역할은 Medium." },
|
||||
disabled: { control: "boolean" },
|
||||
defaultOpen: { control: "boolean" },
|
||||
defaultValue: { control: "select", options: [undefined, ...statusOptions.map((option) => option.value)] },
|
||||
placeholder: { control: "text" },
|
||||
label: { control: "text" },
|
||||
},
|
||||
} satisfies Meta<typeof SelectSpecimen>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Representative Story: every state on one page. Named after the component, as in ui_viz.
|
||||
// The open list is a Radix popover: press Escape or click outside to close it, click the trigger to reopen.
|
||||
export const All: Story = {
|
||||
name: "Select",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Select"
|
||||
description="목록에서 하나의 값을 고르는 컨트롤입니다. Option은 Trigger의 size를 따르고 목록 폭은 Trigger와 같습니다."
|
||||
/>
|
||||
{/* Open stays in the first section: the list opens on load, so its trigger must be on the first screen. */}
|
||||
<StorySection title="Overview" description="size를 생략한 기본 Select(Large 44px)와 열린 목록입니다. 목록은 Escape 또는 바깥 클릭으로 닫히고, Trigger를 클릭하면 다시 열립니다.">
|
||||
<StateBoard
|
||||
fixed
|
||||
states={[
|
||||
{ id: "default", label: "Default · Large (size 생략)", children: <SelectSpecimen /> },
|
||||
{
|
||||
id: "open",
|
||||
label: "Open",
|
||||
children: (
|
||||
<div className="min-h-64">
|
||||
<SelectSpecimen size="medium" defaultOpen />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Sizes" description="Medium 34 / Large 44px. Input과 독립된 두 크기입니다.">
|
||||
<SizeComparison />
|
||||
</StorySection>
|
||||
<StorySection title="States" description="값이 선택된 상태와 Disabled입니다.">
|
||||
<StateBoard
|
||||
fixed
|
||||
states={[
|
||||
{ id: "selected", label: "Selected", children: <SelectSpecimen size="medium" defaultValue="active" /> },
|
||||
{ id: "disabled", label: "Disabled", children: <DisabledStates /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Invalid / Focus" description="aria-invalid는 Error 테두리로 표시합니다. Tab으로 Trigger에 포커스해 Invalid + Focus를 확인합니다.">
|
||||
<StateBoard
|
||||
fixed
|
||||
states={[
|
||||
{ id: "invalid", label: "Invalid", children: <SelectSpecimen size="medium" aria-invalid label="Invalid Select" defaultValue="active" /> },
|
||||
{ id: "invalid-disabled", label: "Invalid · Disabled", children: <SelectSpecimen size="medium" aria-invalid disabled label="Disabled Invalid Select" /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
// Verification Stories: the same specimens as separate Stories, hidden from the sidebar (tag `verification`), reachable by URL or the sidebar tag filter.
|
||||
export const Default: Story = { tags: ["verification"], render: () => narrow(<SelectSpecimen />) }
|
||||
export const InvalidFocus: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => (
|
||||
<div className="grid max-w-sm gap-6">
|
||||
<button type="button">Before field</button>
|
||||
<SelectSpecimen size="medium" aria-invalid label="Invalid Select" defaultValue="active" />
|
||||
<SelectSpecimen size="medium" aria-invalid disabled label="Disabled Invalid Select" />
|
||||
<button type="button">After field</button>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
export const Open: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => narrow(<div className="min-h-64"><SelectSpecimen size="medium" defaultOpen /></div>),
|
||||
}
|
||||
export const Selected: Story = { tags: ["verification"], render: () => narrow(<SelectSpecimen size="medium" defaultValue="active" />) }
|
||||
export const Disabled: Story = { tags: ["verification"], render: () => <DisabledStates /> }
|
||||
export const Sizes: Story = { tags: ["verification"], name: "Compare Sizes", render: () => <SizeComparison /> }
|
||||
export const Medium: Story = { tags: ["verification"], render: () => narrow(<SizeCard spec={sizes[0]} />) }
|
||||
export const Large: Story = { tags: ["verification"], render: () => narrow(<SizeCard spec={sizes[1]} />) }
|
||||
|
||||
// HM resilience checks only: ui_viz had no public long-content or scroll Story, so these are not parity evidence.
|
||||
export const LongContent: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => (
|
||||
<div style={{ display: "grid", gap: 24, maxWidth: 280 }}>
|
||||
<SelectSpecimen
|
||||
size="medium"
|
||||
label="Long placeholder"
|
||||
placeholder="A very long placeholder that must truncate without moving the chevron"
|
||||
options={longOptions}
|
||||
/>
|
||||
<SelectSpecimen size="medium" label="Long selected value" defaultValue="long" options={longOptions} />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
// Open list with an option longer than the list: checks Viewport → Item → ItemText sizing (Trigger 14px vs Option 16px for Medium).
|
||||
const longOpenOptions = [
|
||||
{ value: "short", label: "Short option" },
|
||||
{ value: "long", label: "A very long option label that is wider than the open list and must truncate inside it" },
|
||||
]
|
||||
export const OpenLargeShort: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => narrow(<div className="min-h-64"><SelectSpecimen size="large" defaultOpen /></div>),
|
||||
}
|
||||
export const OpenMediumLong: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => <div className="min-h-64" style={{ maxWidth: 280 }}><SelectSpecimen size="medium" defaultOpen defaultValue="long" options={longOpenOptions} /></div>,
|
||||
}
|
||||
export const OpenLargeLong: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => <div className="min-h-64" style={{ maxWidth: 280 }}><SelectSpecimen size="large" defaultOpen defaultValue="long" options={longOpenOptions} /></div>,
|
||||
}
|
||||
|
||||
// Place the real Trigger near the viewport bottom so Radix collision detection flips it.
|
||||
const atBottom = (children: ReactNode) => (
|
||||
<div style={{ minHeight: "calc(100dvh - 32px)", display: "grid", alignItems: "end", width: 280, maxWidth: "100%" }}>{children}</div>
|
||||
)
|
||||
export const OpenTop: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => atBottom(<SelectSpecimen size="large" defaultOpen />),
|
||||
}
|
||||
export const OpenTopLong: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => atBottom(<SelectSpecimen size="medium" defaultOpen defaultValue="long" options={longOpenOptions} />),
|
||||
}
|
||||
|
||||
// Comparison only: pins the list profile independently of the Trigger to check whether a Medium Trigger + Large list
|
||||
// breaks the open list. Production derives the list size from the Trigger; this Story overrides the data-size attribute
|
||||
// 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}>
|
||||
<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"} />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
export const ListSizeComparison: Story = {
|
||||
tags: ["verification"],
|
||||
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))]">
|
||||
<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} />
|
||||
<ListSizeColumn title="B · Medium Trigger + Medium List" listSize="medium" width={384} />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const Scroll: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => (
|
||||
<div style={{ minHeight: 360, maxWidth: 280 }}>
|
||||
<SelectSpecimen size="medium" label="Scrollable options" defaultOpen options={scrollOptions} />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
// HM only: not part of the ui_viz Select Stories. Tagged verification so the default sidebar keeps only the representative Story.
|
||||
export const Playground: Story = {
|
||||
tags: ["verification"],
|
||||
args: { label: "Select", placeholder: "Choose a status", size: "medium", disabled: false, defaultOpen: false },
|
||||
render: (args) => narrow(<SelectSpecimen key={String(args.defaultOpen)} {...args} />),
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Separator } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
const meta = {
|
||||
title: "Components/Data & Display/Separator",
|
||||
component: Separator,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"현재 HM 규격: 1px 선, 색은 pale warm neutral(Primitive 참조). Radix Separator의 orientation과 decorative(기본 true) API를 유지합니다. decorative=false이면 role=separator가 노출됩니다.",
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: { orientation: { control: "inline-radio", options: ["horizontal", "vertical"] }, decorative: { control: "boolean" } },
|
||||
} satisfies Meta<typeof Separator>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const All: Story = {
|
||||
name: "Separator",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Separator"
|
||||
description="콘텐츠 사이를 나누는 선입니다. 기본은 장식용(decorative)이며 접근성 트리에 노출되지 않습니다."
|
||||
/>
|
||||
<StorySection title="Overview">
|
||||
<StateBoard
|
||||
min={280}
|
||||
states={[
|
||||
{
|
||||
id: "horizontal",
|
||||
label: "Horizontal",
|
||||
children: (
|
||||
<div className="grid gap-3 text-sm">
|
||||
<span>Above</span>
|
||||
<Separator />
|
||||
<span>Below</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Variants">
|
||||
<StateBoard
|
||||
min={280}
|
||||
states={[
|
||||
{
|
||||
id: "vertical",
|
||||
label: "Vertical (flex row)",
|
||||
children: (
|
||||
<div className="flex h-6 items-center gap-3 text-sm">
|
||||
<span>Left</span>
|
||||
<Separator orientation="vertical" />
|
||||
<span>Middle</span>
|
||||
<Separator orientation="vertical" />
|
||||
<span>Right</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "semantic",
|
||||
label: "Semantic (decorative=false)",
|
||||
noteAfter: true,
|
||||
note: "decorative=false는 role=separator와 aria-orientation을 노출합니다.",
|
||||
children: (
|
||||
<div className="grid gap-3 text-sm">
|
||||
<span>Section A</span>
|
||||
<Separator decorative={false} />
|
||||
<span>Section B</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const Horizontal: Story = { tags: ["verification"], render: () => <div className="max-w-xs"><Separator /></div> }
|
||||
export const Vertical: Story = { tags: ["verification"], render: () => <div className="flex h-8 items-center"><Separator orientation="vertical" /></div> }
|
||||
@@ -0,0 +1,130 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
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"
|
||||
|
||||
// Sheet is a modal Radix overlay (Portal), so the representative Story is a row of triggers that open the real Sheet for each
|
||||
// side and content case. The same cases start open in the verification Stories. Radix owns focus trap, Escape and focus return.
|
||||
|
||||
type Side = "top" | "right" | "bottom" | "left"
|
||||
|
||||
const longText = "긴 내용은 Body 영역만 세로로 scroll 하고 Header와 Footer는 그대로 보입니다. "
|
||||
|
||||
function Frame({
|
||||
trigger,
|
||||
side,
|
||||
defaultOpen,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
trigger: string
|
||||
side?: Side
|
||||
defaultOpen?: boolean
|
||||
title: string
|
||||
description?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Sheet defaultOpen={defaultOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="primary" appearance="outline">
|
||||
{trigger}
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side={side}>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
{description ? <SheetDescription>{description}</SheetDescription> : null}
|
||||
</SheetHeader>
|
||||
<SheetBody>{children}</SheetBody>
|
||||
<SheetFooter>
|
||||
<SheetClose asChild>
|
||||
<Button variant="cancel" appearance="solid">
|
||||
취소
|
||||
</Button>
|
||||
</SheetClose>
|
||||
<SheetClose asChild>
|
||||
<Button variant="primary" appearance="solid">
|
||||
저장
|
||||
</Button>
|
||||
</SheetClose>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
function SideCase({ side, defaultOpen }: { side: Side; defaultOpen?: boolean }) {
|
||||
return (
|
||||
<Frame trigger={side} side={side} defaultOpen={defaultOpen} title="설정" description="화면 가장자리에서 열리는 보조 패널입니다.">
|
||||
<p>{`side="${side}"`}</p>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
function FormCase({ defaultOpen }: { defaultOpen?: boolean }) {
|
||||
return (
|
||||
<Frame trigger="폼" defaultOpen={defaultOpen} title="프로젝트 정보">
|
||||
<div className="grid gap-2">
|
||||
<label htmlFor="sheet-form-name" className="text-sm font-medium text-muted-foreground">
|
||||
프로젝트명
|
||||
</label>
|
||||
<Input id="sheet-form-name" size="medium" placeholder="프로젝트명을 입력하세요" />
|
||||
</div>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
function LongCase({ defaultOpen }: { defaultOpen?: boolean }) {
|
||||
return (
|
||||
<Frame trigger="긴 내용" defaultOpen={defaultOpen} title="긴 내용" description="Body만 scroll 합니다.">
|
||||
{Array.from({ length: 30 }, (_, i) => (
|
||||
<p key={i} className="mb-3">
|
||||
{longText}
|
||||
</p>
|
||||
))}
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = { title: "Components/Overlay/Sheet", component: Sheet, parameters: { layout: "centered" } } satisfies Meta<typeof Sheet>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const All: Story = {
|
||||
name: "Sheet",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Sheet"
|
||||
description="화면 가장자리에서 열리는 보조 모달 패널입니다. 각 버튼을 눌러 실제 Sheet를 엽니다."
|
||||
/>
|
||||
<StorySection title="Overview" description="side를 생략한 기본 방향(right)입니다.">
|
||||
<SideCase side="right" />
|
||||
</StorySection>
|
||||
<StorySection title="Variants" description="side: left / top / bottom.">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<SideCase side="left" />
|
||||
<SideCase side="top" />
|
||||
<SideCase side="bottom" />
|
||||
</div>
|
||||
</StorySection>
|
||||
<StorySection title="Usage" description="폼 입력 조합과, 내용이 길 때 Body만 scroll 하는 경우입니다.">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<FormCase />
|
||||
<LongCase />
|
||||
</div>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
// Verification Stories: the same cases starting open, hidden from the sidebar (tag `verification`).
|
||||
export const Right: Story = { tags: ["verification"], render: () => <SideCase side="right" defaultOpen /> }
|
||||
export const Left: Story = { tags: ["verification"], render: () => <SideCase side="left" defaultOpen /> }
|
||||
export const Top: Story = { tags: ["verification"], render: () => <SideCase side="top" defaultOpen /> }
|
||||
export const Bottom: Story = { tags: ["verification"], render: () => <SideCase side="bottom" defaultOpen /> }
|
||||
export const Form: Story = { tags: ["verification"], render: () => <FormCase defaultOpen /> }
|
||||
export const LongContent: Story = { tags: ["verification"], name: "Long Content", render: () => <LongCase defaultOpen /> }
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Card, CardContent, CardHeader, Skeleton } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Skeleton is a visual placeholder; size and shape come from `className` (layout classes here are Story composition).
|
||||
// There is no data fetching. The representative Story shows text lines, an avatar, and a card / list loading composition.
|
||||
|
||||
function TextLines() {
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarRow() {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-10 rounded-full" />
|
||||
<div className="grid flex-1 gap-2">
|
||||
<Skeleton className="h-4 w-1/3" />
|
||||
<Skeleton className="h-3 w-2/3" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CardLoading() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-1/2" />
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3">
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<AvatarRow />
|
||||
<AvatarRow />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const sections = [
|
||||
{ id: "text", label: "Text", node: <TextLines /> },
|
||||
{ id: "avatar", label: "Avatar + text", node: <AvatarRow /> },
|
||||
{ id: "card", label: "Card / list loading", node: <CardLoading /> },
|
||||
]
|
||||
|
||||
const meta = { title: "Components/Data & Display/Skeleton", component: Skeleton, parameters: { layout: "padded" } } satisfies Meta<typeof Skeleton>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const board = (...ids: string[]) => (
|
||||
<StateBoard min={320} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} />
|
||||
)
|
||||
|
||||
// Representative Story: the common Story page; the sections group the same specimens.
|
||||
export const All: Story = {
|
||||
name: "Skeleton",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Skeleton"
|
||||
description="콘텐츠를 불러오는 동안 자리를 표시하는 placeholder입니다. 크기와 모양은 className으로 정합니다."
|
||||
/>
|
||||
<StorySection title="Overview">{board("text")}</StorySection>
|
||||
<StorySection title="Usage" description="Avatar 행과 카드·목록 로딩 조합입니다.">{board("avatar", "card")}</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const Text: Story = { tags: ["verification"], render: () => <TextLines /> }
|
||||
export const CardList: Story = { tags: ["verification"], name: "Card List", render: () => <CardLoading /> }
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useState, type ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Slider } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// The representative Story shows each Slider mode with real, draggable controls; the same specimens also exist as Stories tagged
|
||||
// `verification` (hidden from the sidebar). The value label is the Story's composition (Slider has no built-in label).
|
||||
// Names (`aria-label`) are provided by the consumer.
|
||||
|
||||
function Labeled({ label, children, valueText }: { label: string; children: ReactNode; valueText: string }) {
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="font-medium">{label}</span>
|
||||
<span className="text-muted-foreground" data-testid="value">{valueText}</span>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Single() {
|
||||
const [value, setValue] = useState([40])
|
||||
return (
|
||||
<Labeled label="볼륨" valueText={`${value[0]}`}>
|
||||
<Slider aria-label="볼륨" value={value} onValueChange={setValue} max={100} step={1} />
|
||||
</Labeled>
|
||||
)
|
||||
}
|
||||
|
||||
function Range() {
|
||||
const [value, setValue] = useState([25, 75])
|
||||
return (
|
||||
<Labeled label="가격 범위" valueText={`${value[0]} – ${value[1]}`}>
|
||||
<Slider aria-label="가격 범위" value={value} onValueChange={setValue} max={100} step={1} minStepsBetweenThumbs={5} />
|
||||
</Labeled>
|
||||
)
|
||||
}
|
||||
|
||||
function Step() {
|
||||
const [value, setValue] = useState([50])
|
||||
return (
|
||||
<Labeled label="단계 (step 25)" valueText={`${value[0]}`}>
|
||||
<Slider aria-label="단계" value={value} onValueChange={setValue} max={100} step={25} />
|
||||
</Labeled>
|
||||
)
|
||||
}
|
||||
|
||||
function Disabled() {
|
||||
return <Slider aria-label="비활성" defaultValue={[60]} disabled />
|
||||
}
|
||||
|
||||
function Vertical() {
|
||||
return (
|
||||
<div style={{ height: 180 }} className="flex justify-center">
|
||||
<Slider aria-label="세로" defaultValue={[30]} orientation="vertical" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const sections = [
|
||||
{ id: "single", label: "Single", node: <Single /> },
|
||||
{ id: "range", label: "Range (two thumbs)", node: <Range /> },
|
||||
{ id: "step", label: "Step", node: <Step /> },
|
||||
{ id: "disabled", label: "Disabled", node: <Disabled /> },
|
||||
{ id: "vertical", label: "Vertical", node: <Vertical /> },
|
||||
]
|
||||
|
||||
const meta = { title: "Components/Form/Slider", component: Slider, parameters: { layout: "padded" } } satisfies Meta<typeof Slider>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const board = (...ids: string[]) => (
|
||||
<StateBoard min={320} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} />
|
||||
)
|
||||
|
||||
// Representative Story: the common Story page; the sections group the same specimens.
|
||||
export const All: Story = {
|
||||
name: "Slider",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Slider"
|
||||
description="막대 위의 thumb을 끌어 범위 안의 값을 고르는 컨트롤입니다. 화살표 키로도 값을 바꿉니다."
|
||||
/>
|
||||
<StorySection title="Overview">{board("single")}</StorySection>
|
||||
<StorySection title="Variants" description="두 thumb의 범위, step, 세로 방향입니다.">{board("range", "step", "vertical")}</StorySection>
|
||||
<StorySection title="States">{board("disabled")}</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const SingleValue: Story = { tags: ["verification"], name: "Single", render: () => <Single /> }
|
||||
export const RangeValue: Story = { tags: ["verification"], name: "Range", render: () => <Range /> }
|
||||
export const DisabledSlider: Story = { tags: ["verification"], name: "Disabled", render: () => <Disabled /> }
|
||||
|
||||
export const Focus: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => (
|
||||
<div className="grid gap-8">
|
||||
<Single />
|
||||
<Labeled label="비활성" valueText="60"><Disabled /></Labeled>
|
||||
<output data-testid="slider-focus-result" hidden>Ready</output>
|
||||
</div>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
const thumb = canvasElement.querySelector<HTMLElement>('[data-slot="slider-thumb"]')!
|
||||
const disabled = canvasElement.querySelector<HTMLElement>('[data-disabled] [data-slot="slider-thumb"]')!
|
||||
const result = canvasElement.querySelector<HTMLOutputElement>('[data-testid="slider-focus-result"]')!
|
||||
const states: Record<string, string> = {}
|
||||
const read = (name: string, element = thumb) => { states[name] = getComputedStyle(element).boxShadow }
|
||||
const assert = (condition: boolean, message: string) => { if (!condition) throw Error(message) }
|
||||
thumb.blur()
|
||||
read("Default")
|
||||
// Existing Storybook pseudo-state mirroring preserves production selector order/specificity.
|
||||
thumb.setAttribute("data-sb-hover", "")
|
||||
read("Hover")
|
||||
thumb.setAttribute("data-sb-active", "")
|
||||
read("Drag")
|
||||
thumb.removeAttribute("data-sb-hover")
|
||||
thumb.removeAttribute("data-sb-active")
|
||||
thumb.focus()
|
||||
assert(thumb.matches(":focus-visible"), "Keyboard focus must be visible")
|
||||
read("Focus")
|
||||
thumb.setAttribute("data-sb-hover", "")
|
||||
read("Focus + Hover")
|
||||
thumb.setAttribute("data-sb-active", "")
|
||||
read("Focus + Drag")
|
||||
disabled.setAttribute("data-sb-hover", "")
|
||||
disabled.setAttribute("data-sb-active", "")
|
||||
disabled.focus()
|
||||
read("Disabled", disabled)
|
||||
assert(document.activeElement !== disabled, "Disabled thumb cannot receive focus")
|
||||
assert(states.Default === "none" && states.Disabled === "none", "Default/Disabled have no halo")
|
||||
assert(states.Hover.includes("4px") && states.Drag === states.Hover.replace("4px", "6px"), "Interaction keeps 4/6px and its color")
|
||||
assert(states.Focus.includes("4px") && states["Focus + Hover"] === states.Focus, "Hover cannot mask Focus")
|
||||
assert(states["Focus + Drag"] === states.Focus.replace("4px", "6px"), "Drag keeps 6px without masking Focus color")
|
||||
for (const element of [thumb, disabled]) {
|
||||
element.removeAttribute("data-sb-hover")
|
||||
element.removeAttribute("data-sb-active")
|
||||
}
|
||||
thumb.focus()
|
||||
result.textContent = JSON.stringify(states)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Button, toast, Toaster } from "@hm-design/ui"
|
||||
import { StateBoard } from "./story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "./story-page"
|
||||
|
||||
// Toast is created with the real Sonner API (`toast`, re-exported by @hm-design/ui); nothing is a static specimen.
|
||||
// One <Toaster /> is mounted in .storybook/preview.tsx for every Story (an app mounts it once at its root).
|
||||
|
||||
const wait = (ms: number, fail?: boolean) =>
|
||||
new Promise<string>((resolve, reject) => setTimeout(() => (fail ? reject(new Error("fail")) : resolve("완료")), ms))
|
||||
|
||||
function Trigger({ onClick, children }: { onClick: () => void; children: ReactNode }) {
|
||||
return (
|
||||
<Button variant="primary" appearance="outline" onClick={onClick}>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
// `ids` picks the specimens for one section of the common Story page.
|
||||
function Board({ ids }: { ids: string[] }) {
|
||||
return (
|
||||
<StateBoard
|
||||
min={220}
|
||||
states={[
|
||||
{
|
||||
id: "types",
|
||||
label: "Types",
|
||||
noteAfter: true,
|
||||
note: "Default / Success / Error / Warning / Info / Loading. Only Error has its own color; the other types are neutral (status colors are undecided).",
|
||||
children: (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Trigger onClick={() => toast("알림이 도착했습니다", { description: "기본 알림입니다." })}>Default</Trigger>
|
||||
<Trigger onClick={() => toast.success("저장했습니다", { description: "변경 사항이 저장되었습니다." })}>Success</Trigger>
|
||||
<Trigger onClick={() => toast.error("저장하지 못했습니다", { description: "잠시 후 다시 시도하세요." })}>Error</Trigger>
|
||||
<Trigger onClick={() => toast.warning("저장 공간이 부족합니다", { description: "불필요한 파일을 정리하세요." })}>Warning</Trigger>
|
||||
<Trigger onClick={() => toast.info("새 버전이 있습니다")}>Info</Trigger>
|
||||
<Trigger onClick={() => toast.loading("처리 중입니다", { duration: 4000 })}>Loading</Trigger>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "action",
|
||||
label: "Action / Close",
|
||||
children: (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Trigger onClick={() => toast("파일을 삭제했습니다", { action: { label: "실행 취소", onClick: () => toast.success("복원했습니다") } })}>
|
||||
Action
|
||||
</Trigger>
|
||||
<Trigger
|
||||
onClick={() =>
|
||||
toast("변경 사항을 적용할까요?", {
|
||||
description: "취소하면 이전 상태가 유지됩니다.",
|
||||
duration: Infinity,
|
||||
action: { label: "적용", onClick: () => undefined },
|
||||
cancel: { label: "취소", onClick: () => undefined },
|
||||
})
|
||||
}
|
||||
>
|
||||
Action + Cancel
|
||||
</Trigger>
|
||||
<Trigger onClick={() => toast("닫기 버튼이 있는 알림", { closeButton: true, duration: Infinity })}>Close button</Trigger>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "promise",
|
||||
label: "Promise",
|
||||
noteAfter: true,
|
||||
note: "Loading, then Success or Error (local timeout, no network).",
|
||||
children: (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Trigger onClick={() => toast.promise(wait(1500), { loading: "저장 중...", success: "저장했습니다", error: "저장하지 못했습니다" })}>
|
||||
Promise → Success
|
||||
</Trigger>
|
||||
<Trigger onClick={() => toast.promise(wait(1500, true), { loading: "저장 중...", success: "저장했습니다", error: "저장하지 못했습니다" })}>
|
||||
Promise → Error
|
||||
</Trigger>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "stack",
|
||||
label: "Stack / Long",
|
||||
children: (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Trigger
|
||||
onClick={() => {
|
||||
toast("첫 번째 알림")
|
||||
toast.success("두 번째 알림")
|
||||
toast.error("세 번째 알림")
|
||||
}}
|
||||
>
|
||||
Stack ×3
|
||||
</Trigger>
|
||||
<Trigger
|
||||
onClick={() =>
|
||||
toast.info("긴 메시지 확인용 제목입니다. 좁은 화면에서도 줄바꿈되어야 합니다.", {
|
||||
description: "설명도 길게 작성하면 여러 줄로 나뉘고 토스트 폭을 넘치지 않아야 합니다. 이 문장은 줄바꿈을 확인하기 위한 예시입니다.",
|
||||
action: { label: "자세히 보기", onClick: () => undefined },
|
||||
closeButton: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
Long message
|
||||
</Trigger>
|
||||
<Trigger onClick={() => toast.dismiss()}>Dismiss all</Trigger>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
].filter((s) => ids.includes(s.id))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = { title: "Components/Feedback/Sonner", component: Toaster, parameters: { layout: "padded" } } satisfies Meta<typeof Toaster>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Representative Story: buttons create real toasts through Sonner (types, action / cancel, close, promise, stack, long message).
|
||||
export const All: Story = {
|
||||
name: "Sonner",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Sonner"
|
||||
description="작업 결과를 잠시 띄우는 Toast 알림입니다. 실제 Sonner API(toast)로 만들며, Toaster는 앱 루트에 한 번 둡니다."
|
||||
/>
|
||||
<StorySection title="Variants" description="버튼을 눌러 각 유형의 Toast를 띄웁니다.">
|
||||
<Board ids={["types"]} />
|
||||
</StorySection>
|
||||
<StorySection title="Usage" description="Action / Cancel / Close, Promise, 여러 개 쌓기와 긴 메시지입니다.">
|
||||
<Board ids={["action", "promise", "stack"]} />
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useContext, type CSSProperties, type ReactNode } from "react"
|
||||
import { StorySectionContext } from "./story-page"
|
||||
|
||||
// Representative-Story layout used by component review pages (ui_viz StateBoard): labeled sections on one page.
|
||||
// It only arranges examples; it never styles the component being reviewed.
|
||||
// `note` is a Story note, not Product UI. It sits under the title by default; `noteAfter` places it below the specimen
|
||||
// (Title → actual UI → Story note) for specimens whose Label/Control must stay directly under the title.
|
||||
export type BoardState = { id: string; label: string; note?: string; noteAfter?: boolean; wide?: boolean; children: ReactNode }
|
||||
|
||||
// `fixed` uses columns of exactly `min` px instead of stretching them (1fr). Specimens whose popup is anchored to a Radix
|
||||
// trigger need whole-pixel columns: a fractional column (e.g. 358.4px) puts the trigger edge between device pixels while the
|
||||
// popper rounds the popup to whole pixels, so the two outlines end up 1px apart.
|
||||
export function StateBoard({ states, min = 320, fixed = false }: { states: BoardState[]; min?: number; fixed?: boolean }) {
|
||||
// Inside a StorySection the labels sit one heading level below the section title.
|
||||
const Label = useContext(StorySectionContext) ? "h3" : "h2"
|
||||
return (
|
||||
<div
|
||||
className={`grid items-start gap-x-6 gap-y-10 ${fixed ? "[grid-template-columns:repeat(auto-fill,min(100%,var(--sb-state-min,320px)))]" : "[grid-template-columns:repeat(auto-fill,minmax(min(100%,var(--sb-state-min,320px)),1fr))]"}`}
|
||||
style={{ "--sb-state-min": `${min}px` } as CSSProperties}
|
||||
>
|
||||
{states.map(({ id, label, note, noteAfter, wide, children }) => (
|
||||
<section
|
||||
className={`grid min-w-0 content-start gap-3${wide ? " col-span-full" : ""}`}
|
||||
key={id}
|
||||
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}
|
||||
{children}
|
||||
{note && noteAfter ? <p className="text-sm text-muted-foreground">{note}</p> : null}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/* Storybook presentation only. Common page frame for representative Stories; all selectors stay inside .sb-page.
|
||||
Inner (max 1280, gutter 16–40) and title (kicker 12 / title 28·36·700 / description, underlined) reuse Foundation / Color.
|
||||
Section reuses the Center Graphic / 교량 split: heading + description, a divider, then the specimen. */
|
||||
.sb-page {
|
||||
--sb-page-max-width: 1280px;
|
||||
--sb-page-gutter: clamp(16px, 3vw, 40px);
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding-block: var(--sb-page-gutter);
|
||||
font-family: var(--hm-font-family-base);
|
||||
color: var(--hm-sem-fg-primary);
|
||||
}
|
||||
.sb-page > .sb-page-inner {
|
||||
box-sizing: border-box;
|
||||
/* Keep the established content width: the 1280px envelope includes both gutters. */
|
||||
width: calc(min(100%, var(--sb-page-max-width)) - 2 * var(--sb-page-gutter));
|
||||
min-width: 0;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.sb-page .sb-page-header {
|
||||
padding-bottom: 24px;
|
||||
border-bottom: 1px solid var(--hm-sem-stroke-input-default);
|
||||
font-size: var(--hm-label-font-size);
|
||||
line-height: var(--hm-label-line-height);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.sb-page .sb-page-header .sb-page-kicker {
|
||||
margin: 0 0 12px;
|
||||
color: var(--hm-sem-fg-secondary);
|
||||
font-size: var(--hm-button-font-size-sm);
|
||||
font-weight: var(--hm-font-weight-bold);
|
||||
}
|
||||
.sb-page .sb-page-header .sb-page-title {
|
||||
margin: 8px 0 12px;
|
||||
font-size: var(--hm-page-header-title-font-size);
|
||||
line-height: var(--hm-page-header-title-line-height);
|
||||
font-weight: var(--hm-page-header-title-font-weight);
|
||||
}
|
||||
.sb-page .sb-page-header .sb-page-description { margin: 0 0 12px; font-weight: var(--hm-input-font-weight); }
|
||||
|
||||
.sb-page .sb-page-section { margin-top: 32px; scroll-margin-top: 16px; }
|
||||
.sb-page .sb-page-section-header {
|
||||
padding-bottom: 12px;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid var(--hm-sem-stroke-input-default);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.sb-page .sb-page-section-header .sb-page-section-title {
|
||||
margin: 0;
|
||||
font-size: var(--hm-card-title-font-size);
|
||||
line-height: var(--hm-card-title-line-height);
|
||||
font-weight: var(--hm-card-title-font-weight);
|
||||
}
|
||||
.sb-page .sb-page-section-header .sb-page-section-description {
|
||||
margin: 4px 0 0;
|
||||
color: var(--hm-sem-fg-secondary);
|
||||
font-size: var(--hm-label-font-size);
|
||||
line-height: var(--hm-label-line-height);
|
||||
font-weight: var(--hm-input-font-weight);
|
||||
}
|
||||
.sb-page .sb-page-section-body { min-width: 0; }
|
||||
@@ -0,0 +1,56 @@
|
||||
import { createContext, type ReactNode } from "react"
|
||||
import "./story-page.css"
|
||||
|
||||
// Storybook presentation only (not part of @hm-design/ui): the common frame of a representative Story page.
|
||||
// Page → Header (title, description) → Sections (title, description, specimen). It never styles the component being reviewed.
|
||||
// Inner and title follow Foundation / Color; the section header → content split follows Center Graphic / 교량.
|
||||
// A Story using it sets `parameters.layout: "fullscreen"` so the page owns its inner padding.
|
||||
|
||||
// Lets StateBoard render its specimen labels one level below the section heading.
|
||||
export const StorySectionContext = createContext(false)
|
||||
|
||||
export function StoryPage({ className, children }: { className?: string; children: ReactNode }) {
|
||||
return <div className={className ? `sb-page ${className}` : "sb-page"}><div className="sb-page-inner">{children}</div></div>
|
||||
}
|
||||
|
||||
export function StoryPageHeader({ kicker, title, description }: { kicker: string; title: string; description?: ReactNode }) {
|
||||
return (
|
||||
<header className="sb-page-header">
|
||||
<p className="sb-page-kicker">{kicker}</p>
|
||||
<h1 className="sb-page-title">{title}</h1>
|
||||
{description ? <p className="sb-page-description">{description}</p> : null}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
// Headings and specimens share StoryPage's single Inner. Wide specimens own their local scroll frame.
|
||||
export function StorySection({
|
||||
title,
|
||||
description,
|
||||
id,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
description?: ReactNode
|
||||
id?: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<section className="sb-page-section" id={id} aria-label={title}>
|
||||
<header className="sb-page-section-header">
|
||||
<h2 className="sb-page-section-title">{title}</h2>
|
||||
{description ? <p className="sb-page-section-description">{description}</p> : null}
|
||||
</header>
|
||||
<StorySectionContext.Provider value>
|
||||
<div className="sb-page-section-body">{children}</div>
|
||||
</StorySectionContext.Provider>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export const STORY_KICKER = {
|
||||
foundation: "HM DESIGN SYSTEM / FOUNDATION",
|
||||
components: "HM DESIGN SYSTEM / COMPONENTS",
|
||||
patterns: "HM DESIGN SYSTEM / PATTERNS",
|
||||
center: "HM DESIGN SYSTEM / CENTER GRAPHIC",
|
||||
} as const
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useId, useState, type ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Label, Switch } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
function Option({ label, children }: { label: string; children: (id: string) => ReactNode }) {
|
||||
const id = useId()
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{children(id)}
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Controlled() {
|
||||
const [on, setOn] = useState(false)
|
||||
return <Option label={`Notifications (${on ? "on" : "off"})`}>{(id) => <Switch id={id} checked={on} onCheckedChange={setOn} />}</Option>
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Components/Form/Switch",
|
||||
component: Switch,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"현재 HM 규격: 트랙 36×20, 썸 16, On은 state-active, Off는 stroke 색. ui-viz에는 Switch 규격이 없어 참고만 했습니다. Radix Switch의 role=switch, 키보드, 접근성과 shadcn 기본 transition을 유지합니다.",
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Switch>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const All: Story = {
|
||||
name: "Switch",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Switch"
|
||||
description="설정을 즉시 켜고 끄는 컨트롤입니다. 트랙 36×20, 썸 16이며 On은 state-active 색입니다."
|
||||
/>
|
||||
<StorySection title="Overview">
|
||||
<StateBoard
|
||||
min={240}
|
||||
states={[
|
||||
{ id: "off", label: "Off", children: <Option label="Option">{(id) => <Switch id={id} />}</Option> },
|
||||
{ id: "on", label: "On", children: <Option label="Option">{(id) => <Switch id={id} defaultChecked />}</Option> },
|
||||
{ id: "interactive", label: "Controlled · click toggles", children: <Controlled /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="States">
|
||||
<StateBoard
|
||||
min={240}
|
||||
states={[
|
||||
{ id: "hover", label: "Hover", noteAfter: true, note: "Black 20% overlay. 포인터를 올려 확인합니다.", children: <Option label="Option">{(id) => <Switch id={id} />}</Option> },
|
||||
{ id: "focus", label: "Focus", children: <Option label="Option">{(id) => <Switch id={id} />}</Option> },
|
||||
{
|
||||
id: "disabled",
|
||||
label: "Disabled",
|
||||
children: (
|
||||
<div className="grid gap-3">
|
||||
<Option label="Off">{(id) => <Switch id={id} disabled />}</Option>
|
||||
<Option label="On">{(id) => <Switch id={id} disabled defaultChecked />}</Option>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
// preventScroll: the page opens at its header, not at the States section.
|
||||
canvasElement.querySelector<HTMLButtonElement>('[data-overview-state="focus"] [role="switch"]')?.focus({ preventScroll: true })
|
||||
},
|
||||
}
|
||||
|
||||
export const Off: Story = { tags: ["verification"], render: () => <Option label="Option">{(id) => <Switch id={id} />}</Option> }
|
||||
export const On: Story = { tags: ["verification"], render: () => <Option label="Option">{(id) => <Switch id={id} defaultChecked />}</Option> }
|
||||
export const Disabled: Story = { tags: ["verification"], render: () => <Option label="Option">{(id) => <Switch id={id} disabled defaultChecked />}</Option> }
|
||||
@@ -0,0 +1,170 @@
|
||||
import { useState, type ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Badge, Checkbox, Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Table is a static display primitive, so the representative Story is a board of specimens. Checkbox and Badge appear only as
|
||||
// cell content (Story composition); Table has no dependency on them and no selection, sorting or paging behavior.
|
||||
// The Story keeps the selected rows in local state and passes `data-state="selected"` itself.
|
||||
|
||||
const projects = [
|
||||
{ id: "P-001", name: "한맥 노선 정비", status: "진행", amount: 1280000 },
|
||||
{ id: "P-002", name: "시설물 점검 리포트", status: "완료", amount: 450000 },
|
||||
{ id: "P-003", name: "교량 안전 진단", status: "대기", amount: 9800000 },
|
||||
]
|
||||
const won = (n: number) => n.toLocaleString("ko-KR")
|
||||
|
||||
function BasicTable() {
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>번호</TableHead>
|
||||
<TableHead>프로젝트</TableHead>
|
||||
<TableHead>상태</TableHead>
|
||||
<TableHead className="text-right">금액</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{projects.map((p) => (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell>{p.id}</TableCell>
|
||||
<TableCell>{p.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge>{p.status}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{won(p.amount)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
<TableFooter>
|
||||
<TableRow>
|
||||
<TableCell colSpan={3}>합계</TableCell>
|
||||
<TableCell className="text-right">{won(projects.reduce((s, p) => s + p.amount, 0))}</TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
<TableCaption>프로젝트별 계약 금액(원)</TableCaption>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectableTable() {
|
||||
const [selected, setSelected] = useState<string[]>(["P-002"])
|
||||
const toggle = (id: string) => setSelected((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id]))
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10">
|
||||
<Checkbox
|
||||
aria-label="전체 선택"
|
||||
checked={selected.length === projects.length ? true : selected.length ? "indeterminate" : false}
|
||||
onCheckedChange={(v) => setSelected(v ? projects.map((p) => p.id) : [])}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>프로젝트</TableHead>
|
||||
<TableHead className="text-right">금액</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{projects.map((p) => (
|
||||
<TableRow key={p.id} data-state={selected.includes(p.id) ? "selected" : undefined}>
|
||||
<TableCell>
|
||||
<Checkbox aria-label={`${p.name} 선택`} checked={selected.includes(p.id)} onCheckedChange={() => toggle(p.id)} />
|
||||
</TableCell>
|
||||
<TableCell>{p.name}</TableCell>
|
||||
<TableCell className="text-right">{won(p.amount)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
function LongTextTable() {
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>제목</TableHead>
|
||||
<TableHead>비고</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>긴 텍스트는 셀 안에서 줄바꿈되고 행 높이만 늘어납니다. 공백 없는 문자열도 열 폭을 밀어내지 않습니다.</TableCell>
|
||||
<TableCell>AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>짧은 행</TableCell>
|
||||
<TableCell>-</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
function WideTable() {
|
||||
const cols = Array.from({ length: 10 }, (_, i) => `컬럼 ${i + 1}`)
|
||||
return (
|
||||
<Table style={{ minWidth: 1100 }}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{cols.map((c) => (
|
||||
<TableHead key={c}>{c}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{Array.from({ length: 3 }, (_, r) => (
|
||||
<TableRow key={r}>
|
||||
{cols.map((c) => (
|
||||
<TableCell key={c}>{`값 ${r + 1}-${c.slice(3)}`}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
const box = (node: ReactNode) => <div style={{ width: "100%", maxWidth: 720, minWidth: 0 }}>{node}</div>
|
||||
|
||||
const sections = [
|
||||
{ id: "basic", label: "Basic + Badge + numeric + footer + caption", node: <BasicTable /> },
|
||||
{ id: "selected", label: "Selected row", note: "Checkbox와 data-state=\"selected\"는 Story가 조합합니다(Table에는 선택 로직이 없음)", node: <SelectableTable /> },
|
||||
{ id: "long", label: "Long text", node: <LongTextTable /> },
|
||||
{ id: "wide", label: "Horizontal overflow", note: "container가 가로로 scroll 하고 페이지는 밀리지 않습니다", node: <WideTable /> },
|
||||
]
|
||||
|
||||
const meta = { title: "Components/Data & Display/Table", component: Table, parameters: { layout: "padded" } } satisfies Meta<typeof Table>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const board = (...ids: string[]) => (
|
||||
<StateBoard min={460} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, note: s.note, wide: true, children: box(s.node) }))} />
|
||||
)
|
||||
|
||||
// Representative Story: the common Story page; the sections group the same specimens.
|
||||
export const All: Story = {
|
||||
name: "Table",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Table"
|
||||
description="행과 열로 데이터를 보여주는 정적 표시 primitive입니다. 정렬·선택·페이지 로직은 Data Table Pattern이 담당합니다."
|
||||
/>
|
||||
<StorySection title="Overview">{board("basic")}</StorySection>
|
||||
<StorySection title="States">{board("selected")}</StorySection>
|
||||
<StorySection title="Usage" description="긴 텍스트와 가로 넘침입니다.">{board("long", "wide")}</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const Basic: Story = { tags: ["verification"], render: () => box(<BasicTable />) }
|
||||
export const Selected: Story = { tags: ["verification"], render: () => box(<SelectableTable />) }
|
||||
export const LongText: Story = { tags: ["verification"], name: "Long Text", render: () => box(<LongTextTable />) }
|
||||
export const Overflow: Story = { tags: ["verification"], name: "Horizontal Overflow", render: () => box(<WideTable />) }
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import type { ComponentProps } from "react"
|
||||
import { 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.
|
||||
|
||||
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" },
|
||||
} as const
|
||||
|
||||
function Specimen({
|
||||
variant = "primary",
|
||||
defaultValue = "menu2",
|
||||
disabledSecond,
|
||||
count = 3,
|
||||
labels,
|
||||
listProps,
|
||||
}: {
|
||||
variant?: Variant
|
||||
defaultValue?: string
|
||||
disabledSecond?: boolean
|
||||
count?: number
|
||||
labels?: string[]
|
||||
listProps?: ListProps
|
||||
}) {
|
||||
const { listProps: defaults, panel } = variantProps[variant]
|
||||
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)}>
|
||||
{items.map(({ value, label }, i) => (
|
||||
<TabsTrigger key={value} value={value} disabled={disabledSecond && i === 1}>
|
||||
{label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{items.map(({ value }, i) => (
|
||||
<TabsContent key={value} value={value} className={`rounded-b p-4 text-sm ${panel}`}>
|
||||
메뉴 {i + 1} 콘텐츠
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
|
||||
const longLabels = ["기본 정보", "보상 협의 및 계약 진행 현황", "토지 및 지장물 조사 내역"]
|
||||
|
||||
function LongLabelSpecimens() {
|
||||
return (
|
||||
<div className="grid min-w-0 max-w-full gap-5">
|
||||
<div className="min-w-0">
|
||||
<p className="mb-2 text-xs text-muted-foreground">짧은 label</p>
|
||||
<div className="w-full min-w-0 max-w-sm"><Specimen defaultValue="menu1" /></div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="mb-2 text-xs text-muted-foreground">긴 label 1개</p>
|
||||
<div className="w-full min-w-0 max-w-lg"><Specimen labels={["기본 정보", longLabels[1], "조사 내역"]} /></div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="mb-2 text-xs text-muted-foreground">긴 label 여러 개</p>
|
||||
<div className="w-full min-w-0 max-w-2xl"><Specimen labels={longLabels} /></div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="mb-2 text-xs text-muted-foreground">좁은 container · nowrap · overflow controls</p>
|
||||
<div className="w-full min-w-0 max-w-[260px]"><Specimen labels={longLabels} /></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Components/Navigation/Tabs",
|
||||
component: Tabs,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
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 이력은 유지합니다.",
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Tabs>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const All: Story = {
|
||||
name: "Tabs",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Tabs"
|
||||
description="같은 영역의 콘텐츠를 전환하는 탭입니다. 선택된 탭은 하단으로 콘텐츠 면과 이어지며, 넘칠 때만 이전/다음 이동 버튼이 나타납니다."
|
||||
/>
|
||||
<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 /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Default API">
|
||||
<StateBoard
|
||||
states={[
|
||||
{ id: "primary-fill", label: "Primary · TabsList fill", noteAfter: true, note: "panelSurface 생략 → content. 기본 TabsList와 같은 Primary입니다.", children: <Specimen listProps={{ fill: true }} /> },
|
||||
{ id: "primary-explicit", label: "Primary · fill + panelSurface content", noteAfter: true, note: "두 Props를 명시해도 기본 API와 같은 Primary입니다.", children: <Specimen listProps={{ fill: true, panelSurface: "content" }} /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Variants">
|
||||
<StateBoard
|
||||
states={[
|
||||
{ id: "secondary", label: "Secondary · Fill + panelSurface surface", noteAfter: true, note: "서브형. Surface(White) 위에서 사용하며 선택 면이 White로 이어집니다.", children: <Specimen variant="secondary" /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<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> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
// Verification: Primary 기준 상태와 Secondary 비교. Secondary도 같은 CSS 경로를 쓴다.
|
||||
export const Secondary: Story = { tags: ["verification"], render: () => <div className="max-w-md"><Specimen variant="secondary" /></div> }
|
||||
export const SelectedFirst: Story = { tags: ["verification"], name: "Selected First", render: () => <div className="max-w-md"><Specimen defaultValue="menu1" /></div> }
|
||||
export const SelectedLast: Story = { tags: ["verification"], name: "Selected Last", render: () => <div className="max-w-md"><Specimen defaultValue="menu3" /></div> }
|
||||
export const Focus: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => <div className="max-w-md"><Specimen /></div>,
|
||||
play: async ({ canvasElement }) => canvasElement.querySelector<HTMLButtonElement>('[role="tab"][data-state="active"]')?.focus(),
|
||||
}
|
||||
export const Disabled: Story = { tags: ["verification"], render: () => <div className="max-w-md"><Specimen disabledSecond /></div> }
|
||||
export const Overflow: Story = { tags: ["verification"], render: () => <div className="max-w-[260px]"><Specimen count={8} /></div> }
|
||||
export const SecondaryOverflow: Story = { tags: ["verification"], name: "Secondary Overflow", render: () => <div className="max-w-[260px]"><Specimen variant="secondary" count={8} /></div> }
|
||||
export const LongLabel: Story = { tags: ["verification"], name: "Long Label", render: () => <LongLabelSpecimens /> }
|
||||
|
||||
export const Compatibility: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader kicker="VERIFICATION ONLY" title="Tabs Compatibility" description="Legacy non-fill은 OLD_TEST_ONLY / REMOVE_CANDIDATE 이력을 보존하며 명시적으로 사용할 수 있습니다. 사용자 별도 승인 없이 삭제하지 않습니다." />
|
||||
<StorySection title="Legacy non-fill">
|
||||
<StateBoard states={[
|
||||
{ id: "legacy", label: "TabsList fill={false}", noteAfter: true, note: "panelSurface 기본 content는 non-fill 시각에 영향을 주지 않습니다. 패널은 기존 White surface를 유지합니다.", children: <Specimen variant="secondary" listProps={{ fill: false }} /> },
|
||||
{ id: "legacy-surface", label: "TabsList fill={false} panelSurface surface", noteAfter: true, note: "이전 기본값을 모두 명시한 경우에도 같은 Legacy 디자인입니다.", children: <Specimen variant="secondary" listProps={{ fill: false, panelSurface: "surface" }} /> },
|
||||
]} />
|
||||
</StorySection>
|
||||
<StorySection title="Explicit panelSurface">
|
||||
<StateBoard states={[
|
||||
{ id: "surface-only", label: "TabsList panelSurface surface", noteAfter: true, note: "fill 생략 → true이므로 Secondary가 됩니다.", children: <Specimen variant="secondary" listProps={{ panelSurface: "surface" }} /> },
|
||||
]} />
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useId, type ComponentProps, type ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Textarea } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Story structure follows Input: one representative "Textarea" Story shows every state on one page; the same specimens
|
||||
// also exist as separate Stories tagged `verification`, hidden from the sidebar by default (see .storybook/main.ts).
|
||||
// ui_viz docs/components/textarea.md §6 Reference Viewer: Default, Hover, Focus, Error, Error + Focus, Readonly, Disabled and Multiline.
|
||||
//
|
||||
// Label, Required star and Helper/Error message are not part of the Textarea component. Specimen composes them with plain
|
||||
// elements and shadcn Token utilities, the way a consumer would: Label 14/20/500 and Helper 12/18/400 in the secondary
|
||||
// text color, Error message 11/18 in the destructive color, field gap 8.
|
||||
|
||||
function Specimen({
|
||||
label = "Description",
|
||||
message,
|
||||
error,
|
||||
required,
|
||||
id,
|
||||
...textarea
|
||||
}: ComponentProps<typeof Textarea> & { label?: string; message?: string; error?: boolean }) {
|
||||
const uid = useId()
|
||||
const fieldId = id ?? `textarea-${uid}`
|
||||
const messageId = `${fieldId}-message`
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<label htmlFor={fieldId} className="justify-self-start text-sm font-medium text-muted-foreground">
|
||||
{label}
|
||||
{required ? (
|
||||
<span aria-hidden="true" className="text-destructive">
|
||||
{" *"}
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
<Textarea
|
||||
id={fieldId}
|
||||
required={required}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={message ? messageId : undefined}
|
||||
{...textarea}
|
||||
/>
|
||||
{message ? (
|
||||
<p id={messageId} className={error ? "text-[11px] leading-[18px] text-destructive" : "text-xs leading-[18px] text-muted-foreground"}>
|
||||
{message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const narrow = (children: ReactNode) => <div className="max-w-sm">{children}</div>
|
||||
const placeholder = "Enter a description"
|
||||
const multiline = "First line\nSecond line keeps its line break.\nThird line."
|
||||
|
||||
const hoverNote = "Hover는 Default와 같습니다(사용자 결정 TEXTAREA-05). 포인터를 올려도 면, 테두리, 글자색이 바뀌지 않습니다."
|
||||
const readonlyNote =
|
||||
"Readonly는 Default의 면, 테두리, typography를 유지하고 흐리게 표시하지 않습니다. 값 변경은 막고 선택·복사는 허용하며 resize 표시가 없습니다."
|
||||
|
||||
const meta = {
|
||||
title: "Components/Form/Textarea",
|
||||
component: Textarea,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"ui_viz docs/components/textarea.md: 최소 높이 112px, 안쪽 여백 8/12px(Textarea 한정 owner 승인, SCOPED_DESIGN_APPROVAL), 값 16/24/400, resize는 기본 vertical. Focus와 Error는 1.5px 테두리로 구분하며 shadow·glow를 쓰지 않습니다. Hover는 Default와 같습니다. Label·Helper·Error 문구는 Textarea가 아니라 소비 조합입니다. size 체계는 없습니다.",
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
resize: {
|
||||
control: "inline-radio",
|
||||
options: ["vertical", "none", "both"],
|
||||
description: "기본 vertical. Readonly에서는 항상 none.",
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Textarea>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Representative Story: every state on one page. Focus is placed by the play function. Named after the component, as in ui_viz.
|
||||
export const All: Story = {
|
||||
name: "Textarea",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Textarea"
|
||||
description="여러 줄 텍스트를 입력하는 컨트롤입니다. 최소 높이 112px, resize 기본 vertical이며 size 체계는 없습니다. Label·Helper·Error 문구는 소비 조합입니다."
|
||||
/>
|
||||
<StorySection title="Overview">
|
||||
<StateBoard states={[{ id: "default", label: "Default", children: <Specimen placeholder={placeholder} /> }]} />
|
||||
</StorySection>
|
||||
<StorySection title="States" description="Focus는 1.5px 테두리로 구분하며 shadow·glow를 쓰지 않습니다. Hover는 Default와 같습니다.">
|
||||
<StateBoard
|
||||
states={[
|
||||
{ id: "hover", label: "Hover (= Default)", note: hoverNote, noteAfter: true, children: <Specimen defaultValue="Hover over this field" /> },
|
||||
{ id: "focus", label: "Focus", children: <Specimen placeholder={placeholder} /> },
|
||||
{ id: "readonly", label: "Readonly", note: readonlyNote, noteAfter: true, children: <Specimen readOnly defaultValue="Confirmed description" /> },
|
||||
{ id: "disabled", label: "Disabled", children: <Specimen disabled defaultValue="Unavailable description" /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Validation" description="aria-invalid로 Error 테두리를 표시하고, 오류 문구는 소비 조합으로 연결합니다.">
|
||||
<StateBoard
|
||||
states={[
|
||||
{ id: "error", label: "Error", children: <Specimen error defaultValue="Invalid value" /> },
|
||||
{ id: "error-message", label: "Error Message", children: <Specimen error message="설명을 입력하세요." /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Usage" description="줄바꿈 유지와 resize 해제입니다.">
|
||||
<StateBoard
|
||||
states={[
|
||||
{ id: "multiline", label: "Multiline · Wrap", children: <Specimen defaultValue={multiline} /> },
|
||||
{ id: "resize", label: "Resize · none", children: <Specimen resize="none" defaultValue="This field cannot be resized." /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
// preventScroll: the page opens at its header, not at the States section.
|
||||
canvasElement.querySelector<HTMLTextAreaElement>('[data-overview-state="focus"] textarea')?.focus({ preventScroll: true })
|
||||
},
|
||||
}
|
||||
|
||||
// Verification Stories: the same specimens as separate Stories, hidden from the sidebar (tag `verification`), reachable by URL or the sidebar tag filter.
|
||||
export const Default: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => narrow(<Specimen placeholder={placeholder} />),
|
||||
}
|
||||
export const Focus: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => narrow(<Specimen placeholder={placeholder} />),
|
||||
play: async ({ canvasElement }) => canvasElement.querySelector("textarea")?.focus(),
|
||||
parameters: { docs: { description: { story: "Focus는 실제로 focus를 받은 컨트롤에서만 보입니다. play 함수가 focus를 줍니다. Tab 또는 클릭으로도 확인합니다." } } },
|
||||
}
|
||||
export const Error: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => narrow(<Specimen error defaultValue="Invalid value" />),
|
||||
}
|
||||
export const InvalidFocus: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => narrow(<Specimen error defaultValue="Invalid value" />),
|
||||
play: async ({ canvasElement }) => canvasElement.querySelector("textarea")?.focus(),
|
||||
}
|
||||
export const ErrorMessage: Story = {
|
||||
tags: ["verification"],
|
||||
name: "Error Message",
|
||||
render: () => narrow(<Specimen error message="설명을 입력하세요." />),
|
||||
}
|
||||
export const Required: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => narrow(<Specimen required />),
|
||||
}
|
||||
export const Readonly: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => narrow(<Specimen readOnly defaultValue="Confirmed description" />),
|
||||
parameters: { docs: { description: { story: readonlyNote } } },
|
||||
}
|
||||
export const Disabled: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => narrow(<Specimen disabled defaultValue="Unavailable description" />),
|
||||
}
|
||||
export const LongContent: Story = {
|
||||
tags: ["verification"],
|
||||
name: "Long Content",
|
||||
render: () =>
|
||||
narrow(
|
||||
<Specimen
|
||||
defaultValue={Array.from({ length: 12 }, (_, i) => `Line ${i + 1}: a long value keeps its line order and scrolls inside the control.`).join("\n")}
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
// HM only: not part of the ui_viz Textarea Stories. Tagged verification so the default sidebar keeps only the representative Story.
|
||||
export const Playground: Story = {
|
||||
tags: ["verification"],
|
||||
args: { resize: "vertical", placeholder, disabled: false, readOnly: false },
|
||||
argTypes: {
|
||||
placeholder: { control: "text" },
|
||||
disabled: { control: "boolean" },
|
||||
readOnly: { control: "boolean" },
|
||||
"aria-invalid": { control: "boolean" },
|
||||
className: { table: { disable: true } },
|
||||
},
|
||||
render: (args) => narrow(<Specimen {...args} />),
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useState } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { AlignCenterIcon, AlignLeftIcon, AlignRightIcon, BoldIcon, ItalicIcon, UnderlineIcon } from "lucide-react"
|
||||
import { ToggleGroup, ToggleGroupItem } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Toggle Group is a group of pressed-state items (toolbar-style), not a content switcher (that is Tabs). The representative Story
|
||||
// shows single / multiple / icon-only / disabled / vertical / spaced groups with real controls; the same specimens also exist as
|
||||
// Stories tagged `verification` (hidden from the sidebar). Icon-only items carry an `aria-label`.
|
||||
|
||||
function Single() {
|
||||
const [value, setValue] = useState("left")
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<ToggleGroup type="single" variant="outline" value={value} onValueChange={(v) => v && setValue(v)} aria-label="정렬">
|
||||
<ToggleGroupItem value="left">왼쪽</ToggleGroupItem>
|
||||
<ToggleGroupItem value="center">가운데</ToggleGroupItem>
|
||||
<ToggleGroupItem value="right">오른쪽</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<span className="text-sm text-muted-foreground" data-testid="single">{`value = ${value}`}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Multiple() {
|
||||
const [value, setValue] = useState<string[]>(["bold"])
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<ToggleGroup type="multiple" variant="outline" value={value} onValueChange={setValue} aria-label="서식">
|
||||
<ToggleGroupItem value="bold" aria-label="굵게">
|
||||
<BoldIcon />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="italic" aria-label="기울임">
|
||||
<ItalicIcon />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="underline" aria-label="밑줄">
|
||||
<UnderlineIcon />
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<span className="text-sm text-muted-foreground" data-testid="multiple">{`value = [${value.join(", ")}]`}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Disabled() {
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<ToggleGroup type="single" variant="outline" defaultValue="a" disabled aria-label="비활성 그룹">
|
||||
<ToggleGroupItem value="a">A</ToggleGroupItem>
|
||||
<ToggleGroupItem value="b">B</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<ToggleGroup type="single" variant="outline" defaultValue="a" aria-label="비활성 항목">
|
||||
<ToggleGroupItem value="a">A</ToggleGroupItem>
|
||||
<ToggleGroupItem value="b" disabled>
|
||||
B (비활성)
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="c">C</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Vertical() {
|
||||
return (
|
||||
<ToggleGroup type="single" variant="outline" orientation="vertical" defaultValue="left" aria-label="세로 정렬">
|
||||
<ToggleGroupItem value="left" aria-label="왼쪽">
|
||||
<AlignLeftIcon />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="center" aria-label="가운데">
|
||||
<AlignCenterIcon />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="right" aria-label="오른쪽">
|
||||
<AlignRightIcon />
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
)
|
||||
}
|
||||
|
||||
function Spaced() {
|
||||
return (
|
||||
<ToggleGroup type="multiple" variant="outline" spacing={2} aria-label="간격 있는 그룹">
|
||||
<ToggleGroupItem value="a">첫째</ToggleGroupItem>
|
||||
<ToggleGroupItem value="b">둘째</ToggleGroupItem>
|
||||
<ToggleGroupItem value="c">셋째</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
)
|
||||
}
|
||||
|
||||
function Many() {
|
||||
return (
|
||||
<ToggleGroup type="multiple" variant="outline" spacing={1} className="flex-wrap" aria-label="많은 항목">
|
||||
{Array.from({ length: 10 }, (_, i) => (
|
||||
<ToggleGroupItem key={i} value={`${i}`}>{`옵션 ${i + 1}`}</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
)
|
||||
}
|
||||
|
||||
function Sizes() {
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
{(["sm", "default", "lg"] as const).map((size) => (
|
||||
<ToggleGroup key={size} type="multiple" size={size} variant="outline" aria-label={`${size} content kinds`}>
|
||||
<ToggleGroupItem value="icon" aria-label={`${size} icon-only`}><BoldIcon /></ToggleGroupItem>
|
||||
<ToggleGroupItem value="mixed"><BoldIcon />굵게</ToggleGroupItem>
|
||||
<ToggleGroupItem value="text">{size}</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const sections = [
|
||||
{ id: "single", label: "Single selection", node: <Single /> },
|
||||
{ id: "multiple", label: "Multiple selection (icon-only)", node: <Multiple /> },
|
||||
{ id: "sizes", label: "Sizes · Icon-only / Icon + Text / Text-only", node: <Sizes /> },
|
||||
{ id: "disabled", label: "Disabled group / item", node: <Disabled /> },
|
||||
{ id: "vertical", label: "Vertical", node: <Vertical /> },
|
||||
{ id: "spaced", label: "spacing={2} (separated)", node: <Spaced /> },
|
||||
{ id: "many", label: "Many items (spacing + flex-wrap, consumer opts in)", node: <Many /> },
|
||||
]
|
||||
|
||||
// No `component`: ToggleGroup props are a union (single | multiple), so Stories without args would not type-check.
|
||||
const meta = { title: "Components/Form/Toggle Group", parameters: { layout: "padded" } } satisfies Meta
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const board = (...ids: string[]) => (
|
||||
<StateBoard min={360} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} />
|
||||
)
|
||||
|
||||
// Representative Story: the common Story page; the sections group the same specimens.
|
||||
export const All: Story = {
|
||||
name: "Toggle Group",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Toggle Group"
|
||||
description="눌림 상태를 갖는 항목 묶음(툴바형)입니다. 내용을 전환하는 용도는 Tabs를 씁니다."
|
||||
/>
|
||||
<StorySection title="Overview">{board("single")}</StorySection>
|
||||
<StorySection title="Sizes" description="Icon-only / Icon + Text / Text-only.">
|
||||
<Sizes />
|
||||
</StorySection>
|
||||
<StorySection title="Variants" description="다중 선택, 세로 방향, 항목 간격(spacing)입니다.">{board("multiple", "vertical", "spaced")}</StorySection>
|
||||
<StorySection title="States">{board("disabled")}</StorySection>
|
||||
<StorySection title="Usage">{board("many")}</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const SingleSelection: Story = { tags: ["verification"], name: "Single", render: () => <Single /> }
|
||||
export const MultipleSelection: Story = { tags: ["verification"], name: "Multiple", render: () => <Multiple /> }
|
||||
export const VerticalGroup: Story = { tags: ["verification"], name: "Vertical", render: () => <Vertical /> }
|
||||
|
||||
export const ContentNodes: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => (
|
||||
<ToggleGroup type="multiple" variant="outline" aria-label="Content nodes">
|
||||
<ToggleGroupItem value="array" aria-label="Array icon">{[<BoldIcon key="icon" />]}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="fragment" aria-label="Fragment icon"><><BoldIcon /></></ToggleGroupItem>
|
||||
<ToggleGroupItem value="mixed"><><BoldIcon />굵게</></ToggleGroupItem>
|
||||
<ToggleGroupItem value="span"><BoldIcon /><span>굵게</span></ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
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 { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Toggle is a persistent pressed-state control. The representative Story shows each state with real, clickable toggles; the same
|
||||
// specimens also exist as Stories tagged `verification` (hidden from the sidebar). Icon-only toggles carry an `aria-label`.
|
||||
|
||||
function OffOn() {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Toggle>Off</Toggle>
|
||||
<Toggle defaultPressed>On</Toggle>
|
||||
<Toggle variant="outline">Outline off</Toggle>
|
||||
<Toggle variant="outline" defaultPressed>
|
||||
Outline on
|
||||
</Toggle>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WithIcon() {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Toggle aria-label="굵게">
|
||||
<BoldIcon />
|
||||
</Toggle>
|
||||
<Toggle aria-label="기울임" defaultPressed>
|
||||
<ItalicIcon />
|
||||
</Toggle>
|
||||
<Toggle variant="outline">
|
||||
<StarIcon />
|
||||
즐겨찾기
|
||||
</Toggle>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Sizes() {
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
{(["sm", "default", "lg"] as const).map((size) => (
|
||||
<div key={size} data-toggle-size={size} className="flex flex-wrap items-center gap-3">
|
||||
<Toggle size={size} variant="outline" aria-label={`${size} icon-only`}><StarIcon /></Toggle>
|
||||
<Toggle size={size} variant="outline"><StarIcon />즐겨찾기</Toggle>
|
||||
<Toggle size={size} variant="outline">{size}</Toggle>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Disabled() {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Toggle disabled>Disabled off</Toggle>
|
||||
<Toggle disabled defaultPressed>
|
||||
Disabled on
|
||||
</Toggle>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Controlled() {
|
||||
const [pressed, setPressed] = useState(false)
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<Toggle variant="outline" pressed={pressed} onPressedChange={setPressed}>
|
||||
알림
|
||||
</Toggle>
|
||||
<span className="text-sm text-muted-foreground" data-testid="controlled">{`pressed = ${String(pressed)}`}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// className extension: the consumer's width / rounded utilities win over the default look.
|
||||
function Extended() {
|
||||
return (
|
||||
<Toggle variant="outline" className="w-40 rounded-full">
|
||||
w-40 rounded-full
|
||||
</Toggle>
|
||||
)
|
||||
}
|
||||
|
||||
const sections = [
|
||||
{ id: "offon", label: "Off / On", node: <OffOn /> },
|
||||
{ id: "icon", label: "Icon-only and icon + text", node: <WithIcon /> },
|
||||
{ id: "sizes", label: "Sizes", node: <Sizes /> },
|
||||
{ id: "disabled", label: "Disabled", node: <Disabled /> },
|
||||
{ id: "controlled", label: "Controlled", node: <Controlled /> },
|
||||
{ id: "extended", label: "className extension", node: <Extended /> },
|
||||
]
|
||||
|
||||
const meta = { title: "Components/Form/Toggle", component: Toggle, parameters: { layout: "padded" } } satisfies Meta<typeof Toggle>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const board = (...ids: string[]) => (
|
||||
<StateBoard min={360} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} />
|
||||
)
|
||||
|
||||
// Representative Story: the common Story page; the sections group the same specimens.
|
||||
export const All: Story = {
|
||||
name: "Toggle",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Toggle"
|
||||
description="눌림(On) 상태를 유지하는 단일 버튼입니다."
|
||||
/>
|
||||
<StorySection title="Overview">{board("offon")}</StorySection>
|
||||
<StorySection title="Sizes">
|
||||
<Sizes />
|
||||
</StorySection>
|
||||
<StorySection title="Variants" description="아이콘만, 아이콘 + 텍스트입니다.">{board("icon")}</StorySection>
|
||||
<StorySection title="States">{board("disabled", "controlled")}</StorySection>
|
||||
<StorySection title="Usage" description="className 확장입니다.">{board("extended")}</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const States: Story = { tags: ["verification"], render: () => <OffOn /> }
|
||||
export const IconOnly: Story = { tags: ["verification"], name: "Icon Only", render: () => <WithIcon /> }
|
||||
export const DisabledToggle: Story = { tags: ["verification"], name: "Disabled", render: () => <Disabled /> }
|
||||
|
||||
export const ContentNodes: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Toggle aria-label="Array icon">{[<StarIcon key="icon" />]}</Toggle>
|
||||
<Toggle aria-label="Fragment icon"><><StarIcon /></></Toggle>
|
||||
<Toggle><><StarIcon />즐겨찾기</></Toggle>
|
||||
<Toggle><StarIcon /><span>즐겨찾기</span></Toggle>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@hm-design/ui"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Tooltip is a Radix overlay (Portal) with short, non-interactive text. The representative Story shows real triggers
|
||||
// (hover or focus to open); the verification Story starts one open. Interactive content belongs in Popover / Hover Card.
|
||||
|
||||
const longText = "긴 설명은 최대 폭에서 줄바꿈됩니다. 이 정도 길이가 Tooltip의 한계이며, 더 긴 내용은 Popover나 Hover Card를 사용합니다."
|
||||
|
||||
function Basic({ defaultOpen }: { defaultOpen?: boolean }) {
|
||||
return (
|
||||
<Tooltip defaultOpen={defaultOpen}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="primary" appearance="outline">
|
||||
기본
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>저장하지 않은 변경 사항이 있습니다</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function Placements() {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{(["top", "right", "bottom", "left"] as const).map((side) => (
|
||||
<Tooltip key={side}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="primary" appearance="outline" size="sm">
|
||||
{side}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={side}>{`side="${side}"`}</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LongText() {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="primary" appearance="outline">
|
||||
긴 텍스트
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{longText}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function Disabled() {
|
||||
// A disabled button gets no pointer events, so the trigger is a wrapper element.
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span tabIndex={0} className="inline-block">
|
||||
<Button variant="primary" appearance="solid" disabled>
|
||||
비활성
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>권한이 없어 사용할 수 없습니다</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = { title: "Components/Overlay/Tooltip", component: Tooltip, parameters: { layout: "centered" } } satisfies Meta<typeof Tooltip>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const All: Story = {
|
||||
name: "Tooltip",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<TooltipProvider>
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Tooltip"
|
||||
description="짧고 상호작용 없는 설명을 띄우는 overlay입니다. Trigger에 포인터를 올리거나 포커스하면 열립니다."
|
||||
/>
|
||||
<StorySection title="Overview" description="기본 Tooltip입니다.">
|
||||
<Basic />
|
||||
</StorySection>
|
||||
<StorySection title="Variants" description="side: top / right / bottom / left.">
|
||||
<Placements />
|
||||
</StorySection>
|
||||
<StorySection title="Usage" description="긴 문구는 줄바꿈됩니다. Disabled 버튼은 포인터 이벤트가 없으므로 감싼 요소를 Trigger로 씁니다.">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<LongText />
|
||||
<Disabled />
|
||||
</div>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
</TooltipProvider>
|
||||
),
|
||||
}
|
||||
|
||||
export const Open: Story = {
|
||||
tags: ["verification"],
|
||||
parameters: { layout: "fullscreen" },
|
||||
render: () => (
|
||||
<div className="flex min-h-[200px] items-end justify-center p-8">
|
||||
<Basic defaultOpen />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/* Export-only document defaults. Component and Story CSS are imported from the
|
||||
existing React entry; this file must not recreate their visual language. */
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
min-width: 320px;
|
||||
}
|
||||
@@ -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 · Dashboard Export</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,17 @@
|
||||
import { StrictMode } from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import { DashboardScreen } from "@/patterns/dashboard-composition"
|
||||
import "@/index.css"
|
||||
import "@/hm-theme.css"
|
||||
import "@hm-design/ui/dashboard-status.css"
|
||||
import "./dashboard-export.css"
|
||||
|
||||
const root = document.getElementById("root")
|
||||
|
||||
if (!root) throw new Error("Dashboard export root was not found")
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<DashboardScreen mode="compact" />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { allColorProperties, componentColors, primitiveColors, semanticColors, type ColorReference } from "./reference-data"
|
||||
import { ReferencePage, ReferenceSection, useResolvedProperties } 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>
|
||||
}
|
||||
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>
|
||||
</ReferencePage>
|
||||
}
|
||||
const meta = { title: "Foundation/Color", parameters: { layout: "fullscreen" } } satisfies Meta
|
||||
export default meta
|
||||
export const All: StoryObj<typeof meta> = { name: "Color", render: () => <ColorReferencePage /> }
|
||||
Vendored
+28
@@ -0,0 +1,28 @@
|
||||
/* Storybook presentation only. All selectors stay inside the Foundation page.
|
||||
Page inner, title and sections come from the common Story page (src/components/story-page.css); these rules style the reference content. */
|
||||
.hm-foundation {
|
||||
font-size: var(--hm-label-font-size); line-height: var(--hm-label-line-height);
|
||||
font-weight: var(--hm-input-font-weight); overflow-wrap: anywhere;
|
||||
}
|
||||
.hm-foundation *, .hm-foundation *::before, .hm-foundation *::after { box-sizing: border-box; }
|
||||
.hm-foundation h3 { margin: 0 0 8px; font-size: var(--hm-label-font-size); font-weight: var(--hm-font-weight-bold); }
|
||||
.hm-foundation p { margin: 0 0 12px; }
|
||||
.hm-foundation-note { color: var(--hm-sem-fg-secondary); }
|
||||
.hm-foundation-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 270px), 1fr)); gap: 16px; }
|
||||
.hm-foundation-card, .hm-foundation-panel { min-width: 0; padding: 16px; border: 1px solid var(--hm-sem-stroke-input-default); border-radius: var(--hm-radius-container); background: var(--hm-sem-bg-surface); }
|
||||
.hm-foundation-swatch { height: 64px; margin-bottom: 16px; border: 1px solid var(--hm-sem-stroke-input-default); border-radius: var(--hm-radius-control-md); }
|
||||
.hm-foundation dl { margin: 16px 0 0; }
|
||||
.hm-foundation dt { margin-top: 8px; color: var(--hm-sem-fg-secondary); font-size: var(--hm-button-font-size-sm); }
|
||||
.hm-foundation dd { margin: 2px 0 0; min-width: 0; }
|
||||
.hm-foundation code { font-family: inherit; font-size: inherit; white-space: normal; overflow-wrap: anywhere; }
|
||||
.hm-foundation-links { display: flex; flex-wrap: wrap; gap: 16px; padding-top: 16px; }
|
||||
.hm-foundation-links a { color: var(--hm-sem-action-primary); text-decoration: underline; }
|
||||
.hm-foundation-details { margin-top: 24px; }
|
||||
.hm-foundation-details summary { cursor: pointer; padding-block: 12px; font-weight: var(--hm-font-weight-bold); }
|
||||
.hm-foundation-specimens { display: grid; gap: 16px; }
|
||||
.hm-foundation-specimen { margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--hm-sem-stroke-input-default); }
|
||||
.hm-foundation-specimen small { display: block; font-size: var(--hm-button-font-size-sm); line-height: var(--hm-button-line-height-sm); font-weight: var(--hm-input-font-weight); }
|
||||
.hm-foundation-spacing { max-width: 100%; height: 24px; margin-block: 12px; background: var(--hm-sem-state-active); }
|
||||
.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); }
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { layoutProperties, radiusReferences, spacingReferences } from "./reference-data"
|
||||
import { ReferencePage, ReferenceSection, useResolvedProperties } from "./reference"
|
||||
|
||||
function LayoutReferencePage() {
|
||||
const values = useResolvedProperties(layoutProperties)
|
||||
const [desktop, setDesktop] = useState(() => matchMedia("(min-width: 1024px)").matches)
|
||||
useEffect(() => { const media = matchMedia("(min-width: 1024px)"); const update = () => setDesktop(media.matches); media.addEventListener("change", update); return () => media.removeEventListener("change", update) }, [])
|
||||
return <ReferencePage title="Layout" intro="현재 HM의 responsive boundary와 컴포넌트별 layout geometry를 확인합니다. 전역 spacing scale이나 grid system을 새로 정의하지 않습니다.">
|
||||
<ReferenceSection title="Responsive Boundary" note="CURRENT_STANDARD · gnb.css / lnb.css / dashboard-layout.css 및 GNB의 matchMedia 규칙에 근거합니다."><div className="hm-foundation-grid"><article className="hm-foundation-panel"><h3>≥ 1024px · Desktop</h3><p>Desktop Navigation · LNB 표시 · GNB desktop menu</p></article><article className="hm-foundation-panel"><h3>≤ 1023px · Mobile</h3><p>Mobile Navigation · LNB 숨김 · GNB mobile menu</p></article></div><p role="status">현재 preview: <strong>{desktop ? "Desktop" : "Mobile"}</strong> · GNB 높이 {values["--hm-gnb-height"]} · LNB track {values["--hm-lnb-width"]}</p><p className="hm-foundation-note">CSS는 min-width: 1024px / max-width: 1023px, GNB JS는 min-width: 1024px를 사용합니다. 표시는 기존 정수 QA 경계를 따릅니다.</p></ReferenceSection>
|
||||
<ReferenceSection title="QA Reference Viewports" note="1280 / 390은 검증용 viewport이며 breakpoint token이 아닙니다."><div className="hm-foundation-grid"><div className="hm-foundation-panel"><h3>1280 × 900</h3><p>HM Desktop · QA reference</p></div><div className="hm-foundation-panel"><h3>390 × 844</h3><p>HM Mobile · QA reference</p></div></div><p className="hm-foundation-note">Boundary QA presets: 1024 × 900 / 1023 × 900</p></ReferenceSection>
|
||||
<ReferenceSection title="Spacing · Current Usage Reference" note="전역 공식 spacing scale은 정의되어 있지 않습니다. 아래 변수는 각 pattern/component의 현재 사용값이며 동일한 숫자를 하나의 token으로 합치지 않습니다."><div className="hm-foundation-grid">{spacingReferences.map(([property, name, source]) => <article className="hm-foundation-card" key={property}><h3>{name}</h3><code>{property}</code><p data-layout-value>{values[property]}</p><div className="hm-foundation-spacing" style={{ width: `var(${property})` }} aria-hidden="true" /><p className="hm-foundation-note">packages/ui/src/theme/{source}</p></article>)}</div></ReferenceSection>
|
||||
<ReferenceSection title="Radius" note="기존 default.css의 geometry variables입니다. 역할이 다른 같은 반경은 따로 표시합니다."><div className="hm-foundation-grid">{radiusReferences.map(property => <article className="hm-foundation-card" key={property}><div className="hm-foundation-radius" style={{ borderRadius: `var(${property})` }} /><code>{property}</code><p>{values[property]}</p></article>)}</div></ReferenceSection>
|
||||
<ReferenceSection title="Layout / Container Reference" note="CURRENT_USAGE_REFERENCE · Dashboard의 가용 폭, 외곽 padding, 섹션 gap과 Card padding만 축소 specimen으로 보여줍니다."><div className="hm-foundation-layout-sample" style={{ padding: "var(--hm-dashboard-padding)", gap: "var(--hm-dashboard-gap)" }}><div className="hm-foundation-layout-card" style={{ padding: "var(--hm-card-padding)" }}><strong>Dashboard content</strong><p>가용 폭을 사용하며 긴 텍스트는 내부에서 줄바꿈합니다.</p></div><div className="hm-foundation-layout-card" style={{ padding: "var(--hm-card-padding)" }}>다음 섹션 · gap: {values["--hm-dashboard-gap"]}</div></div><p className="hm-foundation-note">Grid / Container: NOT_FORMALLY_DEFINED · 공용 column 수 / max-width token은 FOLLOW_UP. src/hm-theme.css의 .hm-container는 앱 데모의 로컬 사용 규칙입니다.</p></ReferenceSection>
|
||||
</ReferencePage>
|
||||
}
|
||||
const meta = { title: "Foundation/Layout", parameters: { layout: "fullscreen" } } satisfies Meta
|
||||
export default meta
|
||||
export const All: StoryObj<typeof meta> = { name: "Layout", render: () => <LayoutReferencePage /> }
|
||||
@@ -0,0 +1,65 @@
|
||||
import { runtimeThemeContract } from "@hm-design/ui/theme"
|
||||
import primitives from "../../packages/ui/src/theme/primitives.css?raw"
|
||||
import defaults from "../../packages/ui/src/theme/default.css?raw"
|
||||
import input from "../../packages/ui/src/theme/input.css?raw"
|
||||
import checkbox from "../../packages/ui/src/theme/checkbox.css?raw"
|
||||
import button from "../../packages/ui/src/theme/button.css?raw"
|
||||
import card from "../../packages/ui/src/theme/card.css?raw"
|
||||
|
||||
// Read the existing source, rather than maintain a second palette in Storybook.
|
||||
export function declarations(source: string): Record<string, string> {
|
||||
return Object.fromEntries([...source.matchAll(/^\s*(--[\w-]+):\s*([^;]+);/gm)].map(match => [match[1], match[2].trim()]))
|
||||
}
|
||||
export const defaultDeclarations = declarations(defaults)
|
||||
export type ColorReference = { property: string; label: string; source: string; declared: string; runtimeKey?: string; defaultValue?: string; usage: string }
|
||||
const label = (property: string) => property.replace(/^--hm-(sem-)?/, "").replaceAll("-", " ")
|
||||
const usages: Record<string, string> = {
|
||||
"color.action.primary": "Primary 동작 · 현재 위치 · 내비게이션",
|
||||
"color.state.active": "Checked / Selected / On 컨트롤의 면",
|
||||
"color.on.state.active": "선택된 컨트롤의 글자와 아이콘",
|
||||
"color.stroke.input.focus": "Input Focus · Select Open · 컨트롤 포커스 링",
|
||||
"color.accent": "메뉴 · Calendar · Table 강조 면의 기준 색",
|
||||
"color.bg.content": "콘텐츠 영역의 배경",
|
||||
"color.bg.surface": "입력 · Card · Popup 표면",
|
||||
"color.action.destructive": "오류 · Invalid · Destructive 동작",
|
||||
}
|
||||
export const primitiveColors: ColorReference[] = Object.entries(declarations(primitives)).map(([property, declared]) => ({ property, declared, label: label(property), source: "packages/ui/src/theme/primitives.css", usage: "색상 자체를 나타내는 내부 Primitive · Runtime Key 없음" }))
|
||||
const publicByProperty = new Map(Object.entries(runtimeThemeContract).map(([key, definition]) => [definition.property as string, { key, ...definition }]))
|
||||
export const semanticColors: ColorReference[] = Object.entries(defaultDeclarations).filter(([property]) => property.startsWith("--hm-sem-") && !/-(width|radius|shadow)$/.test(property)).map(([property, declared]) => {
|
||||
const definition = publicByProperty.get(property)
|
||||
return { property, declared, label: label(property), runtimeKey: definition?.key, defaultValue: definition?.defaultValue, source: "packages/ui/src/theme/default.css · contract.ts", usage: definition ? usages[definition.key] ?? (definition.key.startsWith("color.palette.") ? "Runtime에서 조정 가능한 palette alias · Primitive와 별도 계층" : definition.key.includes("stop.") ? "Gradient stop · 위치와 geometry는 기존 CSS가 소유" : "현재 Runtime Theme contract의 Semantic 역할") : "내부 Semantic / 파생 값 · 공개 Runtime Key 없음" }
|
||||
})
|
||||
const componentSources = { input, checkbox, button, card }
|
||||
const mappings = [
|
||||
["input", "--hm-input-background", "Input Background", "입력 표면"],
|
||||
["input", "--hm-input-focus-border-color", "Input Focus Border", "공통 Focus 역할을 사용하는 입력 테두리"],
|
||||
["input", "--hm-input-error-border-color", "Input Error Border", "Destructive 역할을 사용하는 오류 테두리"],
|
||||
["checkbox", "--hm-checkbox-checked-background", "Checkbox Checked Background", "Active 역할을 사용하는 선택 면"],
|
||||
["checkbox", "--hm-checkbox-indicator-color", "Checkbox Indicator", "On Active 역할을 사용하는 선택 아이콘"],
|
||||
["button", "--hm-button-outline-green-ink", "Button Primary Outline Ink", "Primary 역할을 사용하는 Outline 글자"],
|
||||
["button", "--hm-button-brand-green-background", "Button Brand Background", "기존 4-stop action surface gradient"],
|
||||
["card", "--hm-card-surface", "Card Surface", "Semantic Surface를 소비하는 Card 표면"],
|
||||
] as const
|
||||
export const componentColors: ColorReference[] = mappings.map(([component, property, name, usage]) => ({ property, label: name, usage, source: `packages/ui/src/theme/${component}.css`, declared: declarations(componentSources[component])[property] }))
|
||||
export const allColorProperties = [...primitiveColors, ...semanticColors, ...componentColors].map(item => item.property)
|
||||
|
||||
// These are existing component-local metrics, not new Heading / Body tokens.
|
||||
export const typographyReferences = [
|
||||
{ label: "Page Header · title", source: "page-header.css", size: "--hm-page-header-title-font-size", line: "--hm-page-header-title-line-height", weight: "--hm-page-header-title-font-weight" },
|
||||
{ label: "Card · title", source: "card.css", size: "--hm-card-title-font-size", line: "--hm-card-title-line-height", weight: "--hm-card-title-font-weight" },
|
||||
{ label: "Page Header · description", source: "page-header.css", size: "--hm-page-header-description-font-size", line: "--hm-page-header-description-line-height" },
|
||||
{ label: "Label", source: "label.css", size: "--hm-label-font-size", line: "--hm-label-line-height", weight: "--hm-label-font-weight" },
|
||||
...["sm", "md", "lg"].map(size => ({ label: `Input · ${size}`, source: "input.css", size: `--hm-input-font-size-${size}`, line: `--hm-input-line-height-${size}`, weight: "--hm-input-font-weight" })),
|
||||
...["sm", "md", "lg"].map(size => ({ label: `Button Standard · ${size}`, source: "button.css", size: `--hm-button-font-size-${size}`, line: `--hm-button-line-height-${size}`, weight: "--hm-button-weight-standard" })),
|
||||
]
|
||||
export const typographyProperties = ["--hm-font-family-base", "--hm-font-weight-medium", "--hm-font-weight-bold", "--hm-card-title-font-weight", "--hm-input-font-weight", ...typographyReferences.flatMap(item => [item.size, item.line, ...(item.weight ? [item.weight] : [])])]
|
||||
export const spacingReferences = [
|
||||
["--hm-dashboard-padding", "Dashboard 외곽 여백", "dashboard-layout.css · Desktop 34px / Mobile 20px"],
|
||||
["--hm-dashboard-gap", "Dashboard 섹션 사이", "dashboard-layout.css"],
|
||||
["--hm-card-padding", "Card 안쪽 여백", "card.css"],
|
||||
["--hm-page-header-margin-bottom", "Page Header → 첫 콘텐츠", "page-header.css"],
|
||||
["--hm-gnb-join", "GNB → 콘텐츠 연결", "gnb.css"],
|
||||
["--hm-lnb-join", "LNB → 콘텐츠 연결", "lnb.css"],
|
||||
] as const
|
||||
export const radiusReferences = ["--hm-radius-control-md", "--hm-radius-button", "--hm-radius-input", "--hm-radius-select", "--hm-radius-container"]
|
||||
export const layoutProperties = [...spacingReferences.map(([property]) => property), ...radiusReferences, "--hm-gnb-height", "--hm-lnb-width"]
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
import "./foundation.css"
|
||||
|
||||
export function useResolvedProperties(properties: readonly string[]) {
|
||||
const [values, setValues] = useState<Record<string, string>>({})
|
||||
useEffect(() => {
|
||||
let frame = 0
|
||||
const read = () => {
|
||||
cancelAnimationFrame(frame)
|
||||
frame = requestAnimationFrame(() => {
|
||||
const style = getComputedStyle(document.documentElement)
|
||||
const next = Object.fromEntries(properties.map(property => [property, style.getPropertyValue(property).trim()]))
|
||||
setValues(previous => properties.every(property => previous[property] === next[property]) ? previous : next)
|
||||
})
|
||||
}
|
||||
read()
|
||||
const observer = new MutationObserver(read)
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["style", "class", "data-hm-theme"] })
|
||||
window.addEventListener("resize", read)
|
||||
return () => { observer.disconnect(); window.removeEventListener("resize", read); cancelAnimationFrame(frame) }
|
||||
}, [properties])
|
||||
return values
|
||||
}
|
||||
|
||||
// The page frame, title and sections are the common Story page (src/components/story-page.tsx); .hm-foundation styles the reference content.
|
||||
export function ReferencePage({ title, intro, children }: { title: string; intro: string; children: ReactNode }) {
|
||||
return <StoryPage className="hm-foundation"><StoryPageHeader kicker={STORY_KICKER.foundation} title={title} description={intro} />{children}</StoryPage>
|
||||
}
|
||||
export function ReferenceSection({ title, note, children, id }: { title: string; note: string; children: ReactNode; id?: string }) {
|
||||
return <StorySection id={id} title={title} description={note}>{children}</StorySection>
|
||||
}
|
||||
export function Specimen({ label, size, line, weight }: { label: string; size: string; line: string; weight?: string }) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [metrics, setMetrics] = useState("")
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
void document.fonts.ready.then(() => {
|
||||
if (!mounted || !ref.current) return
|
||||
const style = getComputedStyle(ref.current)
|
||||
setMetrics(`${style.fontSize} / ${style.lineHeight} / ${style.fontWeight} · letter-spacing: ${style.letterSpacing}`)
|
||||
})
|
||||
return () => { mounted = false }
|
||||
}, [])
|
||||
return <div className="hm-foundation-specimen" ref={ref} data-typography-sample={label} style={{ fontSize: `var(${size})`, lineHeight: `var(${line})`, fontWeight: weight ? `var(${weight})` : "normal" }}><p lang="ko">가나다라마바사 아자차카타파하 · 한맥 프로젝트</p><p lang="en">ABCDEFGHIJKLMNOPQRSTUVWXYZ<br />abcdefghijklmnopqrstuvwxyz</p><p>0123456789</p><small className="hm-foundation-note" data-typography-metrics>{metrics}</small></div>
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { typographyProperties, typographyReferences } from "./reference-data"
|
||||
import { ReferencePage, ReferenceSection, Specimen, useResolvedProperties } from "./reference"
|
||||
|
||||
const weights = ["--hm-input-font-weight", "--hm-font-weight-medium", "--hm-card-title-font-weight", "--hm-font-weight-bold"]
|
||||
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 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>
|
||||
}
|
||||
const meta = { title: "Foundation/Typography", parameters: { layout: "fullscreen" } } satisfies Meta
|
||||
export default meta
|
||||
export const All: StoryObj<typeof meta> = { name: "Typography", render: () => <TypographyReferencePage /> }
|
||||
@@ -0,0 +1,83 @@
|
||||
@import "@hm-design/ui/theme.css";
|
||||
|
||||
:root {
|
||||
/* Existing preview artwork keeps its own fixed colors. Shared controls use Semantic values. */
|
||||
--hm-green: var(--hm-sem-action-primary);
|
||||
--hm-green-light: #205241;
|
||||
--hm-green-deep: #0a221d;
|
||||
--hm-brown: #3e3523;
|
||||
--hm-beige: var(--hm-sem-bg-content);
|
||||
--hm-ink: var(--hm-sem-fg-primary);
|
||||
--hm-muted: var(--hm-sem-fg-secondary);
|
||||
--hm-field-border: var(--hm-sem-stroke-input-default);
|
||||
--hm-danger: var(--hm-sem-action-destructive);
|
||||
}
|
||||
|
||||
html, body, button, input { font-family: var(--hm-font-family-base); }
|
||||
html { scroll-behavior: smooth; }
|
||||
body { margin: 0; }
|
||||
.hm-app { min-width: 320px; }
|
||||
.hm-container { width: min(1180px, calc(100% - 48px)); margin-inline: auto; }
|
||||
.hm-topbar { background: var(--hm-sem-bg-brand); color: #fff; border-bottom: 1px solid #31594c; padding: 15px 0; }
|
||||
.hm-mark { display: grid; place-items: center; width: 42px; height: 42px; border: 1px solid #80b6a3; border-radius: 4px; background: #174b3a; font-size: 16px; font-weight: 800; letter-spacing: -.08em; }
|
||||
.hm-brand-name { margin: 0; font-size: 15px; font-weight: 700; letter-spacing: -.02em; }
|
||||
.hm-brand-caption { margin: 2px 0 0; color: #bddfd4; font-size: 11px; }
|
||||
.hm-topbar-status { display: inline-flex; align-items: center; gap: 7px; color: #c6d8d1; font-size: 12px; white-space: nowrap; }
|
||||
.hm-topbar-status span { width: 7px; height: 7px; border-radius: 50%; background: #67d6ac; box-shadow: 0 0 0 4px #67d6ac22; }
|
||||
.hm-breadcrumb { color: #827b70; }
|
||||
.hm-breadcrumb strong { color: var(--hm-green); font-weight: 600; }
|
||||
.hm-hero { position: relative; overflow: hidden; min-height: 330px; border: 1px solid #254d40; border-radius: 4px; background: var(--hm-sem-bg-brand); color: #fff; }
|
||||
.hm-hero::after { content: ""; position: absolute; width: 590px; height: 590px; right: -240px; top: -175px; border: 1px solid #ffffff24; border-radius: 50%; box-shadow: 0 0 0 72px #ffffff09,0 0 0 145px #ffffff06; }
|
||||
.hm-eyebrow { display: inline-flex; align-items: center; gap: 7px; color: #c3e6d7; font-size: 11px; font-weight: 700; letter-spacing: .13em; }
|
||||
.hm-hero h1 { margin: 20px 0 17px; font-size: clamp(32px,4vw,49px); line-height: 1.27; letter-spacing: -.055em; font-weight: 700; }
|
||||
.hm-hero h1 span { color: #d9eac3; }
|
||||
.hm-hero-copy { max-width: 600px; margin: 0; color: #d5e4dd; font-size: 14px; line-height: 1.8; word-break: keep-all; }
|
||||
.hm-hero-link { display: inline-flex; align-items: center; gap: 9px; margin-top: 26px; border-bottom: 1px solid #b6d8c8; padding-bottom: 5px; color: #f7fff9; font-size: 13px; font-weight: 700; text-decoration: none; }
|
||||
.hm-hero-link:hover { color: #d9eac3; }
|
||||
.hm-hero-art { position: relative; z-index: 1; display: flex; align-items: center; justify-content: center; min-height: 210px; }
|
||||
.hm-hero-art-line { position: absolute; width: 195px; height: 195px; border: 1px solid #a6d0bc80; transform: rotate(17deg); }
|
||||
.hm-hero-art-square { display: grid; place-items: center; width: 152px; height: 152px; border: 1px solid #c3e6d799; border-radius: 8px; background: var(--hm-sem-bg-brand); box-shadow: 16px 21px 0 #001a1255; color: #d8ecda; transform: rotate(-8deg); }
|
||||
.hm-hero-art-pill { position: absolute; bottom: 15px; right: 2px; display: flex; gap: 7px; border: 1px solid #8ab6a7; border-radius: 30px; background: #174335; padding: 10px 13px; }
|
||||
.hm-hero-art-pill span { width: 7px; height: 7px; border-radius: 50%; background: #c8e4d5; }
|
||||
.hm-hero-art-pill span:nth-child(2) { background: #74bd98; }
|
||||
.hm-hero-art-pill span:nth-child(3) { background: #477b5a; }
|
||||
.hm-section-kicker { margin: 0 0 6px; color: #6b715f; font-size: 11px; font-weight: 800; letter-spacing: .16em; }
|
||||
.hm-section-heading h2 { margin: 0; color: var(--hm-ink); font-size: 26px; font-weight: 700; letter-spacing: -.04em; }
|
||||
.hm-section-heading p:not(.hm-section-kicker) { margin: 7px 0 0; color: var(--hm-muted); font-size: 13px; }
|
||||
.hm-count { border: 1px solid #d4cfc2; border-radius: 20px; padding: 6px 10px; color: #6e6759; font-size: 11px; white-space: nowrap; }
|
||||
.hm-panel { gap: 0; --card-spacing: 16px; }
|
||||
.hm-panel-header { display: grid; grid-template-columns: 34px 1fr; column-gap: 11px; align-items: center; border-bottom: 1px solid #ede9df; padding: 22px 23px 17px; }
|
||||
.hm-panel-icon { grid-row: span 2; display: grid; place-items: center; width: 34px; height: 34px; border-radius: 4px; background: #e7f1ec; color: var(--hm-green); }
|
||||
.hm-panel-header [data-slot="card-title"] { font-size: 15px; line-height: 1.35; font-weight: 700; letter-spacing: -.02em; }
|
||||
.hm-panel-header [data-slot="card-description"] { margin-top: 2px; color: var(--hm-muted); font-size: 11px; }
|
||||
.hm-panel-content { padding: 23px; }
|
||||
.hm-control-row { display: flex; flex-wrap: wrap; align-items: center; gap: 9px; }
|
||||
.hm-component-note { margin: 20px 0 0; color: #7b766e; font-size: 11px; line-height: 1.6; }
|
||||
.hm-swatch-grid { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 11px; }
|
||||
.hm-swatch { min-width: 0; }
|
||||
.hm-swatch-color { display: block; height: 65px; border: 1px solid #d5cfc3; border-radius: 4px; }
|
||||
.hm-swatch-color--dark { border-color: #1e3b31; }
|
||||
.hm-swatch-name { display: block; margin-top: 8px; color: var(--hm-ink); font-size: 11px; font-weight: 600; overflow-wrap: anywhere; }
|
||||
.hm-swatch-value { color: #8b857a; font-size: 10px; }
|
||||
.hm-field { display: grid; gap: 8px; }
|
||||
.hm-field label { font-size: 12px; font-weight: 600; }
|
||||
.hm-form-footer { border-top: 1px solid #eee9e0; padding-top: 18px; }
|
||||
.hm-form-footer p { display: inline-flex; align-items: center; gap: 6px; margin: 0; color: var(--hm-muted); font-size: 11px; }
|
||||
.hm-form-footer p svg { color: #1a7551; }
|
||||
.hm-inner-card { border: 1px solid #d8d2c5; border-radius: 4px; background: #faf8f3; padding: 21px; }
|
||||
.hm-inner-card-label { color: #798f7c; font-size: 10px; font-weight: 700; letter-spacing: .12em; }
|
||||
.hm-inner-card h3 { margin: 13px 0 7px; color: var(--hm-ink); font-size: 18px; font-weight: 700; letter-spacing: -.03em; }
|
||||
.hm-inner-card p { max-width: 340px; margin: 0 0 19px; color: var(--hm-muted); font-size: 12px; line-height: 1.7; word-break: keep-all; }
|
||||
.hm-dialog-summary { display: grid; grid-template-columns: 110px 1fr; gap: 10px; margin: 8px 0 14px; border-top: 1px solid #dedbd2; border-bottom: 1px solid #dedbd2; padding: 16px 0; font-size: 13px; }
|
||||
.hm-dialog-summary span { color: var(--hm-muted); }
|
||||
.hm-dialog-summary strong { font-weight: 600; }
|
||||
.hm-footer { border-top: 1px solid #d7d1c5; padding-top: 19px; color: #7b7468; }
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.hm-container { width: min(100% - 30px,1180px); }
|
||||
.hm-topbar-status,.hm-hero-art { display: none; }
|
||||
.hm-hero { min-height: auto; }
|
||||
.hm-swatch-grid { grid-template-columns: repeat(2,minmax(0,1fr)); }
|
||||
.hm-panel-header,.hm-panel-content { padding-inline: 18px; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
@import "tailwindcss";
|
||||
@import "@hm-design/ui/tailwind.css";
|
||||
@@ -0,0 +1,11 @@
|
||||
import React from "react"
|
||||
import ReactDOM from "react-dom/client"
|
||||
import App from "./App"
|
||||
import "./index.css"
|
||||
import "./hm-theme.css"
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,178 @@
|
||||
@import "./dashboard-status-contract.css";
|
||||
|
||||
/* Runtime-neutral status contract → Story mapping. HM has no exact public status semantics for these three inks. */
|
||||
.dashboard-production {
|
||||
--dashboard-work-progress: var(--hm-dashboard-status-active);
|
||||
--dashboard-work-hold: var(--hm-dashboard-status-hold);
|
||||
--dashboard-work-issue: var(--hm-dashboard-status-issue);
|
||||
--dashboard-work-badge-background: color-mix(in srgb, var(--hm-sem-palette-green-dark-2) 90%, transparent);
|
||||
}
|
||||
|
||||
/* Existing Badge outline variant, overridden through its component mappings only within Dashboard.
|
||||
Variant C has plain table text, so use its dark selected surface to keep the white done ink legible on HM's light table. */
|
||||
.dashboard-production [data-slot="badge"][data-status] {
|
||||
--hm-badge-outline-text: var(--dashboard-status-color);
|
||||
--hm-badge-outline-background: var(--dashboard-work-badge-background);
|
||||
--hm-badge-outline-border-color: color-mix(in srgb, var(--hm-sem-palette-black) 50%, transparent);
|
||||
}
|
||||
|
||||
.dashboard-production [data-status="active"] { --dashboard-status-color: var(--dashboard-work-progress); }
|
||||
.dashboard-production [data-status="hold"] { --dashboard-status-color: var(--dashboard-work-hold); }
|
||||
.dashboard-production [data-status="done"] { --dashboard-status-color: var(--hm-dashboard-status-done); }
|
||||
.dashboard-production [data-status="issue"][data-issue="true"] { --dashboard-status-color: var(--dashboard-work-issue); }
|
||||
|
||||
.dashboard-production [data-story-slot="work-badges"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Story-only Variant C Compact Header (ui_viz patterns-variant-c--all). Geometry follows the reference: header 48 / padding-x 20,
|
||||
title + 18 after it, status bar 40 high floating from the header top and centred at 50% + 18px, padding-x 36, gap 8, 28px controls,
|
||||
1x16 dividers, bottom radius 16 with concave wings. Colours and type are HM tokens. Content join / padding stay Dashboard Layout's. */
|
||||
.dashboard-production-compact [data-story-slot="compact-header"] {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
height: 48px;
|
||||
padding: 0 20px;
|
||||
background: var(--hm-dashboard-header-background);
|
||||
color: var(--hm-dashboard-nav-text);
|
||||
}
|
||||
|
||||
.dashboard-story-compact [data-story-slot="compact-brand"] {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
height: 48px;
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
.dashboard-story-compact [data-story-slot="compact-brand"] strong,
|
||||
.dashboard-story-compact [data-story-slot="compact-context"] {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
-webkit-text-stroke: var(--hm-dashboard-nav-text-stroke-width) var(--hm-dashboard-nav-text-stroke-color);
|
||||
paint-order: stroke fill;
|
||||
}
|
||||
|
||||
.dashboard-story-compact [data-story-slot="compact-context"] {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dashboard-story-compact [data-story-slot="compact-status"] {
|
||||
--compact-wing: var(--hm-sem-palette-green-default);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: calc(50% + 18px);
|
||||
transform: translateX(-50%);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
height: 40px;
|
||||
padding: 0 36px;
|
||||
border-radius: 0 0 16px 16px;
|
||||
background: linear-gradient(90deg, var(--hm-sem-palette-green-default) 20%, var(--hm-sem-palette-green-light-1) 50%, var(--hm-sem-palette-green-default) 80%);
|
||||
filter: drop-shadow(0 2px 4px color-mix(in srgb, var(--hm-sem-palette-black) 25%, transparent));
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dashboard-story-compact [data-story-slot="compact-status"]::before,
|
||||
.dashboard-story-compact [data-story-slot="compact-status"]::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 36px;
|
||||
height: 30px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dashboard-story-compact [data-story-slot="compact-status"]::before {
|
||||
left: -35px;
|
||||
border-radius: 0 20px 0 0;
|
||||
box-shadow: 18px 0 0 var(--compact-wing);
|
||||
}
|
||||
|
||||
.dashboard-story-compact [data-story-slot="compact-status"]::after {
|
||||
right: -35px;
|
||||
border-radius: 20px 0 0 0;
|
||||
box-shadow: -18px 0 0 var(--compact-wing);
|
||||
}
|
||||
|
||||
.dashboard-story-compact [data-story-slot="compact-divider"] {
|
||||
flex: none;
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
margin: 0 4px;
|
||||
background: color-mix(in srgb, var(--hm-dashboard-nav-text-secondary) 30%, transparent);
|
||||
}
|
||||
|
||||
.dashboard-story-compact [data-story-slot="compact-status-group"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Filter states follow the reference values (black 35/55%, selected Green Dark 2 90% with a Green Light 4 ring). No HM Semantic matches
|
||||
them (state.active / accent differ), so the exact HM palette primitives are used. Selected wins over hover, as in the reference. */
|
||||
.dashboard-story-compact [data-story-slot="compact-status"] [data-slot="button"] {
|
||||
gap: 6px;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border-color: color-mix(in srgb, var(--hm-sem-palette-black) 50%, transparent);
|
||||
background: color-mix(in srgb, var(--hm-sem-palette-black) 35%, transparent);
|
||||
color: color-mix(in srgb, var(--hm-sem-palette-white) 95%, transparent);
|
||||
}
|
||||
|
||||
.dashboard-story-compact [data-story-slot="compact-status"] [data-slot="button"]:hover {
|
||||
border-color: color-mix(in srgb, var(--hm-sem-palette-green-light-4) 40%, transparent);
|
||||
background: color-mix(in srgb, var(--hm-sem-palette-black) 55%, transparent);
|
||||
color: var(--hm-sem-palette-white);
|
||||
}
|
||||
|
||||
.dashboard-story-compact [data-story-slot="compact-status"] [data-slot="button"][aria-pressed="true"] {
|
||||
border-color: color-mix(in srgb, var(--hm-sem-palette-green-light-4) 65%, transparent);
|
||||
background: color-mix(in srgb, var(--hm-sem-palette-green-dark-2) 90%, transparent);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--hm-sem-palette-green-light-4) 35%, transparent);
|
||||
color: var(--hm-sem-palette-white);
|
||||
}
|
||||
|
||||
/* Count ink shares dashboardWorkStatus with the table Badge; labels retain Reference white ink. */
|
||||
.dashboard-story-compact [data-story-slot="compact-status"] [data-status] strong {
|
||||
color: var(--dashboard-status-color);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
-webkit-text-stroke: 1px color-mix(in srgb, var(--hm-sem-palette-black) 22%, transparent);
|
||||
paint-order: stroke fill;
|
||||
}
|
||||
|
||||
/* Issue is separately divided and has its own Default/Hover/Selected surface in Variant C. */
|
||||
.dashboard-story-compact [data-story-slot="compact-status"] [data-status="issue"] {
|
||||
background: color-mix(in srgb, var(--hm-sem-palette-green-light-3) 28%, transparent);
|
||||
border-color: color-mix(in srgb, var(--hm-sem-palette-green-light-4) 45%, transparent);
|
||||
color: var(--hm-sem-palette-white);
|
||||
}
|
||||
.dashboard-story-compact [data-story-slot="compact-status"] [data-status="issue"]:hover {
|
||||
background: color-mix(in srgb, var(--hm-sem-palette-green-light-3) 45%, transparent);
|
||||
border-color: color-mix(in srgb, var(--hm-sem-palette-green-light-4) 65%, transparent);
|
||||
}
|
||||
.dashboard-story-compact [data-story-slot="compact-status"] [data-status="issue"][aria-pressed="true"] {
|
||||
background: color-mix(in srgb, var(--hm-sem-action-destructive) 25%, transparent);
|
||||
border-color: color-mix(in srgb, var(--hm-sem-action-destructive) 70%, transparent);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--hm-sem-action-destructive) 40%, transparent);
|
||||
}
|
||||
|
||||
.dashboard-story-compact [data-story-slot="compact-status"] [data-slot="button"]:focus-visible {
|
||||
outline: var(--hm-dashboard-nav-focus-outline);
|
||||
}
|
||||
|
||||
/* Compact geometry is shared by all viewports. Dashboard's native scroll frame preserves its minimum width. */
|
||||
@@ -0,0 +1,422 @@
|
||||
import { Fragment, useState, type MouseEvent, type ReactNode } from "react"
|
||||
import { ChartColumnIcon, ChevronRightIcon, LayoutGridIcon, MoreHorizontalIcon, SettingsIcon, type LucideIcon } from "lucide-react"
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
Badge,
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
Button,
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
DashboardContent,
|
||||
DashboardLayout,
|
||||
DashboardHeader,
|
||||
DashboardSidebar,
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
DashboardMain,
|
||||
DataTable,
|
||||
DataTableColumnHeader,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
FilterBar,
|
||||
FilterBarActions,
|
||||
FilterBarSearch,
|
||||
PageHeader,
|
||||
PageHeaderActions,
|
||||
PageHeaderContent,
|
||||
PageHeaderDescription,
|
||||
PageHeaderTitle,
|
||||
Progress,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Skeleton,
|
||||
dataTableSelectColumn,
|
||||
useDataTable,
|
||||
type DataTableColumnDef,
|
||||
} from "@hm-design/ui"
|
||||
import { formatDate } from "./sample-users"
|
||||
import { countDashboardWorkFilter, dashboardWorks, dashboardWorkFilters, dashboardWorkStatus, dashboardWorkStyle, matchesDashboardWorkStatus, type DashboardWork, type DashboardWorkFilter } from "./dashboard-work-status"
|
||||
import "./dashboard-composition.css"
|
||||
|
||||
// Dashboard Mode: internal navigation, Page Header, KPI cards, Filter Bar and Data Table.
|
||||
// Menu and profile data are local to this Story. See docs/patterns/dashboard-layout.md.
|
||||
|
||||
const photo =
|
||||
"data:image/svg+xml;utf8," +
|
||||
encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80"><rect width="80" height="80" fill="#266651"/><circle cx="40" cy="31" r="14" fill="#bddfd4"/><rect x="14" y="50" width="52" height="40" rx="22" fill="#bddfd4"/></svg>')
|
||||
|
||||
const profileData = {
|
||||
name: "김한맥",
|
||||
description: "프로젝트 관리자",
|
||||
photo,
|
||||
fallback: "김",
|
||||
}
|
||||
|
||||
const profileActions: { label: string; destructive?: boolean }[] = [
|
||||
{ label: "프로필 설정" },
|
||||
{ label: "로그아웃", destructive: true },
|
||||
]
|
||||
|
||||
function ProfileAvatar() {
|
||||
return (
|
||||
<Avatar>
|
||||
<AvatarImage src={profileData.photo} alt="" />
|
||||
<AvatarFallback>{profileData.fallback}</AvatarFallback>
|
||||
</Avatar>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileMenu({ trigger, side = "bottom", align = "start", modal, matchTrigger = false }: { trigger: ReactNode; side?: "top" | "right" | "bottom" | "left"; align?: "start" | "center" | "end"; modal?: boolean; matchTrigger?: boolean }) {
|
||||
return (
|
||||
<DropdownMenu modal={modal}>
|
||||
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side={side} align={align} className={matchTrigger ? "min-w-(--radix-dropdown-menu-trigger-width)" : undefined}>
|
||||
<DropdownMenuLabel>{profileData.name}</DropdownMenuLabel>
|
||||
{profileActions.map((action) => (
|
||||
<Fragment key={action.label}>
|
||||
{action.destructive ? <DropdownMenuSeparator /> : null}
|
||||
<DropdownMenuItem variant={action.destructive ? "destructive" : "default"}>{action.label}</DropdownMenuItem>
|
||||
</Fragment>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
type Mode = "compact" | "sidebar" | "header-sidebar"
|
||||
type StatusFilter = DashboardWorkFilter
|
||||
const statusFilters = dashboardWorkFilters.map((id) => ({ id, ...dashboardWorkStatus[id] }))
|
||||
|
||||
// Variant C (ui_viz patterns-variant-c--all) composition: a 48px Compact Header with the title on the left and a floating 40px status
|
||||
// filter bar centred on the header, followed immediately by the content surface. No GNB, LNB or profile. The structure mirrors the
|
||||
// reference (header > brand + status bar [context | 전체 | group]); colours, type and controls are HM.
|
||||
function CompactHeader({ data, status, onStatusChange }: { data: DashboardWork[]; status: StatusFilter; onStatusChange: (status: StatusFilter) => void }) {
|
||||
const count = (id: StatusFilter) => countDashboardWorkFilter(data, id)
|
||||
const filterButton = (filter: (typeof statusFilters)[number]) => (
|
||||
<Button key={filter.id} type="button" variant="ghost" size="sm" data-filter={filter.id} data-status={filter.id} data-issue={filter.id === "issue" ? "true" : undefined} style={dashboardWorkStyle(filter.id)} aria-pressed={status === filter.id} aria-controls="dashboard-compact-results" onClick={() => onStatusChange(filter.id)}>
|
||||
{filter.label} <strong>{count(filter.id)}</strong>건
|
||||
</Button>
|
||||
)
|
||||
const [all, ...rest] = statusFilters
|
||||
const workStates = rest.filter((filter) => filter.id !== "issue")
|
||||
const issue = rest.find((filter) => filter.id === "issue")!
|
||||
return (
|
||||
<DashboardHeader data-story-slot="compact-header">
|
||||
<div data-story-slot="compact-brand">
|
||||
<strong>프로젝트 대시보드</strong>
|
||||
</div>
|
||||
<div data-story-slot="compact-status" role="group" aria-label="업무 상태 필터">
|
||||
<span data-story-slot="compact-context">업무</span>
|
||||
<span data-story-slot="compact-divider" aria-hidden="true" />
|
||||
{filterButton(all)}
|
||||
<span data-story-slot="compact-divider" aria-hidden="true" />
|
||||
<div data-story-slot="compact-status-group">{workStates.map(filterButton)}</div>
|
||||
<span data-story-slot="compact-divider" aria-hidden="true" />
|
||||
{filterButton(issue)}
|
||||
</div>
|
||||
</DashboardHeader>
|
||||
)
|
||||
}
|
||||
|
||||
// Dashboard internal menu; kept at every viewport width.
|
||||
type LocalNode = { id: string; label: string; icon?: LucideIcon; children?: LocalNode[] }
|
||||
|
||||
const localNav = (thirdLevel?: boolean): LocalNode[] => [
|
||||
{ id: "overview", label: "개요", icon: LayoutGridIcon },
|
||||
{
|
||||
id: "projects",
|
||||
label: "업무 관리",
|
||||
children: [
|
||||
thirdLevel
|
||||
? {
|
||||
id: "projects/tasks",
|
||||
label: "업무 현황",
|
||||
children: [
|
||||
{ id: "projects/tasks/all", label: "전체 업무" },
|
||||
{ id: "projects/tasks/mine", label: "담당 업무" },
|
||||
{ id: "projects/tasks/delayed", label: "지연 업무" },
|
||||
],
|
||||
}
|
||||
: { id: "projects/tasks", label: "업무 현황" },
|
||||
{ id: "projects/schedule", label: "일정" },
|
||||
],
|
||||
},
|
||||
{ id: "reports", label: "리포트", icon: ChartColumnIcon },
|
||||
{ id: "settings", label: "설정", icon: SettingsIcon },
|
||||
]
|
||||
|
||||
const statusBadge = (status: Exclude<StatusFilter, "all">) => (
|
||||
<Badge variant="outline" data-status={status} data-issue={status === "issue" ? "true" : undefined} data-work-status={status} style={dashboardWorkStyle(status)}>{dashboardWorkStatus[status].label}</Badge>
|
||||
)
|
||||
|
||||
const columns: DataTableColumnDef<DashboardWork>[] = [
|
||||
dataTableSelectColumn<DashboardWork>(),
|
||||
{ accessorKey: "name", header: ({ column }) => <DataTableColumnHeader column={column} title="업무명" /> },
|
||||
{ accessorKey: "department", header: "부서" },
|
||||
{ accessorKey: "status", header: "상태", cell: ({ row }) => <span data-story-slot="work-badges">{statusBadge(row.original.status)}{row.original.issue ? statusBadge("issue") : null}</span>, filterFn: (row, _columnId, value: StatusFilter) => matchesDashboardWorkStatus(row.original, value), enableSorting: false },
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: ({ column }) => <DataTableColumnHeader column={column} title="등록일" />,
|
||||
cell: ({ row }) => formatDate(row.original.createdAt),
|
||||
},
|
||||
]
|
||||
|
||||
function TaskList({ data, loading, compact, selectedStatus = "all", onReset }: { data: DashboardWork[]; loading?: boolean; compact?: boolean; selectedStatus?: StatusFilter; onReset?: () => void }) {
|
||||
const table = useDataTable({ columns, data, getRowId: (row) => row.id })
|
||||
const [search, setSearch] = useState("")
|
||||
const [status, setStatus] = useState("all")
|
||||
const total = table.getFilteredRowModel().rows.length
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>업무 목록</CardTitle>
|
||||
<CardDescription role="status">{dashboardWorkStatus[compact ? selectedStatus : status as StatusFilter].label} 업무 {total}건</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4">
|
||||
<FilterBar>
|
||||
<FilterBarSearch
|
||||
value={search}
|
||||
onValueChange={(value) => {
|
||||
setSearch(value)
|
||||
table.setGlobalFilter(value || undefined)
|
||||
}}
|
||||
placeholder="업무명·부서 검색"
|
||||
/>
|
||||
{compact ? null : <Select
|
||||
value={status}
|
||||
onValueChange={(value) => {
|
||||
setStatus(value)
|
||||
table.getColumn("status")?.setFilterValue(value === "all" ? undefined : value)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size="medium" aria-label="상태">
|
||||
<SelectValue placeholder="상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{statusFilters.map((filter) => <SelectItem key={filter.id} value={filter.id}>{filter.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>}
|
||||
<FilterBarActions>
|
||||
<Button
|
||||
type="button"
|
||||
variant="cancel"
|
||||
appearance="solid"
|
||||
onClick={() => {
|
||||
setSearch("")
|
||||
setStatus("all")
|
||||
table.resetGlobalFilter(true)
|
||||
table.resetColumnFilters(true)
|
||||
onReset?.()
|
||||
}}
|
||||
>
|
||||
초기화
|
||||
</Button>
|
||||
</FilterBarActions>
|
||||
</FilterBar>
|
||||
<DataTable table={table} loading={loading} emptyMessage="조건에 맞는 업무가 없습니다." />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function Kpis({ data, loading }: { data: DashboardWork[]; loading?: boolean }) {
|
||||
const kpis = statusFilters.map(({ id, label }) => {
|
||||
const value = countDashboardWorkFilter(data, id)
|
||||
const progress = data.length ? Math.round(value / data.length * 100) : 0
|
||||
return { id, label, value, progress }
|
||||
})
|
||||
return (
|
||||
<div className="grid gap-6" style={{ gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 200px), 1fr))" }}>
|
||||
{kpis.map((kpi) => (
|
||||
<Card key={kpi.label}>
|
||||
<CardHeader>
|
||||
<CardDescription>{kpi.label}</CardDescription>
|
||||
{loading ? null : (
|
||||
<CardAction>
|
||||
{kpi.id === "all" ? <Badge variant="secondary">전체</Badge> : statusBadge(kpi.id)}
|
||||
</CardAction>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="grid gap-3">
|
||||
<Skeleton style={{ height: 36, width: 80 }} />
|
||||
<Skeleton style={{ height: 8 }} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
<p style={{ margin: 0, fontSize: 28, lineHeight: "36px", fontWeight: 700 }}>{kpi.value}</p>
|
||||
<Progress value={kpi.progress} aria-label={`${kpi.label} 비율`} />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DashboardScreen({
|
||||
mode = "compact",
|
||||
data = dashboardWorks,
|
||||
loading,
|
||||
longTitle,
|
||||
wide,
|
||||
thirdLevel,
|
||||
}: {
|
||||
mode?: Mode
|
||||
data?: DashboardWork[]
|
||||
loading?: boolean
|
||||
longTitle?: boolean
|
||||
wide?: boolean
|
||||
thirdLevel?: boolean
|
||||
}) {
|
||||
const [current, setCurrent] = useState(thirdLevel ? "projects/tasks/all" : "projects/tasks")
|
||||
const [compactStatus, setCompactStatus] = useState<StatusFilter>("all")
|
||||
const compact = mode === "compact"
|
||||
const resultData = compact ? data.filter((row) => matchesDashboardWorkStatus(row, compactStatus)) : data
|
||||
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({})
|
||||
const go = (id: string) => (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
setCurrent(id)
|
||||
}
|
||||
const isCurrent = (id: string) => current === id || current.startsWith(`${id}/`)
|
||||
const nav = localNav(thirdLevel)
|
||||
const dashboardMenu = (nodes: LocalNode[]): ReactNode =>
|
||||
nodes.map((node) =>
|
||||
node.children ? (
|
||||
<Collapsible
|
||||
key={node.id}
|
||||
data-current={isCurrent(node.id) ? "" : undefined}
|
||||
open={openGroups[node.id] ?? true}
|
||||
onOpenChange={(value) => setOpenGroups((state) => ({ ...state, [node.id]: value }))}
|
||||
>
|
||||
<CollapsibleTrigger asChild><Button variant="ghost" type="button"><ChevronRightIcon aria-hidden="true" />{node.label}</Button></CollapsibleTrigger>
|
||||
<CollapsibleContent className="pl-3">{dashboardMenu(node.children)}</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : (
|
||||
<Button key={node.id} variant="ghost" asChild><a href={`#${node.id}`} aria-current={current === node.id ? "page" : undefined} onClick={go(node.id)}>
|
||||
{node.icon ? <node.icon aria-hidden="true" /> : null}
|
||||
{node.label}
|
||||
</a></Button>
|
||||
)
|
||||
)
|
||||
const title = longTitle ? "전사 프로젝트 포트폴리오의 진행 현황과 담당 부서별 업무 상세 현황" : "업무 현황"
|
||||
const sidebar = (
|
||||
<DashboardSidebar>
|
||||
<strong>프로젝트 대시보드</strong>
|
||||
<nav aria-label="Dashboard 업무 메뉴">{dashboardMenu(nav)}</nav>
|
||||
<ProfileMenu side="right" align="end" trigger={
|
||||
<Button type="button" variant="ghost" aria-label={`${profileData.name} 사용자 메뉴`}>
|
||||
<ProfileAvatar />{profileData.name}
|
||||
</Button>
|
||||
} />
|
||||
</DashboardSidebar>
|
||||
)
|
||||
|
||||
// Variant C content: heading, then the result card (Page Header's own 20px bottom spacing; no breadcrumb, actions or KPI cards in the reference).
|
||||
const compactMain = (
|
||||
<DashboardMain id="dashboard-compact-results">
|
||||
<PageHeader>
|
||||
<PageHeaderContent>
|
||||
<PageHeaderTitle>업무 현황을 한눈에 확인하세요</PageHeaderTitle>
|
||||
</PageHeaderContent>
|
||||
</PageHeader>
|
||||
<TaskList data={resultData} loading={loading} compact selectedStatus={compactStatus} onReset={() => setCompactStatus("all")} />
|
||||
</DashboardMain>
|
||||
)
|
||||
|
||||
const main = compact ? compactMain : (
|
||||
<DashboardMain>
|
||||
<PageHeader>
|
||||
<PageHeaderContent>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="#dashboard">홈</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="#projects">프로젝트</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>업무 현황</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<PageHeaderTitle>{title}</PageHeaderTitle>
|
||||
<PageHeaderDescription>프로젝트별 업무 진행 상황을 확인하고 조회합니다.</PageHeaderDescription>
|
||||
</PageHeaderContent>
|
||||
<PageHeaderActions>
|
||||
<Button type="button" variant="cancel" appearance="solid">
|
||||
다운로드
|
||||
</Button>
|
||||
<Button type="button" variant="primary">
|
||||
신규 등록
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" variant="primary" appearance="outline" size="icon" aria-label="더보기">
|
||||
<MoreHorizontalIcon />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem>일괄 수정</DropdownMenuItem>
|
||||
<DropdownMenuItem>보관</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</PageHeaderActions>
|
||||
</PageHeader>
|
||||
<DashboardContent>
|
||||
<Kpis data={data} loading={loading} />
|
||||
<TaskList data={resultData} loading={loading} />
|
||||
{wide ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>넓은 내용</CardTitle>
|
||||
<CardDescription>페이지를 밀지 않고 카드 안에서 가로 스크롤합니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<div style={{ width: 1600, height: 80, background: "var(--hm-sem-bg-content)" }} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</DashboardContent>
|
||||
</DashboardMain>
|
||||
)
|
||||
|
||||
return (
|
||||
<DashboardLayout
|
||||
className={compact ? "dashboard-production dashboard-story-compact" : "dashboard-production"}
|
||||
header={compact ? <CompactHeader data={data} status={compactStatus} onStatusChange={setCompactStatus} /> : mode === "header-sidebar" ? <DashboardHeader><strong>업무 현황</strong></DashboardHeader> : undefined}
|
||||
sidebar={compact ? undefined : sidebar}
|
||||
>
|
||||
{main}
|
||||
</DashboardLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
@import "./dashboard-status-contract.css";
|
||||
|
||||
/* Runtime-neutral status contract → Story mapping. HM has no exact public status semantics for these three inks. */
|
||||
.dashboard-story {
|
||||
--dashboard-work-progress: var(--hm-dashboard-status-active);
|
||||
--dashboard-work-hold: var(--hm-dashboard-status-hold);
|
||||
--dashboard-work-issue: var(--hm-dashboard-status-issue);
|
||||
--dashboard-work-badge-background: color-mix(in srgb, var(--hm-sem-palette-green-dark-2) 90%, transparent);
|
||||
}
|
||||
|
||||
/* Existing Badge outline variant, overridden through its component mappings only within Dashboard.
|
||||
Variant C has plain table text, so use its dark selected surface to keep the white done ink legible on HM's light table. */
|
||||
.dashboard-story [data-slot="badge"][data-status] {
|
||||
--hm-badge-outline-text: var(--dashboard-status-color);
|
||||
--hm-badge-outline-background: var(--dashboard-work-badge-background);
|
||||
--hm-badge-outline-border-color: color-mix(in srgb, var(--hm-sem-palette-black) 50%, transparent);
|
||||
}
|
||||
|
||||
.dashboard-story [data-status="active"] { --dashboard-status-color: var(--dashboard-work-progress); }
|
||||
.dashboard-story [data-status="hold"] { --dashboard-status-color: var(--dashboard-work-hold); }
|
||||
.dashboard-story [data-status="done"] { --dashboard-status-color: var(--hm-dashboard-status-done); }
|
||||
.dashboard-story [data-status="issue"][data-issue="true"] { --dashboard-status-color: var(--dashboard-work-issue); }
|
||||
|
||||
.dashboard-story [data-story-slot="work-badges"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Story-only Variant C Compact Header (ui_viz patterns-variant-c--all). Geometry follows the reference: header 48 / padding-x 20,
|
||||
title + 18 after it, status bar 40 high floating from the header top and centred at 50% + 18px, padding-x 36, gap 8, 28px controls,
|
||||
1x16 dividers, bottom radius 16 with concave wings. Colours and type are HM tokens. Content join / padding stay Dashboard Layout's. */
|
||||
.dashboard-story-compact [data-story-slot="compact-header"] {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
height: 48px;
|
||||
padding: 0 20px;
|
||||
background: var(--hm-dashboard-header-background);
|
||||
color: var(--hm-dashboard-nav-text);
|
||||
}
|
||||
|
||||
.dashboard-story-compact [data-story-slot="compact-brand"] {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
height: 48px;
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
.dashboard-story-compact [data-story-slot="compact-brand"] strong,
|
||||
.dashboard-story-compact [data-story-slot="compact-context"] {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
-webkit-text-stroke: var(--hm-dashboard-nav-text-stroke-width) var(--hm-dashboard-nav-text-stroke-color);
|
||||
paint-order: stroke fill;
|
||||
}
|
||||
|
||||
.dashboard-story-compact [data-story-slot="compact-context"] {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dashboard-story-compact [data-story-slot="compact-status"] {
|
||||
--compact-wing: var(--hm-sem-palette-green-default);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: calc(50% + 18px);
|
||||
transform: translateX(-50%);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
height: 40px;
|
||||
padding: 0 36px;
|
||||
border-radius: 0 0 16px 16px;
|
||||
background: linear-gradient(90deg, var(--hm-sem-palette-green-default) 20%, var(--hm-sem-palette-green-light-1) 50%, var(--hm-sem-palette-green-default) 80%);
|
||||
filter: drop-shadow(0 2px 4px color-mix(in srgb, var(--hm-sem-palette-black) 25%, transparent));
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dashboard-story-compact [data-story-slot="compact-status"]::before,
|
||||
.dashboard-story-compact [data-story-slot="compact-status"]::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 36px;
|
||||
height: 30px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dashboard-story-compact [data-story-slot="compact-status"]::before {
|
||||
left: -35px;
|
||||
border-radius: 0 20px 0 0;
|
||||
box-shadow: 18px 0 0 var(--compact-wing);
|
||||
}
|
||||
|
||||
.dashboard-story-compact [data-story-slot="compact-status"]::after {
|
||||
right: -35px;
|
||||
border-radius: 20px 0 0 0;
|
||||
box-shadow: -18px 0 0 var(--compact-wing);
|
||||
}
|
||||
|
||||
.dashboard-story-compact [data-story-slot="compact-divider"] {
|
||||
flex: none;
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
margin: 0 4px;
|
||||
background: color-mix(in srgb, var(--hm-dashboard-nav-text-secondary) 30%, transparent);
|
||||
}
|
||||
|
||||
.dashboard-story-compact [data-story-slot="compact-status-group"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Filter states follow the reference values (black 35/55%, selected Green Dark 2 90% with a Green Light 4 ring). No HM Semantic matches
|
||||
them (state.active / accent differ), so the exact HM palette primitives are used. Selected wins over hover, as in the reference. */
|
||||
.dashboard-story-compact [data-story-slot="compact-status"] [data-slot="button"] {
|
||||
gap: 6px;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border-color: color-mix(in srgb, var(--hm-sem-palette-black) 50%, transparent);
|
||||
background: color-mix(in srgb, var(--hm-sem-palette-black) 35%, transparent);
|
||||
color: color-mix(in srgb, var(--hm-sem-palette-white) 95%, transparent);
|
||||
}
|
||||
|
||||
.dashboard-story-compact [data-story-slot="compact-status"] [data-slot="button"]:hover {
|
||||
border-color: color-mix(in srgb, var(--hm-sem-palette-green-light-4) 40%, transparent);
|
||||
background: color-mix(in srgb, var(--hm-sem-palette-black) 55%, transparent);
|
||||
color: var(--hm-sem-palette-white);
|
||||
}
|
||||
|
||||
.dashboard-story-compact [data-story-slot="compact-status"] [data-slot="button"][aria-pressed="true"] {
|
||||
border-color: color-mix(in srgb, var(--hm-sem-palette-green-light-4) 65%, transparent);
|
||||
background: color-mix(in srgb, var(--hm-sem-palette-green-dark-2) 90%, transparent);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--hm-sem-palette-green-light-4) 35%, transparent);
|
||||
color: var(--hm-sem-palette-white);
|
||||
}
|
||||
|
||||
/* Count ink shares dashboardWorkStatus with the table Badge; labels retain Reference white ink. */
|
||||
.dashboard-story-compact [data-story-slot="compact-status"] [data-status] strong {
|
||||
color: var(--dashboard-status-color);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
-webkit-text-stroke: 1px color-mix(in srgb, var(--hm-sem-palette-black) 22%, transparent);
|
||||
paint-order: stroke fill;
|
||||
}
|
||||
|
||||
/* Issue is separately divided and has its own Default/Hover/Selected surface in Variant C. */
|
||||
.dashboard-story-compact [data-story-slot="compact-status"] [data-status="issue"] {
|
||||
background: color-mix(in srgb, var(--hm-sem-palette-green-light-3) 28%, transparent);
|
||||
border-color: color-mix(in srgb, var(--hm-sem-palette-green-light-4) 45%, transparent);
|
||||
color: var(--hm-sem-palette-white);
|
||||
}
|
||||
.dashboard-story-compact [data-story-slot="compact-status"] [data-status="issue"]:hover {
|
||||
background: color-mix(in srgb, var(--hm-sem-palette-green-light-3) 45%, transparent);
|
||||
border-color: color-mix(in srgb, var(--hm-sem-palette-green-light-4) 65%, transparent);
|
||||
}
|
||||
.dashboard-story-compact [data-story-slot="compact-status"] [data-status="issue"][aria-pressed="true"] {
|
||||
background: color-mix(in srgb, var(--hm-sem-action-destructive) 25%, transparent);
|
||||
border-color: color-mix(in srgb, var(--hm-sem-action-destructive) 70%, transparent);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--hm-sem-action-destructive) 40%, transparent);
|
||||
}
|
||||
|
||||
.dashboard-story-compact [data-story-slot="compact-status"] [data-slot="button"]:focus-visible {
|
||||
outline: var(--hm-dashboard-nav-focus-outline);
|
||||
}
|
||||
|
||||
/* Compact geometry is shared by all viewports. Dashboard's native scroll frame preserves its minimum width. */
|
||||
@@ -0,0 +1,473 @@
|
||||
import { Fragment, useState, type CSSProperties, type MouseEvent, type ReactNode } from "react"
|
||||
import { DashboardScreen } from "./dashboard-composition"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { ChartColumnIcon, ChevronRightIcon, LayoutGridIcon, MoreHorizontalIcon, SettingsIcon, type LucideIcon } from "lucide-react"
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
Badge,
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
Button,
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
DashboardContent,
|
||||
DashboardLayout,
|
||||
DashboardHeader,
|
||||
DashboardSidebar,
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
DashboardMain,
|
||||
DataTable,
|
||||
DataTableColumnHeader,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
FilterBar,
|
||||
FilterBarActions,
|
||||
FilterBarSearch,
|
||||
PageHeader,
|
||||
PageHeaderActions,
|
||||
PageHeaderContent,
|
||||
PageHeaderDescription,
|
||||
PageHeaderTitle,
|
||||
Progress,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Skeleton,
|
||||
dataTableSelectColumn,
|
||||
useDataTable,
|
||||
type DataTableColumnDef,
|
||||
} from "@hm-design/ui"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
import { formatDate } from "./sample-users"
|
||||
import { countDashboardWorkFilter, dashboardWorks, dashboardWorkFilters, dashboardWorkStatus, dashboardWorkStyle, matchesDashboardWorkStatus, type DashboardWork, type DashboardWorkFilter } from "./dashboard-work-status"
|
||||
import "./dashboard-layout.stories.css"
|
||||
|
||||
// Dashboard Mode: internal navigation, Page Header, KPI cards, Filter Bar and Data Table.
|
||||
// Menu and profile data are local to this Story. See docs/patterns/dashboard-layout.md.
|
||||
|
||||
const photo =
|
||||
"data:image/svg+xml;utf8," +
|
||||
encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80"><rect width="80" height="80" fill="#266651"/><circle cx="40" cy="31" r="14" fill="#bddfd4"/><rect x="14" y="50" width="52" height="40" rx="22" fill="#bddfd4"/></svg>')
|
||||
|
||||
const profileData = {
|
||||
name: "김한맥",
|
||||
description: "프로젝트 관리자",
|
||||
photo,
|
||||
fallback: "김",
|
||||
}
|
||||
|
||||
const profileActions: { label: string; destructive?: boolean }[] = [
|
||||
{ label: "프로필 설정" },
|
||||
{ label: "로그아웃", destructive: true },
|
||||
]
|
||||
|
||||
function ProfileAvatar() {
|
||||
return (
|
||||
<Avatar>
|
||||
<AvatarImage src={profileData.photo} alt="" />
|
||||
<AvatarFallback>{profileData.fallback}</AvatarFallback>
|
||||
</Avatar>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileMenu({ trigger, side = "bottom", align = "start", modal, matchTrigger = false }: { trigger: ReactNode; side?: "top" | "right" | "bottom" | "left"; align?: "start" | "center" | "end"; modal?: boolean; matchTrigger?: boolean }) {
|
||||
return (
|
||||
<DropdownMenu modal={modal}>
|
||||
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side={side} align={align} className={matchTrigger ? "min-w-(--radix-dropdown-menu-trigger-width)" : undefined}>
|
||||
<DropdownMenuLabel>{profileData.name}</DropdownMenuLabel>
|
||||
{profileActions.map((action) => (
|
||||
<Fragment key={action.label}>
|
||||
{action.destructive ? <DropdownMenuSeparator /> : null}
|
||||
<DropdownMenuItem variant={action.destructive ? "destructive" : "default"}>{action.label}</DropdownMenuItem>
|
||||
</Fragment>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
type Mode = "compact" | "sidebar" | "header-sidebar"
|
||||
type StatusFilter = DashboardWorkFilter
|
||||
const statusFilters = dashboardWorkFilters.map((id) => ({ id, ...dashboardWorkStatus[id] }))
|
||||
|
||||
// Variant C (ui_viz patterns-variant-c--all) composition: a 48px Compact Header with the title on the left and a floating 40px status
|
||||
// filter bar centred on the header, followed immediately by the content surface. No GNB, LNB or profile. The structure mirrors the
|
||||
// reference (header > brand + status bar [context | 전체 | group]); colours, type and controls are HM.
|
||||
function CompactHeader({ data, status, onStatusChange }: { data: DashboardWork[]; status: StatusFilter; onStatusChange: (status: StatusFilter) => void }) {
|
||||
const count = (id: StatusFilter) => countDashboardWorkFilter(data, id)
|
||||
const filterButton = (filter: (typeof statusFilters)[number]) => (
|
||||
<Button key={filter.id} type="button" variant="ghost" size="sm" data-filter={filter.id} data-status={filter.id} data-issue={filter.id === "issue" ? "true" : undefined} style={dashboardWorkStyle(filter.id)} aria-pressed={status === filter.id} aria-controls="dashboard-compact-results" onClick={() => onStatusChange(filter.id)}>
|
||||
{filter.label} <strong>{count(filter.id)}</strong>건
|
||||
</Button>
|
||||
)
|
||||
const [all, ...rest] = statusFilters
|
||||
const workStates = rest.filter((filter) => filter.id !== "issue")
|
||||
const issue = rest.find((filter) => filter.id === "issue")!
|
||||
return (
|
||||
<DashboardHeader data-story-slot="compact-header">
|
||||
<div data-story-slot="compact-brand">
|
||||
<strong>프로젝트 대시보드</strong>
|
||||
</div>
|
||||
<div data-story-slot="compact-status" role="group" aria-label="업무 상태 필터">
|
||||
<span data-story-slot="compact-context">업무</span>
|
||||
<span data-story-slot="compact-divider" aria-hidden="true" />
|
||||
{filterButton(all)}
|
||||
<span data-story-slot="compact-divider" aria-hidden="true" />
|
||||
<div data-story-slot="compact-status-group">{workStates.map(filterButton)}</div>
|
||||
<span data-story-slot="compact-divider" aria-hidden="true" />
|
||||
{filterButton(issue)}
|
||||
</div>
|
||||
</DashboardHeader>
|
||||
)
|
||||
}
|
||||
|
||||
// Dashboard internal menu; kept at every viewport width.
|
||||
type LocalNode = { id: string; label: string; icon?: LucideIcon; children?: LocalNode[] }
|
||||
|
||||
const localNav = (thirdLevel?: boolean): LocalNode[] => [
|
||||
{ id: "overview", label: "개요", icon: LayoutGridIcon },
|
||||
{
|
||||
id: "projects",
|
||||
label: "업무 관리",
|
||||
children: [
|
||||
thirdLevel
|
||||
? {
|
||||
id: "projects/tasks",
|
||||
label: "업무 현황",
|
||||
children: [
|
||||
{ id: "projects/tasks/all", label: "전체 업무" },
|
||||
{ id: "projects/tasks/mine", label: "담당 업무" },
|
||||
{ id: "projects/tasks/delayed", label: "지연 업무" },
|
||||
],
|
||||
}
|
||||
: { id: "projects/tasks", label: "업무 현황" },
|
||||
{ id: "projects/schedule", label: "일정" },
|
||||
],
|
||||
},
|
||||
{ id: "reports", label: "리포트", icon: ChartColumnIcon },
|
||||
{ id: "settings", label: "설정", icon: SettingsIcon },
|
||||
]
|
||||
|
||||
const statusBadge = (status: Exclude<StatusFilter, "all">) => (
|
||||
<Badge variant="outline" data-status={status} data-issue={status === "issue" ? "true" : undefined} data-work-status={status} style={dashboardWorkStyle(status)}>{dashboardWorkStatus[status].label}</Badge>
|
||||
)
|
||||
|
||||
const columns: DataTableColumnDef<DashboardWork>[] = [
|
||||
dataTableSelectColumn<DashboardWork>(),
|
||||
{ accessorKey: "name", header: ({ column }) => <DataTableColumnHeader column={column} title="업무명" /> },
|
||||
{ accessorKey: "department", header: "부서" },
|
||||
{ accessorKey: "status", header: "상태", cell: ({ row }) => <span data-story-slot="work-badges">{statusBadge(row.original.status)}{row.original.issue ? statusBadge("issue") : null}</span>, filterFn: (row, _columnId, value: StatusFilter) => matchesDashboardWorkStatus(row.original, value), enableSorting: false },
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: ({ column }) => <DataTableColumnHeader column={column} title="등록일" />,
|
||||
cell: ({ row }) => formatDate(row.original.createdAt),
|
||||
},
|
||||
]
|
||||
|
||||
function TaskList({ data, loading, compact, selectedStatus = "all", onReset }: { data: DashboardWork[]; loading?: boolean; compact?: boolean; selectedStatus?: StatusFilter; onReset?: () => void }) {
|
||||
const table = useDataTable({ columns, data, getRowId: (row) => row.id })
|
||||
const [search, setSearch] = useState("")
|
||||
const [status, setStatus] = useState("all")
|
||||
const total = table.getFilteredRowModel().rows.length
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>업무 목록</CardTitle>
|
||||
<CardDescription role="status">{dashboardWorkStatus[compact ? selectedStatus : status as StatusFilter].label} 업무 {total}건</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4">
|
||||
<FilterBar>
|
||||
<FilterBarSearch
|
||||
value={search}
|
||||
onValueChange={(value) => {
|
||||
setSearch(value)
|
||||
table.setGlobalFilter(value || undefined)
|
||||
}}
|
||||
placeholder="업무명·부서 검색"
|
||||
/>
|
||||
{compact ? null : <Select
|
||||
value={status}
|
||||
onValueChange={(value) => {
|
||||
setStatus(value)
|
||||
table.getColumn("status")?.setFilterValue(value === "all" ? undefined : value)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size="medium" aria-label="상태">
|
||||
<SelectValue placeholder="상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{statusFilters.map((filter) => <SelectItem key={filter.id} value={filter.id}>{filter.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>}
|
||||
<FilterBarActions>
|
||||
<Button
|
||||
type="button"
|
||||
variant="cancel"
|
||||
appearance="solid"
|
||||
onClick={() => {
|
||||
setSearch("")
|
||||
setStatus("all")
|
||||
table.resetGlobalFilter(true)
|
||||
table.resetColumnFilters(true)
|
||||
onReset?.()
|
||||
}}
|
||||
>
|
||||
초기화
|
||||
</Button>
|
||||
</FilterBarActions>
|
||||
</FilterBar>
|
||||
<DataTable table={table} loading={loading} emptyMessage="조건에 맞는 업무가 없습니다." />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function Kpis({ data, loading }: { data: DashboardWork[]; loading?: boolean }) {
|
||||
const kpis = statusFilters.map(({ id, label }) => {
|
||||
const value = countDashboardWorkFilter(data, id)
|
||||
const progress = data.length ? Math.round(value / data.length * 100) : 0
|
||||
return { id, label, value, progress }
|
||||
})
|
||||
return (
|
||||
<div className="grid gap-6" style={{ gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 200px), 1fr))" }}>
|
||||
{kpis.map((kpi) => (
|
||||
<Card key={kpi.label}>
|
||||
<CardHeader>
|
||||
<CardDescription>{kpi.label}</CardDescription>
|
||||
{loading ? null : (
|
||||
<CardAction>
|
||||
{kpi.id === "all" ? <Badge variant="secondary">전체</Badge> : statusBadge(kpi.id)}
|
||||
</CardAction>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="grid gap-3">
|
||||
<Skeleton style={{ height: 36, width: 80 }} />
|
||||
<Skeleton style={{ height: 8 }} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
<p style={{ margin: 0, fontSize: 28, lineHeight: "36px", fontWeight: 700 }}>{kpi.value}</p>
|
||||
<Progress value={kpi.progress} aria-label={`${kpi.label} 비율`} />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Screen({
|
||||
mode = "compact",
|
||||
data = dashboardWorks,
|
||||
loading,
|
||||
longTitle,
|
||||
wide,
|
||||
thirdLevel,
|
||||
}: {
|
||||
mode?: Mode
|
||||
data?: DashboardWork[]
|
||||
loading?: boolean
|
||||
longTitle?: boolean
|
||||
wide?: boolean
|
||||
thirdLevel?: boolean
|
||||
}) {
|
||||
const [current, setCurrent] = useState(thirdLevel ? "projects/tasks/all" : "projects/tasks")
|
||||
const [compactStatus, setCompactStatus] = useState<StatusFilter>("all")
|
||||
const compact = mode === "compact"
|
||||
const resultData = compact ? data.filter((row) => matchesDashboardWorkStatus(row, compactStatus)) : data
|
||||
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({})
|
||||
const go = (id: string) => (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
setCurrent(id)
|
||||
}
|
||||
const isCurrent = (id: string) => current === id || current.startsWith(`${id}/`)
|
||||
const nav = localNav(thirdLevel)
|
||||
const dashboardMenu = (nodes: LocalNode[]): ReactNode =>
|
||||
nodes.map((node) =>
|
||||
node.children ? (
|
||||
<Collapsible
|
||||
key={node.id}
|
||||
data-current={isCurrent(node.id) ? "" : undefined}
|
||||
open={openGroups[node.id] ?? true}
|
||||
onOpenChange={(value) => setOpenGroups((state) => ({ ...state, [node.id]: value }))}
|
||||
>
|
||||
<CollapsibleTrigger asChild><Button variant="ghost" type="button"><ChevronRightIcon aria-hidden="true" />{node.label}</Button></CollapsibleTrigger>
|
||||
<CollapsibleContent className="pl-3">{dashboardMenu(node.children)}</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : (
|
||||
<Button key={node.id} variant="ghost" asChild><a href={`#${node.id}`} aria-current={current === node.id ? "page" : undefined} onClick={go(node.id)}>
|
||||
{node.icon ? <node.icon aria-hidden="true" /> : null}
|
||||
{node.label}
|
||||
</a></Button>
|
||||
)
|
||||
)
|
||||
const title = longTitle ? "전사 프로젝트 포트폴리오의 진행 현황과 담당 부서별 업무 상세 현황" : "업무 현황"
|
||||
const sidebar = (
|
||||
<DashboardSidebar>
|
||||
<strong>프로젝트 대시보드</strong>
|
||||
<nav aria-label="Dashboard 업무 메뉴">{dashboardMenu(nav)}</nav>
|
||||
<ProfileMenu side="right" align="end" trigger={
|
||||
<Button type="button" variant="ghost" aria-label={`${profileData.name} 사용자 메뉴`}>
|
||||
<ProfileAvatar />{profileData.name}
|
||||
</Button>
|
||||
} />
|
||||
</DashboardSidebar>
|
||||
)
|
||||
|
||||
// Variant C content: heading, then the result card (Page Header's own 20px bottom spacing; no breadcrumb, actions or KPI cards in the reference).
|
||||
const compactMain = (
|
||||
<DashboardMain id="dashboard-compact-results">
|
||||
<PageHeader>
|
||||
<PageHeaderContent>
|
||||
<PageHeaderTitle>업무 현황을 한눈에 확인하세요</PageHeaderTitle>
|
||||
</PageHeaderContent>
|
||||
</PageHeader>
|
||||
<TaskList data={resultData} loading={loading} compact selectedStatus={compactStatus} onReset={() => setCompactStatus("all")} />
|
||||
</DashboardMain>
|
||||
)
|
||||
|
||||
const main = compact ? compactMain : (
|
||||
<DashboardMain>
|
||||
<PageHeader>
|
||||
<PageHeaderContent>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="#dashboard">홈</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="#projects">프로젝트</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>업무 현황</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<PageHeaderTitle>{title}</PageHeaderTitle>
|
||||
<PageHeaderDescription>프로젝트별 업무 진행 상황을 확인하고 조회합니다.</PageHeaderDescription>
|
||||
</PageHeaderContent>
|
||||
<PageHeaderActions>
|
||||
<Button type="button" variant="cancel" appearance="solid">
|
||||
다운로드
|
||||
</Button>
|
||||
<Button type="button" variant="primary">
|
||||
신규 등록
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" variant="primary" appearance="outline" size="icon" aria-label="더보기">
|
||||
<MoreHorizontalIcon />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem>일괄 수정</DropdownMenuItem>
|
||||
<DropdownMenuItem>보관</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</PageHeaderActions>
|
||||
</PageHeader>
|
||||
<DashboardContent>
|
||||
<Kpis data={data} loading={loading} />
|
||||
<TaskList data={resultData} loading={loading} />
|
||||
{wide ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>넓은 내용</CardTitle>
|
||||
<CardDescription>페이지를 밀지 않고 카드 안에서 가로 스크롤합니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<div style={{ width: 1600, height: 80, background: "var(--hm-sem-bg-content)" }} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</DashboardContent>
|
||||
</DashboardMain>
|
||||
)
|
||||
|
||||
return (
|
||||
<DashboardLayout
|
||||
className={compact ? "dashboard-story dashboard-story-compact" : "dashboard-story"}
|
||||
header={compact ? <CompactHeader data={data} status={compactStatus} onStatusChange={setCompactStatus} /> : mode === "header-sidebar" ? <DashboardHeader><strong>업무 현황</strong></DashboardHeader> : undefined}
|
||||
sidebar={compact ? undefined : sidebar}
|
||||
>
|
||||
{main}
|
||||
</DashboardLayout>
|
||||
)
|
||||
}
|
||||
|
||||
const frame = (height?: number): CSSProperties | undefined => (height ? ({ "--hm-dashboard-height": `${height}px` } as CSSProperties) : undefined)
|
||||
|
||||
// Shared by Storybook and the standalone Vite export. Keeping the representative
|
||||
// story composition here prevents the export from becoming a second hand-written UI.
|
||||
export function DashboardAll() {
|
||||
return (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.patterns}
|
||||
title="Dashboard"
|
||||
description="업무 화면의 전체 틀입니다. DASHBOARD 단일 모드로, 내부 48px Compact Header와 상태 필터 바 아래에 콘텐츠를 바로 잇습니다."
|
||||
/>
|
||||
<StorySection
|
||||
title="Overview"
|
||||
description="Compact Header(48px) · 상태 필터 · 콘텐츠. 최소 폭 960px을 유지하며 좁은 화면에서는 Dashboard 전체를 가로 스크롤합니다."
|
||||
>
|
||||
<DashboardScreen mode="compact" />
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
)
|
||||
}
|
||||
|
||||
// Menu label is Dashboard; the fixed id keeps the existing story IDs (patterns-layout-dashboard-layout--*) used by links and QA inventory.
|
||||
const meta = { id: "patterns-layout-dashboard-layout", title: "Patterns/Layout/Dashboard", excludeStories: ["DashboardAll"], parameters: { layout: "fullscreen", controls: { disable: true } } } satisfies Meta
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Representative Story reproduces the ui_viz Variant C layout (48px Compact Header, centred status bar, content) with HM parts.
|
||||
// Operating DASHBOARD Mode has no Sidebar; retained Sidebar examples are compatibility verification only.
|
||||
// The page heading and Dashboard's native minimum-width scroll frame share the common Inner.
|
||||
export const All: Story = {
|
||||
name: "Dashboard",
|
||||
render: () => <DashboardAll />,
|
||||
}
|
||||
// Keep existing Sidebar coverage and Story IDs; these are not operating references.
|
||||
function SidebarVerification({ children }: { children: ReactNode }) {
|
||||
return <>
|
||||
<p role="note">VERIFICATION ONLY — 운영 사용 금지</p>
|
||||
{children}
|
||||
</>
|
||||
}
|
||||
export const WithSidebar: Story = { tags: ["verification"], name: "VERIFICATION ONLY — 운영 사용 금지 · Dashboard Header + Sidebar", render: () => <SidebarVerification><Screen mode="header-sidebar" /></SidebarVerification> }
|
||||
export const ThirdLevel: Story = { tags: ["verification"], name: "VERIFICATION ONLY — 운영 사용 금지 · Third Level", render: () => <SidebarVerification><Screen mode="sidebar" thirdLevel /></SidebarVerification> }
|
||||
export const SidebarOnly: Story = { tags: ["verification"], name: "VERIFICATION ONLY — 운영 사용 금지 · Dashboard Sidebar", render: () => <SidebarVerification><Screen mode="sidebar" /></SidebarVerification> }
|
||||
export const LongContent: Story = { tags: ["verification"], name: "VERIFICATION ONLY — 운영 사용 금지 · Long Content", render: () => <SidebarVerification><Screen mode="sidebar" longTitle wide /></SidebarVerification> }
|
||||
export const Loading: Story = { tags: ["verification"], render: () => <Screen loading /> }
|
||||
export const Empty: Story = { tags: ["verification"], render: () => <Screen data={[]} /> }
|
||||
export const Framed: Story = { tags: ["verification"], name: "VERIFICATION ONLY — 운영 사용 금지 · Framed", render: () => <SidebarVerification><div style={frame(640)}><Screen mode="sidebar" /></div></SidebarVerification> }
|
||||
@@ -0,0 +1,2 @@
|
||||
/* Compatibility path for existing Storybook and repository-relative HTML imports. */
|
||||
@import "../../packages/ui/src/patterns/dashboard-status/dashboard-status.css";
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { CSSProperties } from "react"
|
||||
import {
|
||||
countDashboardFilter,
|
||||
dashboardFilterDefinitions,
|
||||
dashboardFilterIds,
|
||||
dashboardStatusDefinitions,
|
||||
matchesDashboardFilter,
|
||||
type DashboardFilterId,
|
||||
type DashboardStatusId,
|
||||
} from "@hm-design/ui"
|
||||
|
||||
// Story-local work data. User account states in sample-users.ts are a different domain.
|
||||
// Variant C treats issue as an independent flag: a held task can also have an issue.
|
||||
export type DashboardWorkStatus = DashboardStatusId
|
||||
export type DashboardWorkFilter = DashboardFilterId
|
||||
export type DashboardWork = {
|
||||
id: string
|
||||
name: string
|
||||
department: string
|
||||
status: DashboardWorkStatus
|
||||
issue?: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export const dashboardWorkStatus = {
|
||||
all: { label: dashboardFilterDefinitions.all.label, color: "var(--hm-sem-palette-yellow-accent)" },
|
||||
active: { label: dashboardStatusDefinitions.active.label, color: "var(--dashboard-work-progress)" },
|
||||
hold: { label: dashboardStatusDefinitions.hold.label, color: "var(--dashboard-work-hold)" },
|
||||
done: { label: dashboardStatusDefinitions.done.label, color: "var(--hm-dashboard-status-done)" },
|
||||
issue: { label: dashboardFilterDefinitions.issue.label, color: "var(--dashboard-work-issue)" },
|
||||
} satisfies Record<DashboardWorkFilter, { label: string; color: string }>
|
||||
|
||||
export const dashboardWorkFilters = dashboardFilterIds
|
||||
|
||||
// Both filter counts and the existing HM Badge consume this component mapping.
|
||||
export const dashboardWorkStyle = (status: DashboardWorkFilter): CSSProperties =>
|
||||
({ "--dashboard-status-color": dashboardWorkStatus[status].color } as CSSProperties)
|
||||
|
||||
export const matchesDashboardWorkStatus = (work: DashboardWork, status: DashboardWorkFilter) =>
|
||||
matchesDashboardFilter(work, status)
|
||||
|
||||
export const countDashboardWorkFilter = (works: readonly DashboardWork[], filter: DashboardWorkFilter) => countDashboardFilter(works, filter)
|
||||
|
||||
const names = ["대시보드 화면 검토", "업무 자료 정리", "현장 점검 보고", "설계 의견 반영", "품질 검토", "일정 확인", "도면 검수", "안전 점검", "회의록 작성", "최종 보고서 검토"]
|
||||
const departments = ["설계팀", "시공팀", "안전관리팀", "품질팀", "경영지원팀"]
|
||||
const statuses: DashboardWorkStatus[] = ["hold", "active", "done", "active"]
|
||||
|
||||
// Keep pagination coverage without reinterpreting any shared user fixture or public enum.
|
||||
export const dashboardWorks: DashboardWork[] = Array.from({ length: 47 }, (_, i) => ({
|
||||
id: `T-${String(i + 1).padStart(3, "0")}`,
|
||||
name: `${names[i % names.length]}${i >= names.length ? ` ${Math.floor(i / names.length) + 1}` : ""}`,
|
||||
department: departments[(i * 3) % departments.length],
|
||||
status: statuses[i % statuses.length],
|
||||
issue: i % 11 === 0,
|
||||
createdAt: new Date(2026, 9, 1 + (i % 28)).toISOString(),
|
||||
}))
|
||||
@@ -0,0 +1,207 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Combobox,
|
||||
DataTable,
|
||||
DataTableColumnHeader,
|
||||
DataTableViewOptions,
|
||||
DatePicker,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
FilterBar,
|
||||
FilterBarActions,
|
||||
FilterBarSearch,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
dataTableSelectColumn,
|
||||
toast,
|
||||
useDataTable,
|
||||
type DataTableColumnDef,
|
||||
} from "@hm-design/ui"
|
||||
import { MoreHorizontalIcon } from "lucide-react"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
import { formatDate, sameDay, sampleUsers, statusLabel, type SampleUser } from "./sample-users"
|
||||
|
||||
// Data Table is a pattern: the HM Table primitive driven by TanStack Table v9 (useDataTable). Columns and data stay referentially stable
|
||||
// (module scope / useMemo). Row actions use the HM Dropdown Menu and report through Sonner `toast` (Story composition only).
|
||||
|
||||
const statusBadge = (status: SampleUser["status"]) => (
|
||||
<Badge variant={status === "active" ? "default" : status === "pending" ? "secondary" : "outline"}>{statusLabel[status]}</Badge>
|
||||
)
|
||||
|
||||
const columnLabels = { name: "이름", department: "부서", status: "상태", createdAt: "등록일" }
|
||||
|
||||
const columns: DataTableColumnDef<SampleUser>[] = [
|
||||
dataTableSelectColumn<SampleUser>(),
|
||||
{ accessorKey: "name", header: ({ column }) => <DataTableColumnHeader column={column} title="이름" /> },
|
||||
{ accessorKey: "department", header: "부서" },
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "상태",
|
||||
cell: ({ row }) => statusBadge(row.original.status),
|
||||
filterFn: "equalsString",
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: ({ column }) => <DataTableColumnHeader column={column} title="등록일" />,
|
||||
cell: ({ row }) => formatDate(row.original.createdAt),
|
||||
filterFn: (row, columnId, value: Date) => sameDay(row.getValue<string>(columnId), value),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" variant="primary" appearance="outline" size="icon-sm" aria-label={`${row.original.name} 작업`}>
|
||||
<MoreHorizontalIcon />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => toast(`${row.original.name} 보기`)}>보기</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => toast(`${row.original.name} 수정`)}>수정</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" onSelect={() => toast.error(`${row.original.name} 삭제 요청`)}>
|
||||
삭제
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
function Basic({ data = sampleUsers, loading }: { data?: SampleUser[]; loading?: boolean }) {
|
||||
const table = useDataTable({ columns, data, getRowId: (row) => row.id })
|
||||
return (
|
||||
<div className="grid w-full gap-3">
|
||||
<div className="flex justify-end">
|
||||
<DataTableViewOptions table={table} labels={columnLabels} />
|
||||
</div>
|
||||
<DataTable table={table} loading={loading} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const statusOptions = [
|
||||
{ value: "active", label: "활성" },
|
||||
{ value: "pending", label: "대기" },
|
||||
{ value: "inactive", label: "비활성" },
|
||||
]
|
||||
const departmentOptions = [...new Set(sampleUsers.map((u) => u.department))].map((d) => ({ value: d, label: d }))
|
||||
|
||||
// Filter Bar → filter state → Data Table: search is the TanStack global filter, status / date are column filters.
|
||||
function WithFilterBar() {
|
||||
const data = useMemo(() => sampleUsers, [])
|
||||
const table = useDataTable({ columns, data, getRowId: (row) => row.id })
|
||||
const [search, setSearch] = useState("")
|
||||
const [status, setStatus] = useState("all")
|
||||
const [date, setDate] = useState<Date | undefined>()
|
||||
const [department, setDepartment] = useState<string | undefined>()
|
||||
|
||||
const setSearchValue = (value: string) => {
|
||||
setSearch(value)
|
||||
table.setGlobalFilter(value || undefined)
|
||||
}
|
||||
const reset = () => {
|
||||
setSearch("")
|
||||
setStatus("all")
|
||||
setDate(undefined)
|
||||
setDepartment(undefined)
|
||||
table.resetGlobalFilter(true)
|
||||
table.resetColumnFilters(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid w-full gap-4">
|
||||
<FilterBar>
|
||||
<FilterBarSearch value={search} onValueChange={setSearchValue} placeholder="이름·부서 검색" />
|
||||
<Select
|
||||
value={status}
|
||||
onValueChange={(value) => {
|
||||
setStatus(value)
|
||||
table.getColumn("status")?.setFilterValue(value === "all" ? undefined : value)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size="medium" aria-label="상태">
|
||||
<SelectValue placeholder="상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체 상태</SelectItem>
|
||||
{statusOptions.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Combobox
|
||||
controlled
|
||||
size="medium"
|
||||
aria-label="부서"
|
||||
options={departmentOptions}
|
||||
value={department}
|
||||
placeholder="부서"
|
||||
onValueChange={(value) => {
|
||||
setDepartment(value)
|
||||
setSearchValue(value)
|
||||
}}
|
||||
/>
|
||||
<DatePicker
|
||||
controlled
|
||||
size="medium"
|
||||
aria-label="등록일"
|
||||
placeholder="등록일"
|
||||
value={date}
|
||||
onValueChange={(value) => {
|
||||
setDate(value)
|
||||
table.getColumn("createdAt")?.setFilterValue(value)
|
||||
}}
|
||||
/>
|
||||
<FilterBarActions>
|
||||
<Button type="button" variant="cancel" appearance="outline" onClick={reset}>
|
||||
초기화
|
||||
</Button>
|
||||
<DataTableViewOptions table={table} labels={columnLabels} />
|
||||
</FilterBarActions>
|
||||
</FilterBar>
|
||||
<DataTable table={table} emptyMessage="조건에 맞는 사용자가 없습니다." />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = { title: "Patterns/Data/Data Table", parameters: { layout: "padded", controls: { disable: true } } } satisfies Meta
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Representative Story: selection (mixed header), sorting, column visibility, row actions and pagination with page size.
|
||||
export const All: Story = {
|
||||
name: "Data Table",
|
||||
parameters: { layout: "fullscreen" },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.patterns}
|
||||
title="Data Table"
|
||||
description="업무 목록을 표로 보여 주는 Pattern입니다. 선택·정렬·열 표시·페이지 이동을 하나의 표에서 다룹니다."
|
||||
/>
|
||||
<StorySection title="Overview" description="행 선택(헤더 혼합 상태), 정렬, 열 표시, 행 작업, 페이지 크기와 페이지 이동을 실제로 조작합니다.">
|
||||
<Basic />
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
// Combination Story: Filter Bar (search, status, department, date, reset) filtering the Data Table rows.
|
||||
export const WithFilterBarStory: Story = { tags: ["verification"], name: "With Filter Bar", render: () => <WithFilterBar /> }
|
||||
|
||||
export const Loading: Story = { tags: ["verification"], render: () => <Basic loading /> }
|
||||
export const Empty: Story = { tags: ["verification"], render: () => <Basic data={[]} /> }
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useState } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import {
|
||||
Button,
|
||||
Combobox,
|
||||
DatePicker,
|
||||
FilterBar,
|
||||
FilterBarActions,
|
||||
FilterBarSearch,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@hm-design/ui"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Filter Bar is a layout pattern: the consumer composes the HM controls as children and owns every value. It does not depend on Data Table.
|
||||
|
||||
const categories = [
|
||||
{ value: "design", label: "설계" },
|
||||
{ value: "build", label: "시공" },
|
||||
{ value: "safety", label: "안전" },
|
||||
]
|
||||
|
||||
function Full() {
|
||||
const [search, setSearch] = useState("")
|
||||
const [status, setStatus] = useState("all")
|
||||
const [category, setCategory] = useState<string | undefined>()
|
||||
const [date, setDate] = useState<Date | undefined>()
|
||||
const active = [search, status !== "all" && status, category, date].filter(Boolean).length
|
||||
return (
|
||||
<div className="grid w-full gap-3">
|
||||
<FilterBar>
|
||||
<FilterBarSearch value={search} onValueChange={setSearch} placeholder="검색어 입력" />
|
||||
<Select value={status} onValueChange={setStatus}>
|
||||
<SelectTrigger size="medium" aria-label="상태">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체 상태</SelectItem>
|
||||
<SelectItem value="active">활성</SelectItem>
|
||||
<SelectItem value="inactive">비활성</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Combobox controlled size="medium" aria-label="분류" options={categories} value={category} onValueChange={setCategory} placeholder="분류" />
|
||||
<DatePicker controlled size="medium" aria-label="날짜" value={date} onValueChange={setDate} placeholder="날짜" />
|
||||
<FilterBarActions>
|
||||
<Button
|
||||
type="button"
|
||||
variant="cancel"
|
||||
appearance="outline"
|
||||
onClick={() => {
|
||||
setSearch("")
|
||||
setStatus("all")
|
||||
setCategory(undefined)
|
||||
setDate(undefined)
|
||||
}}
|
||||
>
|
||||
초기화
|
||||
</Button>
|
||||
<Button type="button" variant="primary" appearance="outline">
|
||||
조회
|
||||
</Button>
|
||||
</FilterBarActions>
|
||||
</FilterBar>
|
||||
<p className="text-sm text-muted-foreground" role="status">
|
||||
적용된 조건 {active}개
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = { title: "Patterns/Data/Filter Bar", parameters: { layout: "padded", controls: { disable: true } } } satisfies Meta
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Representative Story: search, Select, Combobox, Date Picker and the actions row. Narrow it to 390px to see the wrapping.
|
||||
export const All: Story = {
|
||||
name: "Filter Bar",
|
||||
parameters: { layout: "fullscreen" },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.patterns}
|
||||
title="Filter Bar"
|
||||
description="목록 위에서 조회 조건을 고르는 레이아웃 Pattern입니다. HM 컨트롤을 children으로 조합하며 모든 값은 소비 코드가 소유합니다. Data Table에 의존하지 않습니다."
|
||||
/>
|
||||
<StorySection title="Overview" description="검색 · Select · Combobox · Date Picker와 초기화 / 조회 버튼입니다. 폭이 좁으면 줄바꿈됩니다.">
|
||||
<Full />
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
import { useEffect, useState, type CSSProperties, type MouseEvent, type ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { BellIcon, ChartColumnIcon, LayoutGridIcon, SearchIcon, SettingsIcon, type LucideIcon } from "lucide-react"
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
Badge,
|
||||
Button,
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
GNB,
|
||||
GNBBrand,
|
||||
GNBMobileGroup,
|
||||
GNBMobileLink,
|
||||
GNBMobileMenu,
|
||||
GNBNav,
|
||||
GNBUser,
|
||||
GNBUtilities,
|
||||
LNB,
|
||||
LNBGroup,
|
||||
LNBItem,
|
||||
LNBMobileGroup,
|
||||
LNBMobileLink,
|
||||
LNBNav,
|
||||
NavigationMenu,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuList,
|
||||
NavigationMenuTrigger,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@hm-design/ui"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// GNB is a composition pattern. Layout visual reference is the ui-viz GNB (green 70px bar, centered menu, Beige current item joined to
|
||||
// the content, White 2-depth panel, text-only profile); every part inside is an HM component. The menu data, the user and the current
|
||||
// location below are local sample data for this Story only (no network, no auth, no router). The desktop / mobile switch follows the
|
||||
// viewport (1024px): narrow the Story to 390px to see the hamburger and the Sheet menu.
|
||||
|
||||
type Destination = { id: string; label: string }
|
||||
type NavItem = { id: string; label: string; children?: Destination[] }
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ id: "dashboard", label: "대시보드" },
|
||||
{
|
||||
id: "projects",
|
||||
label: "프로젝트",
|
||||
children: [
|
||||
{ id: "projects/active", label: "진행 중 프로젝트" },
|
||||
{ id: "projects/done", label: "완료 프로젝트" },
|
||||
{ id: "projects/templates", label: "프로젝트 템플릿 관리와 기준 설정을 위한 긴 하위 메뉴 이름" },
|
||||
],
|
||||
},
|
||||
{ id: "reports", label: "리포트" },
|
||||
{
|
||||
id: "admin",
|
||||
label: "관리",
|
||||
children: [
|
||||
{ id: "admin/users", label: "사용자 관리" },
|
||||
{ id: "admin/org", label: "조직 관리" },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const longNavItems: NavItem[] = [
|
||||
{ id: "dashboard", label: "통합 대시보드와 현황 요약" },
|
||||
{ id: "projects", label: "전사 프로젝트 포트폴리오 관리", children: [{ id: "projects/active", label: "진행 중 프로젝트" }] },
|
||||
{ id: "reports", label: "정기 점검 결과 리포트" },
|
||||
]
|
||||
|
||||
const photo =
|
||||
"data:image/svg+xml;utf8," +
|
||||
encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80"><rect width="80" height="80" fill="#266651"/><circle cx="40" cy="31" r="14" fill="#bddfd4"/><rect x="14" y="50" width="52" height="40" rx="22" fill="#bddfd4"/></svg>')
|
||||
// An address that cannot load, so the Radix fallback shows.
|
||||
const brokenPhoto = "data:image/png;base64,broken"
|
||||
|
||||
const user = { image: photo, name: "김한맥", description: "프로젝트 관리자" }
|
||||
const longUser = { image: brokenPhoto, name: "김한맥 (한맥기술 인프라사업본부 스마트시설팀)", description: "통합 시스템 관리자 · 인프라사업본부" }
|
||||
|
||||
// A test logo (not a Hanmac asset), only to check the logo layout.
|
||||
const testLogo =
|
||||
"data:image/svg+xml;utf8," +
|
||||
encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="120" height="32"><rect width="120" height="32" rx="4" fill="#fff" fill-opacity=".9"/><text x="60" y="21" font-size="13" font-weight="700" text-anchor="middle" fill="#123328">TEST LOGO</text></svg>')
|
||||
|
||||
// The content that follows the bar: positioned, later in the DOM, top corners rounded by the join, so the bar's green continues behind it.
|
||||
const contentStyle: CSSProperties = {
|
||||
position: "relative",
|
||||
minHeight: 220,
|
||||
padding: 34,
|
||||
borderRadius: "var(--hm-gnb-join) var(--hm-gnb-join) 0 0",
|
||||
background: "var(--hm-sem-bg-content)",
|
||||
}
|
||||
|
||||
function isCurrent(current: string, id: string) {
|
||||
return current === id || current.startsWith(`${id}/`)
|
||||
}
|
||||
|
||||
function SearchAction() {
|
||||
return (
|
||||
<Popover>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="icon" aria-label="검색">
|
||||
<SearchIcon aria-hidden="true" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>검색</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent align="end" style={{ "--hm-popover-padding": "0", "--hm-popover-width": "320px" } as CSSProperties}>
|
||||
<Command>
|
||||
<CommandInput placeholder="메뉴·프로젝트 검색" />
|
||||
<CommandList>
|
||||
<CommandEmpty>결과가 없습니다.</CommandEmpty>
|
||||
<CommandGroup heading="바로가기">
|
||||
<CommandItem>진행 중 프로젝트</CommandItem>
|
||||
<CommandItem>월간 리포트</CommandItem>
|
||||
<CommandItem>사용자 관리</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
function NotificationAction({ count = 3 }: { count?: number }) {
|
||||
return (
|
||||
<Popover>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="icon" aria-label={`알림 ${count}건`} style={{ position: "relative" }}>
|
||||
<BellIcon aria-hidden="true" />
|
||||
<Badge
|
||||
variant="destructive"
|
||||
aria-hidden="true"
|
||||
className="size-6"
|
||||
style={{ position: "absolute", top: -8, right: -8, padding: 0, borderRadius: "50%" }}
|
||||
>
|
||||
{count}
|
||||
</Badge>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>알림</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent align="end" style={{ "--hm-popover-width": "300px" } as CSSProperties}>
|
||||
<div className="grid gap-2">
|
||||
<p className="font-bold">새 알림</p>
|
||||
<p>1호선 구간 점검 결과가 등록되었습니다.</p>
|
||||
<p>월간 리포트 승인이 요청되었습니다.</p>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
function UserMenu({ person, defaultOpen }: { person: { name: string; description: string; image?: string }; defaultOpen?: boolean }) {
|
||||
return (
|
||||
<DropdownMenu defaultOpen={defaultOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<GNBUser name={person.name} description={person.description}>
|
||||
<Avatar>
|
||||
{person.image ? <AvatarImage src={person.image} alt="" /> : null}
|
||||
<AvatarFallback>{person.name.slice(0, 1)}</AvatarFallback>
|
||||
</Avatar>
|
||||
</GNBUser>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>{person.name}</DropdownMenuLabel>
|
||||
<DropdownMenuItem>프로필</DropdownMenuItem>
|
||||
<DropdownMenuItem>설정</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive">로그아웃</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
function Bar({
|
||||
items = navItems,
|
||||
person = user,
|
||||
eyebrow = "한맥 업무 시스템",
|
||||
brand = "프로젝트 대시보드",
|
||||
logo,
|
||||
initial = "dashboard",
|
||||
openMenu,
|
||||
userOpen,
|
||||
mobileOpen,
|
||||
sticky,
|
||||
utilities,
|
||||
onCurrent,
|
||||
current: currentProp,
|
||||
mobileChildren,
|
||||
mobileMenuOpen,
|
||||
onMobileMenuOpenChange,
|
||||
}: {
|
||||
items?: NavItem[]
|
||||
person?: { name: string; description: string }
|
||||
eyebrow?: string
|
||||
brand?: string
|
||||
logo?: string
|
||||
initial?: string
|
||||
openMenu?: string
|
||||
userOpen?: boolean
|
||||
mobileOpen?: boolean
|
||||
sticky?: boolean
|
||||
utilities?: boolean
|
||||
onCurrent?: (id: string) => void
|
||||
current?: string
|
||||
mobileChildren?: ReactNode
|
||||
mobileMenuOpen?: boolean
|
||||
onMobileMenuOpenChange?: (open: boolean) => void
|
||||
}) {
|
||||
const [localCurrent, setCurrent] = useState(initial)
|
||||
const current = currentProp ?? localCurrent
|
||||
const go = (id: string) => (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
if (currentProp === undefined) setCurrent(id)
|
||||
onCurrent?.(id)
|
||||
}
|
||||
return (
|
||||
<GNB sticky={sticky}>
|
||||
<GNBMobileMenu title="메뉴" defaultOpen={mobileOpen} open={mobileMenuOpen} onOpenChange={onMobileMenuOpenChange}>
|
||||
{mobileChildren ?? items.map((item) =>
|
||||
item.children ? (
|
||||
<GNBMobileGroup key={item.id} label={item.label}>
|
||||
{item.children.map((child) => (
|
||||
<GNBMobileLink key={child.id} href={`#${child.id}`} active={current === child.id} onClick={go(child.id)}>
|
||||
{child.label}
|
||||
</GNBMobileLink>
|
||||
))}
|
||||
</GNBMobileGroup>
|
||||
) : (
|
||||
<GNBMobileLink key={item.id} href={`#${item.id}`} active={current === item.id} onClick={go(item.id)}>
|
||||
{item.label}
|
||||
</GNBMobileLink>
|
||||
)
|
||||
)}
|
||||
</GNBMobileMenu>
|
||||
<GNBBrand href="#dashboard" onClick={go("dashboard")}>
|
||||
{logo ? <img src={logo} alt="" /> : null}
|
||||
<span>
|
||||
{eyebrow ? <small>{eyebrow}</small> : null}
|
||||
<strong>{brand}</strong>
|
||||
</span>
|
||||
</GNBBrand>
|
||||
<GNBNav>
|
||||
<NavigationMenu viewport={false} aria-label="주 메뉴" defaultValue={openMenu}>
|
||||
<NavigationMenuList>
|
||||
{items.map((item) =>
|
||||
item.children ? (
|
||||
<NavigationMenuItem key={item.id} value={item.id}>
|
||||
<NavigationMenuTrigger aria-current={isCurrent(current, item.id) ? "page" : undefined}>{item.label}</NavigationMenuTrigger>
|
||||
<NavigationMenuContent>
|
||||
<ul>
|
||||
{item.children.map((child) => (
|
||||
<li key={child.id}>
|
||||
<NavigationMenuLink href={`#${child.id}`} active={current === child.id} onClick={go(child.id)}>
|
||||
{child.label}
|
||||
</NavigationMenuLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</NavigationMenuContent>
|
||||
</NavigationMenuItem>
|
||||
) : (
|
||||
<NavigationMenuItem key={item.id}>
|
||||
<NavigationMenuLink href={`#${item.id}`} active={current === item.id} onClick={go(item.id)}>
|
||||
{item.label}
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
)
|
||||
)}
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>
|
||||
</GNBNav>
|
||||
<GNBUtilities>
|
||||
{utilities ? (
|
||||
<>
|
||||
<SearchAction />
|
||||
<NotificationAction />
|
||||
</>
|
||||
) : null}
|
||||
<UserMenu person={person} defaultOpen={userOpen} />
|
||||
</GNBUtilities>
|
||||
</GNB>
|
||||
)
|
||||
}
|
||||
|
||||
// The bar with the content that follows it (the GNB Mode page owns this part; here it only shows the join).
|
||||
function Page({ children, label }: { children: ReactNode; label?: string }) {
|
||||
return (
|
||||
<div>
|
||||
{children}
|
||||
<div style={contentStyle}>
|
||||
<p className="text-sm text-muted-foreground">{label ?? "연결된 콘텐츠 영역: GNB는 자기 높이와 12px 접점까지만 차지합니다."}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StickyPage() {
|
||||
return (
|
||||
<div
|
||||
role="region"
|
||||
aria-label="Sticky GNB 스크롤 예시"
|
||||
tabIndex={0}
|
||||
style={{ height: 360, overflow: "auto", scrollbarWidth: "none" }}
|
||||
>
|
||||
<Bar sticky />
|
||||
<div style={{ ...contentStyle, minHeight: 900 }} className="grid content-start gap-3 text-sm text-muted-foreground">
|
||||
{Array.from({ length: 24 }, (_, i) => (
|
||||
<p key={i}>스크롤 본문 {i + 1}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Consumer-owned data and route state; every menu face/disclosure is an existing public LNB part.
|
||||
type SidebarNode = { id: string; label: string; icon?: LucideIcon; children?: SidebarNode[] }
|
||||
const sidebarNodes: SidebarNode[] = [
|
||||
{ id: "dashboard", label: "대시보드", icon: LayoutGridIcon },
|
||||
{
|
||||
id: "projects", label: "프로젝트 관리", children: [
|
||||
{ id: "projects/design", label: "디자인 관리", children: [
|
||||
{ id: "projects/design/tokens", label: "토큰 관리" },
|
||||
{ id: "projects/design/components", label: "컴포넌트 설계와 사용 규칙을 검토하는 긴 메뉴 이름" },
|
||||
{ id: "projects/design/layout", label: "레이아웃 관리" },
|
||||
] },
|
||||
{ id: "projects/tasks", label: "업무 목록" },
|
||||
{ id: "projects/schedule", label: "일정 관리" },
|
||||
...Array.from({ length: 8 }, (_, i) => ({ id: `projects/extra-${i + 1}`, label: `프로젝트 점검 메뉴 ${i + 1}` })),
|
||||
],
|
||||
},
|
||||
{ id: "reports", label: "현황 보고", icon: ChartColumnIcon },
|
||||
{ id: "admin", label: "관리", children: [
|
||||
{ id: "admin/users", label: "사용자 관리" },
|
||||
{ id: "admin/org", label: "조직 관리" },
|
||||
] },
|
||||
{ id: "settings", label: "설정", icon: SettingsIcon },
|
||||
]
|
||||
const sidebarGnbItems: NavItem[] = navItems.map(item => item.id === "projects" ? {
|
||||
...item, children: [
|
||||
{ id: "projects/design/tokens", label: "토큰 관리" },
|
||||
{ id: "projects/tasks", label: "업무 목록" },
|
||||
],
|
||||
} : item)
|
||||
|
||||
function SidebarPage({ fullHeight = false }: { fullHeight?: boolean }) {
|
||||
const [current, setCurrent] = useState("projects/design/tokens")
|
||||
const [groups, setGroups] = useState<Record<string, boolean>>({ projects: true, "projects/design": true })
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
// Same 1024/1023 contract as LNBMobileMenu; the sole Sheet belongs to the GNB header.
|
||||
useEffect(() => {
|
||||
const desktop = window.matchMedia("(min-width: 1024px)")
|
||||
const closeOnDesktop = () => { if (desktop.matches) setMobileOpen(false) }
|
||||
desktop.addEventListener("change", closeOnDesktop)
|
||||
return () => desktop.removeEventListener("change", closeOnDesktop)
|
||||
}, [])
|
||||
const go = (id: string) => (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
setCurrent(id)
|
||||
// LNBMobileLink has its own context: close this GNB-owned Sheet in the shared route handler.
|
||||
setMobileOpen(false)
|
||||
}
|
||||
const desktopMenu = (nodes: SidebarNode[]): ReactNode => nodes.map(node => node.children ? (
|
||||
<LNBGroup key={node.id} label={node.label} current={isCurrent(current, node.id)}
|
||||
open={groups[node.id] ?? false} onOpenChange={open => setGroups(state => ({ ...state, [node.id]: open }))}>
|
||||
{desktopMenu(node.children)}
|
||||
</LNBGroup>
|
||||
) : (
|
||||
<LNBItem key={node.id} href={`#${node.id}`} active={current === node.id} onClick={go(node.id)}>
|
||||
{node.icon ? <node.icon aria-hidden="true" /> : null}{node.label}
|
||||
</LNBItem>
|
||||
))
|
||||
// LNB's existing mobile view is an expanded link list, not a new disclosure menu.
|
||||
const mobileMenu = (nodes: SidebarNode[]): ReactNode => nodes.map(node => node.children ? (
|
||||
<LNBMobileGroup key={node.id} label={node.label}>{mobileMenu(node.children)}</LNBMobileGroup>
|
||||
) : (
|
||||
<LNBMobileLink key={node.id} href={`#${node.id}`} active={current === node.id} onClick={go(node.id)}>{node.label}</LNBMobileLink>
|
||||
))
|
||||
return (
|
||||
<div data-hm-gnb-layout="sidebar" style={{ height: fullHeight ? "100dvh" : 560 }}>
|
||||
<Bar items={sidebarGnbItems} current={current} onCurrent={setCurrent}
|
||||
mobileMenuOpen={mobileOpen} onMobileMenuOpenChange={setMobileOpen}
|
||||
mobileChildren={<div data-slot="lnb-mobile-nav">{mobileMenu(sidebarNodes)}</div>} />
|
||||
<div data-hm-gnb-body>
|
||||
<LNB data-hm-gnb-sidebar>
|
||||
<LNBNav aria-label="서비스 메뉴">{desktopMenu(sidebarNodes)}</LNBNav>
|
||||
</LNB>
|
||||
<main data-hm-gnb-main>
|
||||
<div style={{ padding: contentStyle.padding }}>
|
||||
<h2 className="text-lg font-bold">프로젝트 업무 화면</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground" data-current-menu>선택: {current}</p>
|
||||
<p className="mt-4 text-sm">선택한 메뉴의 콘텐츠를 표시합니다.</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = { title: "Patterns/Layout/GNB", component: GNB, parameters: { layout: "fullscreen", controls: { disable: true } } } satisfies Meta<typeof GNB>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Representative Story: the green bar with a current item, an open 2-depth panel, long labels, a logo, the optional utility icons and
|
||||
// sticky. Menus and the user menu open for real. Narrow the page to 390px for the mobile bar (hamburger → Sheet).
|
||||
// Each GNB fills its specimen parent inside the same Inner as the page and section headings.
|
||||
export const All: Story = {
|
||||
name: "GNB",
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.patterns}
|
||||
title="GNB"
|
||||
description="서비스 최상단의 전역 내비게이션입니다. 1024px 이상은 Desktop 메뉴, 1023px 이하는 햄버거 → Sheet 메뉴를 사용합니다."
|
||||
/>
|
||||
<StorySection
|
||||
title="Overview"
|
||||
description="현재 위치(대시보드)는 Beige 면이 아래 콘텐츠와 오목하게 이어집니다. hover는 Black 10%. 프로젝트·관리는 hover/click으로 White 하위 패널이 열립니다."
|
||||
>
|
||||
<Page>
|
||||
<Bar />
|
||||
</Page>
|
||||
</StorySection>
|
||||
<StorySection
|
||||
title="States · Navigation Menu Open"
|
||||
description="프로젝트 메뉴가 열려 White 면으로 패널과 이어지고, 하위 현재 위치(진행 중 프로젝트)는 Beige로 표시됩니다. 긴 하위 이름은 Desktop 패널 폭을 확장하여 한 줄로 표시합니다."
|
||||
>
|
||||
<div style={{ minHeight: 360 }}>
|
||||
<Page label="프로젝트 > 진행 중 프로젝트">
|
||||
<Bar initial="projects/active" openMenu="projects" />
|
||||
</Page>
|
||||
</div>
|
||||
</StorySection>
|
||||
<StorySection
|
||||
title="Variants · Long Labels"
|
||||
description="긴 브랜드명·메뉴명·사용자명. 브랜드와 사용자 글자는 말줄임, 메뉴 항목은 줄바꿈 없이 유지되고 공간을 넘으면 메뉴 영역에서 잘립니다."
|
||||
>
|
||||
<Page>
|
||||
<Bar items={longNavItems} person={longUser} eyebrow="한맥기술 인프라 통합 시스템" brand="스마트 시설물 통합 관리 시스템" />
|
||||
</Page>
|
||||
</StorySection>
|
||||
<StorySection
|
||||
title="Variants · With Logo"
|
||||
description="로고 asset이 있을 때만 왼쪽에 원본 비율로 놓입니다. 없으면 영역·간격 없이 글자만 둡니다(Overview). 표시 이미지는 배치 확인용 테스트 이미지이며 한맥 로고가 아닙니다."
|
||||
>
|
||||
<Page>
|
||||
<Bar logo={testLogo} />
|
||||
</Page>
|
||||
</StorySection>
|
||||
<StorySection
|
||||
title="Variants · Utilities"
|
||||
description="선택 사항. ui-viz의 GNB 오른쪽은 이름·역할만 둡니다. 검색·알림이 필요한 서비스는 HM ghost Button 아이콘을 GNBUtilities에 조합합니다(Tooltip, Badge, Popover, Command)."
|
||||
>
|
||||
<Page>
|
||||
<Bar utilities />
|
||||
</Page>
|
||||
</StorySection>
|
||||
<StorySection
|
||||
title="Usage · Sticky"
|
||||
description="일반 GNB와 같은 외형으로 스크롤 영역 상단에 붙습니다. 본문 위에서 휠/터치로 스크롤하거나 영역에 포커스한 뒤 PageDown/End/Home 키로 확인합니다."
|
||||
>
|
||||
<StickyPage />
|
||||
</StorySection>
|
||||
<StorySection title="Usage · GNB Sidebar"
|
||||
description="GNB ONLY 내부 Sidebar입니다. 기존 LNB 메뉴를 그대로 사용하고 외곽 배경만 Green Light 2로 바꿉니다. 모바일은 GNB의 단일 Sheet에 같은 메뉴를 표시합니다.">
|
||||
<SidebarPage />
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const Default: Story = { tags: ["verification"], render: () => <Page><Bar /></Page> }
|
||||
export const Sticky: Story = { tags: ["verification"], render: () => <StickyPage /> }
|
||||
export const Sidebar: Story = { tags: ["verification"], name: "GNB Sidebar", render: () => <SidebarPage fullHeight /> }
|
||||
export const NavigationMenuOpen: Story = {
|
||||
tags: ["verification"],
|
||||
name: "Navigation Menu Open",
|
||||
render: () => (
|
||||
<div style={{ minHeight: 360 }}>
|
||||
<Page label="프로젝트 > 진행 중 프로젝트">
|
||||
<Bar initial="projects/active" openMenu="projects" />
|
||||
</Page>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
export const ShortDepthOpen: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => <Page><Bar openMenu="admin" /></Page>,
|
||||
}
|
||||
export const RightEdgeLongDepth: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => <Page><Bar openMenu="admin" items={navItems.map((item) => item.id === "admin" ? { ...item, children: navItems[1].children } : item)} /></Page>,
|
||||
}
|
||||
|
||||
// First / middle / last parents, each with the same short / long 2Depth labels.
|
||||
// Position and width remain Production GNB's; Stories only supply menu data.
|
||||
const depthAlignmentItems = (long: boolean): NavItem[] => ["first", "middle", "last"].map((id, index) => ({
|
||||
id,
|
||||
label: ["첫 메뉴", "중간 메뉴", "마지막 메뉴"][index],
|
||||
children: long ? navItems[1].children : [{ id: `${id}/short`, label: "짧은 메뉴" }],
|
||||
}))
|
||||
const depthAlignmentRender = (openMenu: string, long: boolean) => () => <Page><Bar items={depthAlignmentItems(long)} openMenu={openMenu} /></Page>
|
||||
export const FirstDepthShort: Story = { tags: ["verification"], render: depthAlignmentRender("first", false) }
|
||||
export const FirstDepthLong: Story = { tags: ["verification"], render: depthAlignmentRender("first", true) }
|
||||
export const MiddleDepthShort: Story = { tags: ["verification"], render: depthAlignmentRender("middle", false) }
|
||||
export const MiddleDepthLong: Story = { tags: ["verification"], render: depthAlignmentRender("middle", true) }
|
||||
export const LastDepthShort: Story = { tags: ["verification"], render: depthAlignmentRender("last", false) }
|
||||
export const LastDepthLong: Story = { tags: ["verification"], render: depthAlignmentRender("last", true) }
|
||||
export const EdgeFirstLong: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => <div style={{ width: 600, maxWidth: "100%" }}><Page><Bar items={depthAlignmentItems(true)} openMenu="first" /></Page></div>,
|
||||
}
|
||||
export const EdgeLastLong: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => <div style={{ width: 600, maxWidth: "100%", marginLeft: "auto" }}><Page><Bar items={depthAlignmentItems(true)} openMenu="last" /></Page></div>,
|
||||
}
|
||||
export const UserMenuOpen: Story = { tags: ["verification"], name: "User Menu Open", render: () => <Page><Bar userOpen /></Page> }
|
||||
export const LongLabels: Story = {
|
||||
tags: ["verification"],
|
||||
name: "Long Labels",
|
||||
render: () => (
|
||||
<Page>
|
||||
<Bar items={longNavItems} person={longUser} eyebrow="한맥기술 인프라 통합 시스템" brand="스마트 시설물 통합 관리 시스템" />
|
||||
</Page>
|
||||
),
|
||||
}
|
||||
export const WithLogo: Story = { tags: ["verification"], name: "With Logo", render: () => <Page><Bar logo={testLogo} /></Page> }
|
||||
export const Utilities: Story = { tags: ["verification"], render: () => <Page><Bar utilities /></Page> }
|
||||
// Mobile verification: use a 390px viewport (the switch is a viewport media query, so it cannot be shown inside a narrow container).
|
||||
export const Mobile: Story = { tags: ["verification"], globals: { viewport: { value: "hmMobile390", isRotated: false } }, render: () => <Page><Bar initial="projects/active" utilities /></Page> }
|
||||
export const MobileMenuOpen: Story = {
|
||||
tags: ["verification"],
|
||||
name: "Mobile Menu Open",
|
||||
globals: { viewport: { value: "hmMobile390", isRotated: false } },
|
||||
render: () => (
|
||||
<Page>
|
||||
<Bar initial="projects/active" mobileOpen />
|
||||
</Page>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,756 @@
|
||||
import { Fragment, useState, type CSSProperties, type MouseEvent, type ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { ChartColumnIcon, LayoutGridIcon, ListChecksIcon, SettingsIcon, type LucideIcon } from "lucide-react"
|
||||
import {
|
||||
GNB,
|
||||
GNBBrand,
|
||||
GNBMobileMenu,
|
||||
GNBNav,
|
||||
GNBUser,
|
||||
GNBUtilities,
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
LNB,
|
||||
LNBBrand,
|
||||
LNBGroup,
|
||||
LNBItem,
|
||||
LNBNav,
|
||||
LNBProfile,
|
||||
LNBMobileHeader,
|
||||
LNBMobileBrand,
|
||||
LNBMobileGroup,
|
||||
LNBMobileLink,
|
||||
LNBMobileMenu,
|
||||
} from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// LNB is a composition pattern. Layout visual reference is the ui-viz LNB (272px green column, 18/700 items, Beige current item joined
|
||||
// to the content, Black 10% hover, Black 20% open group). The menu data and the current location are local sample data for this Story
|
||||
// (no router). The content next to the column is a positioned surface with `--hm-lnb-join` rounded left corners: the LNB Mode page
|
||||
// owns it, here it only shows the connection. The column is desktop only (hidden below 1024px).
|
||||
|
||||
const testLogo =
|
||||
"data:image/svg+xml;utf8," +
|
||||
encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="120" height="32"><rect width="120" height="32" rx="4" fill="#fff" fill-opacity=".9"/><text x="60" y="21" font-size="13" font-weight="700" text-anchor="middle" fill="#123328">TEST LOGO</text></svg>')
|
||||
|
||||
const photo =
|
||||
"data:image/svg+xml;utf8," +
|
||||
encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80"><rect width="80" height="80" fill="#266651"/><circle cx="40" cy="31" r="14" fill="#bddfd4"/><rect x="14" y="50" width="52" height="40" rx="22" fill="#bddfd4"/></svg>')
|
||||
// An address that cannot load, so the Radix fallback shows.
|
||||
const brokenPhoto = "data:image/png;base64,broken"
|
||||
|
||||
const profileData = {
|
||||
name: "김한맥",
|
||||
description: "프로젝트 관리자",
|
||||
photo,
|
||||
fallback: "김",
|
||||
}
|
||||
|
||||
const profileActions: { label: string; destructive?: boolean }[] = [
|
||||
{ label: "프로필 설정" },
|
||||
{ label: "로그아웃", destructive: true },
|
||||
]
|
||||
|
||||
function ProfileAvatar() {
|
||||
return (
|
||||
<Avatar>
|
||||
<AvatarImage src={profileData.photo} alt="" />
|
||||
<AvatarFallback>{profileData.fallback}</AvatarFallback>
|
||||
</Avatar>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileMenu({ trigger, side = "bottom", align = "start", modal, matchTrigger = false, defaultOpen = false }: { trigger: ReactNode; side?: "top" | "right" | "bottom" | "left"; align?: "start" | "center" | "end"; modal?: boolean; matchTrigger?: boolean; defaultOpen?: boolean }) {
|
||||
return (
|
||||
<DropdownMenu modal={modal} defaultOpen={defaultOpen}>
|
||||
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side={side} align={align} className={matchTrigger ? "min-w-(--radix-dropdown-menu-trigger-width)" : undefined}>
|
||||
<DropdownMenuLabel>{profileData.name}</DropdownMenuLabel>
|
||||
{profileActions.map((action) => (
|
||||
<Fragment key={action.label}>
|
||||
{action.destructive ? <DropdownMenuSeparator /> : null}
|
||||
<DropdownMenuItem variant={action.destructive ? "destructive" : "default"}>{action.label}</DropdownMenuItem>
|
||||
</Fragment>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
const shellStyle: CSSProperties = {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "var(--hm-lnb-width) minmax(0, 1fr)",
|
||||
minHeight: 460,
|
||||
background: "var(--hm-lnb-background-end)",
|
||||
}
|
||||
const contentStyle: CSSProperties = {
|
||||
position: "relative",
|
||||
// Keep connected content in the second track when the first-track LNB becomes display:none at <=1023px.
|
||||
// Without explicit placement, Grid auto-placement moves it into the now-empty 0px LNB track.
|
||||
gridColumn: 2,
|
||||
minWidth: 0,
|
||||
padding: 34,
|
||||
borderRadius: "var(--hm-lnb-join) 0 0 var(--hm-lnb-join)",
|
||||
background: "var(--hm-sem-bg-content)",
|
||||
}
|
||||
|
||||
type Child = { id: string; label: string }
|
||||
type LocalNode = { id: string; label: string; icon?: LucideIcon; children?: LocalNode[] }
|
||||
|
||||
function Menu({
|
||||
initial = "overview",
|
||||
groupOpen = true,
|
||||
logo,
|
||||
eyebrow = "한맥 업무 시스템",
|
||||
brand = "프로젝트 대시보드",
|
||||
longLabels,
|
||||
many,
|
||||
height,
|
||||
profile = true,
|
||||
}: {
|
||||
initial?: string | null
|
||||
groupOpen?: boolean
|
||||
logo?: string
|
||||
eyebrow?: string
|
||||
/** `null`: no LNBBrand (a GNB above carries the service identity). */
|
||||
brand?: string | null
|
||||
longLabels?: boolean
|
||||
many?: boolean
|
||||
height?: number
|
||||
/** Story composition only: GNB + LNB gives profile ownership to the GNB. */
|
||||
profile?: boolean
|
||||
}) {
|
||||
const [current, setCurrent] = useState<string | null>(initial)
|
||||
const [open, setOpen] = useState(groupOpen)
|
||||
const go = (id: string) => (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
setCurrent(id)
|
||||
}
|
||||
const children: Child[] = longLabels
|
||||
? [
|
||||
{ id: "tasks/list", label: "전사 프로젝트 포트폴리오의 진행 현황 목록" },
|
||||
{ id: "tasks/board", label: "업무 보드" },
|
||||
]
|
||||
: [
|
||||
{ id: "tasks/list", label: "메뉴 2-1" },
|
||||
{ id: "tasks/board", label: "메뉴 2-2" },
|
||||
]
|
||||
const extra = many ? Array.from({ length: 8 }, (_, i) => ({ id: `extra-${i}`, label: `추가 메뉴 ${i + 1}` })) : []
|
||||
return (
|
||||
<div style={{ ...shellStyle, height }}>
|
||||
<LNB>
|
||||
{brand !== null ? (
|
||||
<LNBBrand>
|
||||
{logo ? <img src={logo} alt="" /> : null}
|
||||
<span>
|
||||
{eyebrow ? <small>{eyebrow}</small> : null}
|
||||
<strong>{brand}</strong>
|
||||
</span>
|
||||
</LNBBrand>
|
||||
) : null}
|
||||
<LNBNav aria-label="서비스 메뉴">
|
||||
<LNBItem href="#overview" active={current === "overview"} onClick={go("overview")}>
|
||||
<LayoutGridIcon aria-hidden="true" />
|
||||
{longLabels ? "프로젝트 현황과 전체 일정을 한눈에 보는 개요" : "메뉴 1"}
|
||||
</LNBItem>
|
||||
<LNBGroup label={longLabels ? "업무 관리와 진행 상황 점검" : "메뉴 2"} current={Boolean(current?.startsWith("tasks/"))} open={open} onOpenChange={setOpen}>
|
||||
{children.map((child) => (
|
||||
<LNBItem key={child.id} href={`#${child.id}`} active={current === child.id} onClick={go(child.id)}>
|
||||
{child.label}
|
||||
</LNBItem>
|
||||
))}
|
||||
</LNBGroup>
|
||||
<LNBItem href="#reports" active={current === "reports"} onClick={go("reports")}>
|
||||
<ChartColumnIcon aria-hidden="true" />
|
||||
메뉴 3
|
||||
</LNBItem>
|
||||
<LNBItem href="#settings" active={current === "settings"} onClick={go("settings")}>
|
||||
<SettingsIcon aria-hidden="true" />
|
||||
메뉴 4
|
||||
</LNBItem>
|
||||
{extra.map((item) => (
|
||||
<LNBItem key={item.id} href={`#${item.id}`} active={current === item.id} onClick={go(item.id)}>
|
||||
<ListChecksIcon aria-hidden="true" />
|
||||
{item.label}
|
||||
</LNBItem>
|
||||
))}
|
||||
</LNBNav>
|
||||
{profile ? (
|
||||
<ProfileMenu
|
||||
side="right"
|
||||
align="end"
|
||||
matchTrigger
|
||||
trigger={
|
||||
<LNBProfile name={longLabels ? "김한맥 (한맥기술 인프라사업본부 스마트시설팀)" : profileData.name} description={longLabels ? "통합 시스템 관리자 · 인프라사업본부" : profileData.description}>
|
||||
<Avatar>
|
||||
<AvatarImage src={longLabels ? brokenPhoto : profileData.photo} alt="" />
|
||||
<AvatarFallback>{profileData.fallback}</AvatarFallback>
|
||||
</Avatar>
|
||||
</LNBProfile>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</LNB>
|
||||
<div style={contentStyle}>
|
||||
<h2 className="text-lg font-bold">연결된 콘텐츠</h2>
|
||||
<p className="text-sm text-muted-foreground" data-current-menu>
|
||||
선택: {current ?? "없음"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 3-depth: an `LNBGroup` nested in a group (no depth prop). The selected link is `active`, every group on its path is `current`, and
|
||||
// each group's `open` is kept here so the nested group stays open after the outer one is closed and reopened.
|
||||
const thirdLevelChildren = (long?: boolean): Child[] =>
|
||||
long
|
||||
? [
|
||||
{ id: "projects/design/tokens", label: "컬러 토큰" },
|
||||
{ id: "projects/design/components", label: "컴포넌트 스타일 규칙" },
|
||||
{ id: "projects/design/layout", label: "레이아웃 규칙과 화면 폭별 여백 그리드 기준" },
|
||||
]
|
||||
: [
|
||||
{ id: "projects/design/tokens", label: "컬러 토큰" },
|
||||
{ id: "projects/design/components", label: "컴포넌트 스타일" },
|
||||
{ id: "projects/design/layout", label: "레이아웃 규칙" },
|
||||
]
|
||||
|
||||
function ThirdLevelMenu({
|
||||
initial = "projects/design/tokens",
|
||||
projectOpen = true,
|
||||
designOpen = true,
|
||||
longLabels,
|
||||
many,
|
||||
height,
|
||||
}: {
|
||||
initial?: string | null
|
||||
projectOpen?: boolean
|
||||
designOpen?: boolean
|
||||
longLabels?: boolean
|
||||
many?: boolean
|
||||
height?: number
|
||||
}) {
|
||||
const [current, setCurrent] = useState<string | null>(initial)
|
||||
const extra = many ? Array.from({ length: 6 }, (_, i) => ({ id: `extra-${i}`, label: `추가 메뉴 ${i + 1}` })) : []
|
||||
const [open, setOpen] = useState({ projects: projectOpen, design: designOpen })
|
||||
const go = (id: string) => (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
setCurrent(id)
|
||||
}
|
||||
const onPath = (id: string) => Boolean(current?.startsWith(`${id}/`))
|
||||
return (
|
||||
<div style={{ ...shellStyle, height }}>
|
||||
<LNB>
|
||||
<LNBBrand>
|
||||
<span>
|
||||
<small>한맥 업무 시스템</small>
|
||||
<strong>프로젝트 대시보드</strong>
|
||||
</span>
|
||||
</LNBBrand>
|
||||
<LNBNav aria-label="서비스 메뉴">
|
||||
<LNBItem href="#overview" active={current === "overview"} onClick={go("overview")}>
|
||||
<LayoutGridIcon aria-hidden="true" />
|
||||
대시보드
|
||||
</LNBItem>
|
||||
<LNBGroup label="프로젝트 관리" current={onPath("projects")} open={open.projects} onOpenChange={(value) => setOpen((state) => ({ ...state, projects: value }))}>
|
||||
<LNBGroup label="디자인 관리" current={onPath("projects/design")} open={open.design} onOpenChange={(value) => setOpen((state) => ({ ...state, design: value }))}>
|
||||
{thirdLevelChildren(longLabels).map((child) => (
|
||||
<LNBItem key={child.id} href={`#${child.id}`} active={current === child.id} onClick={go(child.id)}>
|
||||
{child.label}
|
||||
</LNBItem>
|
||||
))}
|
||||
</LNBGroup>
|
||||
<LNBItem href="#projects/tasks" active={current === "projects/tasks"} onClick={go("projects/tasks")}>
|
||||
업무 목록
|
||||
</LNBItem>
|
||||
<LNBItem href="#projects/schedule" active={current === "projects/schedule"} onClick={go("projects/schedule")}>
|
||||
일정 관리
|
||||
</LNBItem>
|
||||
</LNBGroup>
|
||||
<LNBItem href="#reports" active={current === "reports"} onClick={go("reports")}>
|
||||
<ChartColumnIcon aria-hidden="true" />
|
||||
현황 보고
|
||||
</LNBItem>
|
||||
<LNBItem href="#settings" active={current === "settings"} onClick={go("settings")}>
|
||||
<SettingsIcon aria-hidden="true" />
|
||||
설정
|
||||
</LNBItem>
|
||||
{extra.map((item) => (
|
||||
<LNBItem key={item.id} href={`#${item.id}`} active={current === item.id} onClick={go(item.id)}>
|
||||
<ListChecksIcon aria-hidden="true" />
|
||||
{item.label}
|
||||
</LNBItem>
|
||||
))}
|
||||
</LNBNav>
|
||||
<ProfileMenu side="right" align="end" matchTrigger trigger={<LNBProfile name={profileData.name} description={profileData.description}><ProfileAvatar /></LNBProfile>} />
|
||||
</LNB>
|
||||
<div style={contentStyle}>
|
||||
<h2 className="text-lg font-bold">연결된 콘텐츠</h2>
|
||||
<p className="text-sm text-muted-foreground" data-current-menu>
|
||||
선택: {current ?? "없음"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function localProfile(longLabels?: boolean) {
|
||||
return {
|
||||
name: longLabels ? "김한맥 (한맥기술 인프라사업본부 스마트시설팀)" : profileData.name,
|
||||
description: longLabels ? "통합 시스템 관리자 · 인프라사업본부" : profileData.description,
|
||||
photo: longLabels ? brokenPhoto : profileData.photo,
|
||||
}
|
||||
}
|
||||
|
||||
function simpleNav(longLabels?: boolean, many?: boolean): LocalNode[] {
|
||||
const children: LocalNode[] = longLabels
|
||||
? [
|
||||
{ id: "tasks/list", label: "전사 프로젝트 포트폴리오의 진행 현황 목록" },
|
||||
{ id: "tasks/board", label: "업무 보드" },
|
||||
]
|
||||
: [
|
||||
{ id: "tasks/list", label: "메뉴 2-1" },
|
||||
{ id: "tasks/board", label: "메뉴 2-2" },
|
||||
]
|
||||
const extra: LocalNode[] = many ? Array.from({ length: 8 }, (_, i) => ({ id: `extra-${i}`, label: `추가 메뉴 ${i + 1}`, icon: ListChecksIcon })) : []
|
||||
return [
|
||||
{ id: "overview", label: longLabels ? "프로젝트 현황과 전체 일정을 한눈에 보는 개요" : "메뉴 1", icon: LayoutGridIcon },
|
||||
{ id: "tasks", label: longLabels ? "업무 관리와 진행 상황 점검" : "메뉴 2", children },
|
||||
{ id: "reports", label: "메뉴 3", icon: ChartColumnIcon },
|
||||
{ id: "settings", label: "메뉴 4", icon: SettingsIcon },
|
||||
...extra,
|
||||
]
|
||||
}
|
||||
|
||||
function thirdLevelNav(longLabels?: boolean, many?: boolean): LocalNode[] {
|
||||
const extra: LocalNode[] = many ? Array.from({ length: 6 }, (_, i) => ({ id: `extra-${i}`, label: `추가 메뉴 ${i + 1}`, icon: ListChecksIcon })) : []
|
||||
return [
|
||||
{ id: "overview", label: "대시보드", icon: LayoutGridIcon },
|
||||
{
|
||||
id: "projects",
|
||||
label: "프로젝트 관리",
|
||||
children: [
|
||||
{ id: "projects/design", label: "디자인 관리", children: thirdLevelChildren(longLabels) },
|
||||
{ id: "projects/tasks", label: "업무 목록" },
|
||||
{ id: "projects/schedule", label: "일정 관리" },
|
||||
],
|
||||
},
|
||||
{ id: "reports", label: "현황 보고", icon: ChartColumnIcon },
|
||||
{ id: "settings", label: "설정", icon: SettingsIcon },
|
||||
...extra,
|
||||
]
|
||||
}
|
||||
|
||||
// Story-only responsive composition. Desktop LNB and mobile Sheet are two views over one tree and one state source.
|
||||
function ResponsiveSpecimen({
|
||||
nodes,
|
||||
initial,
|
||||
initialOpen,
|
||||
logo,
|
||||
eyebrow = "한맥 업무 시스템",
|
||||
brand = "프로젝트 대시보드",
|
||||
longLabels,
|
||||
height,
|
||||
}: {
|
||||
nodes: LocalNode[]
|
||||
initial: string | null
|
||||
initialOpen: Record<string, boolean>
|
||||
logo?: string
|
||||
eyebrow?: string
|
||||
brand?: string
|
||||
longLabels?: boolean
|
||||
height?: number
|
||||
}) {
|
||||
const [current, setCurrent] = useState<string | null>(initial)
|
||||
const [open, setOpen] = useState<Record<string, boolean>>(initialOpen)
|
||||
const profile = localProfile(longLabels)
|
||||
const go = (id: string) => (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
setCurrent(id)
|
||||
}
|
||||
const onPath = (id: string) => Boolean(current?.startsWith(`${id}/`))
|
||||
const isOpen = (id: string) => open[id] ?? true
|
||||
const lnbMenu = (items: LocalNode[]): ReactNode =>
|
||||
items.map((node) =>
|
||||
node.children ? (
|
||||
<LNBGroup
|
||||
key={node.id}
|
||||
label={node.label}
|
||||
current={onPath(node.id)}
|
||||
open={isOpen(node.id)}
|
||||
onOpenChange={(value) => setOpen((state) => ({ ...state, [node.id]: value }))}
|
||||
>
|
||||
{lnbMenu(node.children)}
|
||||
</LNBGroup>
|
||||
) : (
|
||||
<LNBItem key={node.id} href={`#${node.id}`} active={current === node.id} onClick={go(node.id)}>
|
||||
{node.icon ? <node.icon aria-hidden="true" /> : null}
|
||||
{node.label}
|
||||
</LNBItem>
|
||||
)
|
||||
)
|
||||
const sheetMenu = (items: LocalNode[]): ReactNode =>
|
||||
items.map((node) =>
|
||||
node.children ? (
|
||||
<LNBMobileGroup key={node.id} label={node.label} data-open={isOpen(node.id) ? "" : undefined}>
|
||||
{isOpen(node.id) ? sheetMenu(node.children) : null}
|
||||
</LNBMobileGroup>
|
||||
) : (
|
||||
<LNBMobileLink key={node.id} href={`#${node.id}`} active={current === node.id} onClick={go(node.id)}>
|
||||
{node.label}
|
||||
</LNBMobileLink>
|
||||
)
|
||||
)
|
||||
const brandContent = (
|
||||
<>
|
||||
{logo ? <img src={logo} alt="" /> : null}
|
||||
<span>
|
||||
{eyebrow ? <small>{eyebrow}</small> : null}
|
||||
<strong>{brand}</strong>
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
const profileTrigger = (
|
||||
<LNBProfile name={profile.name} description={profile.description}>
|
||||
<Avatar>
|
||||
<AvatarImage src={profile.photo} alt="" />
|
||||
<AvatarFallback>{profileData.fallback}</AvatarFallback>
|
||||
</Avatar>
|
||||
</LNBProfile>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
className="grid min-w-0 grid-cols-1 bg-[var(--hm-lnb-background-end)] lg:grid-cols-[var(--hm-lnb-width)_minmax(0,1fr)]"
|
||||
style={{ minHeight: height ?? 460, height }}
|
||||
data-story-slot="responsive-lnb-specimen"
|
||||
>
|
||||
<LNB>
|
||||
<LNBBrand>{brandContent}</LNBBrand>
|
||||
<LNBNav aria-label="서비스 메뉴">{lnbMenu(nodes)}</LNBNav>
|
||||
<ProfileMenu side="right" align="end" matchTrigger trigger={profileTrigger} />
|
||||
</LNB>
|
||||
<div className="flex min-w-0 flex-col lg:col-start-2" data-story-slot="lnb-content-stack">
|
||||
<div className="lg:hidden" data-story-slot="local-mobile-menu">
|
||||
<LNBMobileHeader>
|
||||
<LNBMobileMenu title="서비스 메뉴">
|
||||
{sheetMenu(nodes)}
|
||||
<div className="mt-4 border-t border-border pt-4">
|
||||
<ProfileMenu
|
||||
modal={false}
|
||||
trigger={
|
||||
<LNBProfile
|
||||
name={profile.name}
|
||||
description={profile.description}
|
||||
>
|
||||
<Avatar>
|
||||
<AvatarImage src={profile.photo} alt="" />
|
||||
<AvatarFallback>{profileData.fallback}</AvatarFallback>
|
||||
</Avatar>
|
||||
</LNBProfile>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</LNBMobileMenu>
|
||||
<LNBMobileBrand asChild>
|
||||
<div>{brandContent}</div>
|
||||
</LNBMobileBrand>
|
||||
</LNBMobileHeader>
|
||||
</div>
|
||||
{/* Content paints above the navigation join strip, within the same LNB Mode. */}
|
||||
<div className="relative min-w-0 flex-1 max-lg:rounded-t-[var(--hm-lnb-mobile-join)] bg-[var(--hm-sem-bg-content)] p-[34px] lg:rounded-l-[var(--hm-lnb-join)]" data-story-slot="connected-content">
|
||||
<h2 className="text-lg font-bold">연결된 콘텐츠</h2>
|
||||
<p className="text-sm text-muted-foreground" data-current-menu>
|
||||
선택: {current ?? "없음"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// LNB Mode: desktop column and LNB Mobile Header/Sheet share one local tree and state.
|
||||
// This responsive change stays within LNB Mode.
|
||||
const standaloneNav: LocalNode[] = [
|
||||
{ id: "overview", label: "대시보드", icon: LayoutGridIcon },
|
||||
{
|
||||
id: "projects",
|
||||
label: "프로젝트 관리",
|
||||
children: [
|
||||
{
|
||||
id: "projects/design",
|
||||
label: "디자인 관리",
|
||||
children: [
|
||||
{ id: "projects/design/tokens", label: "컬러 토큰" },
|
||||
{ id: "projects/design/components", label: "컴포넌트 스타일" },
|
||||
{ id: "projects/design/layout", label: "레이아웃 규칙" },
|
||||
],
|
||||
},
|
||||
{ id: "projects/tasks", label: "업무 목록" },
|
||||
{ id: "projects/schedule", label: "일정 관리" },
|
||||
],
|
||||
},
|
||||
{ id: "reports", label: "현황 보고", icon: ChartColumnIcon },
|
||||
{ id: "settings", label: "설정", icon: SettingsIcon },
|
||||
]
|
||||
|
||||
function Standalone({ mobileOpen = false, profileOpen = false }: { mobileOpen?: boolean; profileOpen?: boolean }) {
|
||||
const [current, setCurrent] = useState("projects/design/tokens")
|
||||
const [open, setOpen] = useState<Record<string, boolean>>({})
|
||||
const go = (id: string) => (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
setCurrent(id)
|
||||
}
|
||||
const onPath = (id: string) => current.startsWith(`${id}/`)
|
||||
const lnbMenu = (nodes: LocalNode[]): ReactNode =>
|
||||
nodes.map((node) =>
|
||||
node.children ? (
|
||||
<LNBGroup
|
||||
key={node.id}
|
||||
label={node.label}
|
||||
current={onPath(node.id)}
|
||||
open={open[node.id] ?? true}
|
||||
onOpenChange={(value) => setOpen((state) => ({ ...state, [node.id]: value }))}
|
||||
>
|
||||
{lnbMenu(node.children)}
|
||||
</LNBGroup>
|
||||
) : (
|
||||
<LNBItem key={node.id} href={`#${node.id}`} active={current === node.id} onClick={go(node.id)}>
|
||||
{node.icon ? <node.icon aria-hidden="true" /> : null}
|
||||
{node.label}
|
||||
</LNBItem>
|
||||
)
|
||||
)
|
||||
const sheetMenu = (nodes: LocalNode[]): ReactNode =>
|
||||
nodes.map((node) =>
|
||||
node.children ? (
|
||||
<LNBMobileGroup key={node.id} label={node.label}>
|
||||
{sheetMenu(node.children)}
|
||||
</LNBMobileGroup>
|
||||
) : (
|
||||
<LNBMobileLink key={node.id} href={`#${node.id}`} active={current === node.id} onClick={go(node.id)}>
|
||||
{node.label}
|
||||
</LNBMobileLink>
|
||||
)
|
||||
)
|
||||
const brand = (
|
||||
<span>
|
||||
<small>한맥 업무 시스템</small>
|
||||
<strong>프로젝트 대시보드</strong>
|
||||
</span>
|
||||
)
|
||||
return (
|
||||
// LNB Mode owns its height independently of Dashboard.
|
||||
<div
|
||||
className="grid min-h-0 min-w-0 grid-cols-1 bg-[var(--hm-lnb-background-end)] lg:grid-cols-[var(--hm-lnb-width)_minmax(0,1fr)]"
|
||||
style={{ height: "100dvh" }}
|
||||
data-story-slot="responsive-lnb-specimen"
|
||||
>
|
||||
<LNB>
|
||||
<LNBBrand>{brand}</LNBBrand>
|
||||
<LNBNav aria-label="서비스 메뉴">{lnbMenu(standaloneNav)}</LNBNav>
|
||||
<ProfileMenu side="right" align="end" defaultOpen={profileOpen} matchTrigger trigger={<LNBProfile name={profileData.name} description={profileData.description}><ProfileAvatar /></LNBProfile>} />
|
||||
</LNB>
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col" data-story-slot="lnb-content-stack">
|
||||
<div className="lg:hidden" data-story-slot="local-mobile-menu">
|
||||
<LNBMobileHeader>
|
||||
<LNBMobileMenu title="서비스 메뉴" defaultOpen={mobileOpen}>
|
||||
{sheetMenu(standaloneNav)}
|
||||
<div className="mt-4 border-t border-border pt-4">
|
||||
<ProfileMenu
|
||||
modal={false}
|
||||
trigger={
|
||||
<LNBProfile
|
||||
name={profileData.name}
|
||||
description={profileData.description}
|
||||
>
|
||||
<ProfileAvatar />
|
||||
</LNBProfile>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</LNBMobileMenu>
|
||||
<LNBMobileBrand asChild>
|
||||
<div>{brand}</div>
|
||||
</LNBMobileBrand>
|
||||
</LNBMobileHeader>
|
||||
</div>
|
||||
<main className="relative min-h-0 min-w-0 flex-1 overflow-auto max-lg:rounded-t-[var(--hm-lnb-mobile-join)] bg-[var(--hm-sem-bg-content)] p-[34px] lg:rounded-l-[var(--hm-lnb-join)]" data-story-slot="connected-content">
|
||||
<h2 className="text-lg font-bold">연결된 콘텐츠</h2>
|
||||
<p className="text-sm text-muted-foreground" data-current-menu>
|
||||
선택: {current}
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// A 390px viewport for the mobile Stories (the switch is a viewport media query, so a narrow container cannot show it).
|
||||
const mobileViewport = {
|
||||
parameters: { viewport: { options: { hmMobile: { name: "Mobile 390", styles: { width: "390px", height: "844px" }, type: "mobile" } } } },
|
||||
globals: { viewport: { value: "hmMobile", isRotated: false } },
|
||||
}
|
||||
|
||||
// Representative-board preview of a Story at a fixed viewport. The LNB/bar switch is a viewport media query, so the board embeds the
|
||||
// verification Story (the same `Standalone` composition) in an iframe of that width instead of rebuilding it in a narrow container.
|
||||
// Each iframe is its own document, so the closed Sheets do not share a portal or focus with the board.
|
||||
function ViewportPreview({ story, label, width, height }: { story: string; label: string; width: number; height: number }) {
|
||||
return (
|
||||
<figure className="grid min-w-0 content-start gap-2">
|
||||
<figcaption className="text-sm text-muted-foreground">
|
||||
{label} · {width}px
|
||||
</figcaption>
|
||||
<div className="max-w-full overflow-x-auto">
|
||||
<iframe
|
||||
title={`${label} ${width}px`}
|
||||
src={`iframe.html?id=patterns-layout-lnb--${story}&viewMode=story`}
|
||||
width={width}
|
||||
height={height}
|
||||
className="box-content block border border-black/10"
|
||||
/>
|
||||
</div>
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = { title: "Patterns/Layout/LNB", component: LNB, parameters: { layout: "fullscreen", controls: { disable: true } } } satisfies Meta<typeof LNB>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Representative Story: the common Story page with the column in every state side by side. Hover and click work on the real items.
|
||||
// The specimens are bounded LNB columns (their own frame, not the viewport width), so they stay on the page inner.
|
||||
export const All: Story = {
|
||||
name: "LNB",
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.patterns}
|
||||
title="LNB"
|
||||
description="화면 왼쪽의 지역 내비게이션입니다. 1024px 이상에서 표시하고, 1023px 이하는 숨긴 뒤 모바일 메뉴로 대체합니다."
|
||||
/>
|
||||
<StorySection title="Overview" description="모든 뎁스 18px. 선택은 Beige 면 + 초록 글자, 왼쪽 모서리 8로 콘텐츠와 오목하게 이어집니다.">
|
||||
<StateBoard
|
||||
min={560}
|
||||
states={[
|
||||
{ id: "selected", label: "Selected", children: <ResponsiveSpecimen nodes={simpleNav()} initial="overview" initialOpen={{ tasks: true }} /> },
|
||||
{ id: "third-selected", label: "3뎁스 메뉴 · 선택", note: "3뎁스는 20px 들여쓰기와 1px 그린 연결선으로 구분하며, 선은 마지막 메뉴에서 끝납니다. 선택은 베이지, 상위 경로는 노란색입니다.", children: <ResponsiveSpecimen nodes={thirdLevelNav()} initial="projects/design/tokens" initialOpen={{ projects: true, "projects/design": true }} /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="States" description="그룹 펼침·접힘과 하위 선택에 따른 표시입니다. 선택된 경로의 상위는 접혀도 노란색을 유지합니다.">
|
||||
<StateBoard
|
||||
min={560}
|
||||
states={[
|
||||
{ id: "default", label: "Default · 기존 LNB", note: "선택 없음, 2뎁스 그룹 접힘. 항목에 마우스를 올리면 Black 10%가 콘텐츠 경계까지 닿습니다.", children: <ResponsiveSpecimen nodes={simpleNav()} initial={null} initialOpen={{ tasks: false }} /> },
|
||||
{ id: "expanded", label: "Expanded · 2뎁스 펼침", note: "하위 선택 없이 펼친 그룹: Black 20% 면 + 1px 초록 선.", children: <ResponsiveSpecimen nodes={simpleNav()} initial={null} initialOpen={{ tasks: true }} /> },
|
||||
{ id: "selected-child", label: "Selected Child · 2뎁스 선택", note: "하위 선택: 상위 이름·화살표가 노란색, 하위는 Beige.", children: <ResponsiveSpecimen nodes={simpleNav()} initial="tasks/list" initialOpen={{ tasks: true }} /> },
|
||||
{ id: "collapsed-with-selection", label: "Collapsed With Selection · 접힘", note: "하위가 선택된 채 접힌 그룹도 상위가 노란색을 유지합니다.", children: <ResponsiveSpecimen nodes={simpleNav()} initial="tasks/list" initialOpen={{ tasks: false }} /> },
|
||||
{ id: "third-second-collapsed", label: "3뎁스 메뉴 · 2뎁스 접힘", note: "디자인 관리를 접어도 선택된 경로의 1·2뎁스 표시를 유지합니다. 하위 메뉴와 연결선이 함께 숨겨집니다.", children: <ResponsiveSpecimen nodes={thirdLevelNav()} initial="projects/design/tokens" initialOpen={{ projects: true, "projects/design": false }} /> },
|
||||
{ id: "third-first-collapsed", label: "3뎁스 메뉴 · 1뎁스 접힘", note: "프로젝트 관리를 접어도 노란색을 유지합니다. 다시 펼치면 디자인 관리의 열림 상태가 그대로입니다(open은 화면이 관리).", children: <ResponsiveSpecimen nodes={thirdLevelNav()} initial="projects/design/tokens" initialOpen={{ projects: false, "projects/design": true }} /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Variants" description="로고 유무와 긴 이름입니다.">
|
||||
<StateBoard
|
||||
min={560}
|
||||
states={[
|
||||
{ id: "with-logo", label: "With Logo · 로고 있음(테스트 이미지)", note: "로고는 서비스명 위, 같은 시작선. 없으면 영역·간격 없이 서비스명만 둡니다.", children: <ResponsiveSpecimen nodes={simpleNav()} initial="overview" initialOpen={{ tasks: true }} logo={testLogo} /> },
|
||||
{ id: "long", label: "Long labels", note: "긴 이름은 줄바꿈되며 항목 높이가 늘어납니다.", children: <ResponsiveSpecimen nodes={simpleNav(true)} initial="tasks/list" initialOpen={{ tasks: true }} longLabels /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Usage · Scroll" description="메뉴가 열보다 길면 메뉴 영역만 세로로 스크롤하고 브랜드·프로필은 그대로입니다(높이 460).">
|
||||
<StateBoard
|
||||
min={560}
|
||||
states={[
|
||||
{ id: "scroll", label: "Scroll", children: <ResponsiveSpecimen nodes={simpleNav(false, true)} initial="overview" initialOpen={{ tasks: true }} height={460} /> },
|
||||
{ id: "third-scroll", label: "3뎁스 메뉴 · Scroll", note: "3뎁스·연결선·긴 이름도 메뉴 영역 안에서 함께 스크롤됩니다. 스크롤 영역은 브랜드 바로 아래에서 시작해 프로필 바로 위에서 끝납니다.", children: <ResponsiveSpecimen nodes={thirdLevelNav(true, true)} initial="projects/design/layout" initialOpen={{ projects: true, "projects/design": true }} longLabels height={460} /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection
|
||||
title="Usage · LNB Standalone 반응형 (GNB 없음)"
|
||||
description="1024px 이상은 왼쪽 LNB의 브랜드·프로필을, 1023px 이하는 LNB를 숨긴 뒤 Local 모바일 바와 Sheet의 메뉴·프로필을 사용합니다. 열림·키보드·Escape는 LNB Standalone Mobile Menu Open에서 확인합니다."
|
||||
>
|
||||
<div className="flex flex-wrap items-start gap-6">
|
||||
<ViewportPreview story="standalone-desktop" label="Desktop" width={1024} height={640} />
|
||||
<ViewportPreview story="standalone-mobile" label="Mobile" width={390} height={640} />
|
||||
</div>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const Default: Story = { tags: ["verification"], render: () => <Menu initial={null} groupOpen={false} /> }
|
||||
export const Selected: Story = { tags: ["verification"], render: () => <Menu /> }
|
||||
export const Expanded: Story = { tags: ["verification"], render: () => <Menu initial={null} /> }
|
||||
export const SelectedChild: Story = { tags: ["verification"], name: "Selected Child", render: () => <Menu initial="tasks/list" /> }
|
||||
export const CollapsedWithSelection: Story = { tags: ["verification"], name: "Collapsed With Selection", render: () => <Menu initial="tasks/list" groupOpen={false} /> }
|
||||
export const WithLogo: Story = { tags: ["verification"], name: "With Logo", render: () => <Menu logo={testLogo} /> }
|
||||
export const LongLabels: Story = { tags: ["verification"], name: "Long Labels", render: () => <Menu initial="tasks/list" longLabels /> }
|
||||
export const Scroll: Story = { tags: ["verification"], render: () => <Menu many height={460} /> }
|
||||
export const ThirdLevel: Story = { tags: ["verification"], name: "Third Level", render: () => <ThirdLevelMenu /> }
|
||||
export const ThirdLevelLongLabels: Story = { tags: ["verification"], name: "Third Level Long Labels", render: () => <ThirdLevelMenu initial="projects/design/components" longLabels /> }
|
||||
export const ThirdLevelScroll: Story = { tags: ["verification"], name: "Third Level Scroll", render: () => <ThirdLevelMenu initial="projects/design/layout" longLabels many height={460} /> }
|
||||
|
||||
// VERIFICATION ONLY: legacy GNB + LNB technical check, forbidden in operating pages. The GNB owns the brand,
|
||||
// so the LNB below has no LNBBrand and the menu starts at the column top.
|
||||
export const WithGNB: Story = {
|
||||
tags: ["verification"],
|
||||
name: "VERIFICATION ONLY · GNB + LNB",
|
||||
render: () => (
|
||||
<div>
|
||||
<p role="note">VERIFICATION ONLY — 기존 혼합 구조 기술 검증. 운영 사용 금지.</p>
|
||||
<GNB>
|
||||
<GNBMobileMenu title="메뉴">
|
||||
<div className="mt-4 border-t border-border pt-4">
|
||||
<ProfileMenu
|
||||
modal={false}
|
||||
trigger={
|
||||
<GNBUser
|
||||
name={profileData.name}
|
||||
description={profileData.description}
|
||||
className="w-full justify-start !text-foreground [&_[data-slot=gnb-user-description]]:!text-muted-foreground [&_[data-slot=gnb-user-text]]:!grid"
|
||||
>
|
||||
<ProfileAvatar />
|
||||
</GNBUser>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</GNBMobileMenu>
|
||||
<GNBBrand href="#overview">
|
||||
<span>
|
||||
<small>한맥 업무 시스템</small>
|
||||
<strong>프로젝트 대시보드</strong>
|
||||
</span>
|
||||
</GNBBrand>
|
||||
<GNBNav />
|
||||
<GNBUtilities className="hidden lg:flex">
|
||||
<ProfileMenu align="end" trigger={<GNBUser name={profileData.name} description={profileData.description}><ProfileAvatar /></GNBUser>} />
|
||||
</GNBUtilities>
|
||||
</GNB>
|
||||
<Menu brand={null} profile={false} />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
// LNB without a GNB, responsive. Desktop (>= 1024px): the column. Mobile (<= 1023px): the column is hidden and the connected-content
|
||||
// column becomes a full-width stack whose first row opens a Sheet with the same tree, selected item and every depth.
|
||||
export const StandaloneDesktop: Story = { tags: ["verification"], name: "LNB Standalone Desktop", render: () => <Standalone /> }
|
||||
export const LNBProfileMenuOpen: Story = { tags: ["verification"], name: "LNB Profile Menu Open", render: () => <Standalone profileOpen /> }
|
||||
export const StandaloneMobile: Story = { tags: ["verification"], name: "LNB Standalone Mobile", ...mobileViewport, render: () => <Standalone /> }
|
||||
export const StandaloneMobileMenuOpen: Story = {
|
||||
tags: ["verification"],
|
||||
name: "LNB Standalone Mobile Menu Open",
|
||||
...mobileViewport,
|
||||
render: () => <Standalone mobileOpen />,
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useState } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Button, LoadingScreen } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Loading Screen is a Dashboard-local pattern (ui-viz LoadingScreen). Inline fills its parent; fullscreen covers the viewport. The
|
||||
// fullscreen demo is mounted and removed by local state only; there is no async task, cancel or close control in the pattern.
|
||||
|
||||
function Fullscreen() {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<>
|
||||
<Button appearance="outline" onClick={() => setOpen(true)}>전체화면 미리보기</Button>
|
||||
{open ? (
|
||||
<>
|
||||
<LoadingScreen fullscreen>데이터를 불러오는 중</LoadingScreen>
|
||||
<Button className="fixed top-7 right-7 z-[10000]" onClick={() => setOpen(false)}>닫기</Button>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = { title: "Patterns/Layout/Loading Screen", parameters: { layout: "padded", controls: { disable: true } } } satisfies Meta
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// The inline specimen fills its parent inside the common page Inner.
|
||||
export const All: Story = {
|
||||
name: "Loading Screen",
|
||||
parameters: { layout: "fullscreen" },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.patterns}
|
||||
title="Loading Screen"
|
||||
description="Dashboard 안에서 데이터를 불러오는 동안 표시하는 화면입니다. Inline은 부모 영역을 채우고, fullscreen은 viewport 전체를 덮습니다."
|
||||
/>
|
||||
<StorySection title="Overview" description="Inline: 부모 영역(여기서는 공통 Inner 폭)을 채웁니다.">
|
||||
<LoadingScreen />
|
||||
</StorySection>
|
||||
<StorySection title="Variants" description="사용자 문구와 좁은 부모 영역입니다.">
|
||||
<StateBoard
|
||||
states={[
|
||||
{ id: "label", label: "Custom label", children: <LoadingScreen>데이터를 불러오는 중</LoadingScreen> },
|
||||
{ id: "narrow", label: "Narrow parent (240px)", children: <div style={{ width: 240 }}><LoadingScreen /></div> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Usage" description="fullscreen은 viewport 전체를 덮습니다. 버튼으로 열고 오른쪽 위 닫기로 닫습니다.">
|
||||
<Fullscreen />
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
export const Inline: Story = { tags: ["verification"], render: () => <LoadingScreen /> }
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
Button,
|
||||
PageHeader,
|
||||
PageHeaderActions,
|
||||
PageHeaderContent,
|
||||
PageHeaderDescription,
|
||||
PageHeaderTitle,
|
||||
} from "@hm-design/ui"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
const meta = {
|
||||
title: "Patterns/Layout/Page Header",
|
||||
component: PageHeader,
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
} satisfies Meta<typeof PageHeader>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Content composition only. The consuming screen owns navigation and action handlers.
|
||||
// Dashboard's minimum width and internal navigation belong to DashboardLayout.
|
||||
export const All: Story = {
|
||||
name: "Page Header",
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.patterns}
|
||||
title="Page Header"
|
||||
description="화면 콘텐츠의 제목 영역입니다. Breadcrumb, 제목, 설명과 대표 Action을 조합합니다."
|
||||
/>
|
||||
<StorySection title="Overview" description="단일 Brand Action을 사용하는 기본 조합입니다.">
|
||||
<PageHeader>
|
||||
<PageHeaderContent>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="#home">홈</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="#projects">프로젝트</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>업무 현황</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<PageHeaderTitle>업무 현황</PageHeaderTitle>
|
||||
<PageHeaderDescription>프로젝트별 업무 진행 상황을 확인하고 조회합니다.</PageHeaderDescription>
|
||||
</PageHeaderContent>
|
||||
<PageHeaderActions>
|
||||
<Button type="button" variant="primary" appearance="brand" size="default">
|
||||
신규 등록
|
||||
</Button>
|
||||
</PageHeaderActions>
|
||||
</PageHeader>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Local sample data for the pattern Stories (no network).
|
||||
export type SampleUser = {
|
||||
id: string
|
||||
name: string
|
||||
department: string
|
||||
status: "active" | "pending" | "inactive"
|
||||
createdAt: string // ISO date
|
||||
}
|
||||
|
||||
export const statusLabel: Record<SampleUser["status"], string> = { active: "활성", pending: "대기", inactive: "비활성" }
|
||||
|
||||
const names = ["김민수", "이서연", "박지훈", "최유진", "정하늘", "강도현", "조수빈", "윤재민", "임나래", "한지우", "오세준", "서하은"]
|
||||
const departments = ["설계팀", "시공팀", "안전관리팀", "품질팀", "경영지원팀"]
|
||||
const statuses: SampleUser["status"][] = ["active", "active", "pending", "inactive"]
|
||||
|
||||
// 47 rows: more than two pages at 20 per page, with dates spread over October 2026.
|
||||
export const sampleUsers: SampleUser[] = Array.from({ length: 47 }, (_, i) => ({
|
||||
id: `U-${String(i + 1).padStart(3, "0")}`,
|
||||
name: `${names[i % names.length]}${i >= names.length ? ` ${Math.floor(i / names.length) + 1}` : ""}`,
|
||||
department: departments[(i * 3) % departments.length],
|
||||
status: statuses[(i * 5) % statuses.length],
|
||||
createdAt: new Date(2026, 9, 1 + (i % 28)).toISOString(),
|
||||
}))
|
||||
|
||||
export const formatDate = (iso: string) => new Intl.DateTimeFormat("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit" }).format(new Date(iso))
|
||||
|
||||
export const sameDay = (iso: string, date: Date) => {
|
||||
const d = new Date(iso)
|
||||
return d.getFullYear() === date.getFullYear() && d.getMonth() === date.getMonth() && d.getDate() === date.getDate()
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
Button, Combobox, DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem,
|
||||
ContextMenu, ContextMenuTrigger, ContextMenuContent, ContextMenuItem,
|
||||
Menubar, MenubarMenu, MenubarTrigger, MenubarContent, MenubarItem,
|
||||
DataTable, useDataTable, dataTableSelectColumn, type DataTableColumnDef,
|
||||
} from "@hm-design/ui"
|
||||
|
||||
const data = [{ id: "one", name: "선택 행 / Active + Accent" }, { id: "two", name: "일반 행 / Hover" }]
|
||||
const columns: DataTableColumnDef<(typeof data)[number]>[] = [dataTableSelectColumn(), { accessorKey: "name", header: "Data Table" }]
|
||||
const options = [{ value: "one", label: "Selected option" }, { value: "two", label: "Highlighted option" }]
|
||||
|
||||
export function DesignerExtras() {
|
||||
const table = useDataTable({ columns, data, getRowId: row => row.id, initialState: { rowSelection: { one: true } } })
|
||||
return <div style={{ display: "grid", gap: 18, padding: 20 }}>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 12 }}>
|
||||
<Combobox options={options} defaultValue="one" aria-label="Theme combobox" />
|
||||
<DropdownMenu><DropdownMenuTrigger asChild><Button appearance="outline">Dropdown Preview</Button></DropdownMenuTrigger>
|
||||
<DropdownMenuContent><DropdownMenuItem>Dropdown highlight</DropdownMenuItem><DropdownMenuItem variant="destructive">Destructive action</DropdownMenuItem></DropdownMenuContent></DropdownMenu>
|
||||
<ContextMenu><ContextMenuTrigger style={{ border: "1px solid var(--hm-sem-stroke-default)", padding: 8 }}>우클릭 Context Preview</ContextMenuTrigger>
|
||||
<ContextMenuContent><ContextMenuItem>Context highlight</ContextMenuItem></ContextMenuContent></ContextMenu>
|
||||
<Menubar><MenubarMenu><MenubarTrigger>Menubar Preview</MenubarTrigger><MenubarContent><MenubarItem>Menubar highlight</MenubarItem></MenubarContent></MenubarMenu></Menubar>
|
||||
</div>
|
||||
<DataTable table={table} />
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useLayoutEffect, useSyncExternalStore, type ReactNode } from "react"
|
||||
import { applyRuntimeTheme, resetRuntimeTheme, resolveRuntimeTheme, isRuntimeTheme, runtimeThemeContract, type RuntimeTheme, type RuntimeThemeToken } from "@hm-design/ui/theme"
|
||||
import defaultJson from "../../themes/default.json"
|
||||
|
||||
// Storybook-only persistence; production owns its own JSON delivery/storage policy.
|
||||
const storageKey = "hm-designer-color-theme-v1"
|
||||
const listeners = new Set<() => void>()
|
||||
export function isDesignerTheme(value: unknown): value is RuntimeTheme {
|
||||
return isRuntimeTheme(value) && Object.keys(value.tokens).every(key => runtimeThemeContract[key as RuntimeThemeToken].type === "color")
|
||||
}
|
||||
let current: RuntimeTheme = { schemaVersion: 1, tokens: {} }
|
||||
try {
|
||||
const saved: unknown = JSON.parse(sessionStorage.getItem(storageKey) ?? "null")
|
||||
if (isDesignerTheme(saved)) current = saved
|
||||
} catch { /* Disabled storage or stale documents fall back to HM Default. */ }
|
||||
|
||||
export const designerThemeStore = {
|
||||
getSnapshot: () => current,
|
||||
subscribe: (listener: () => void) => { listeners.add(listener); return () => { listeners.delete(listener) } },
|
||||
set(theme: unknown): boolean {
|
||||
if (!isDesignerTheme(theme)) return false
|
||||
current = { schemaVersion: 1, tokens: { ...theme.tokens } }
|
||||
try { sessionStorage.setItem(storageKey, JSON.stringify(current)) } catch { /* Live editing works without storage. */ }
|
||||
listeners.forEach(listener => listener())
|
||||
return true
|
||||
},
|
||||
reset: () => designerThemeStore.set({ schemaVersion: 1, tokens: {} }),
|
||||
}
|
||||
|
||||
export function DesignerThemePreview({ children, enabled }: { children: ReactNode; enabled: boolean }) {
|
||||
const theme = useSyncExternalStore(designerThemeStore.subscribe, designerThemeStore.getSnapshot)
|
||||
useLayoutEffect(() => {
|
||||
if (!enabled) return
|
||||
applyRuntimeTheme(document.documentElement, resolveRuntimeTheme(theme, defaultJson))
|
||||
return () => resetRuntimeTheme(document.documentElement)
|
||||
}, [theme, enabled])
|
||||
return children
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
.hm-designer { color: var(--hm-sem-fg-primary); background: var(--hm-sem-bg-content); font-size: 14px; }
|
||||
.hm-designer h1 { font-size: 24px; font-weight: 700; margin: 4px 0 12px; }
|
||||
.hm-designer h2 { font-size: 18px; font-weight: 700; margin-bottom: 8px; }
|
||||
.hm-designer p, .hm-designer small { color: var(--hm-sem-fg-secondary); line-height: 1.6; }
|
||||
.hm-designer-header { display: flex; gap: 16px; justify-content: space-between; align-items: start; margin-bottom: 24px; }
|
||||
.hm-designer-layout { display: grid; grid-template-columns: minmax(300px, 360px) minmax(0, 1fr); gap: 24px; }
|
||||
.hm-designer-editor, .hm-designer-preview { min-width: 0; padding: 20px; background: var(--hm-sem-bg-surface); border: 1px solid var(--hm-sem-stroke-input-default); border-radius: 4px; }
|
||||
.hm-designer-field { padding: 14px 0; border-bottom: 1px solid var(--hm-sem-stroke-input-default); }
|
||||
.hm-designer-field label { display: block; font-weight: 600; }
|
||||
.hm-designer-field small { display: block; font-size: 12px; font-weight: 400; margin: 3px 0 8px; }
|
||||
.hm-designer-inputs { display: flex; gap: 8px; }
|
||||
.hm-designer-inputs input[type=color] { width: 42px; height: 34px; padding: 2px; cursor: pointer; }
|
||||
.hm-designer-inputs input:not([type=color]) { width: 120px; height: 34px; padding: 6px 8px; font-family: monospace; }
|
||||
.hm-designer-editor input, .hm-designer-editor textarea { border: 1px solid var(--hm-sem-stroke-input-default); background: var(--hm-sem-bg-surface); color: var(--hm-sem-fg-primary); border-radius: 4px; }
|
||||
.hm-designer-editor input[aria-invalid=true] { border-color: var(--hm-sem-action-destructive); }
|
||||
.hm-designer-editor input:focus-visible, .hm-designer-editor textarea:focus-visible,
|
||||
.hm-designer-header button:focus-visible, .hm-designer-source button:focus-visible,
|
||||
.hm-designer-transfer button:focus-visible { outline: 2px solid var(--hm-sem-stroke-input-focus); outline-offset: 2px; }
|
||||
.hm-designer-header button, .hm-designer-transfer button, .hm-designer-source button { border: 1px solid var(--hm-sem-stroke-default); border-radius: 4px; background: var(--hm-sem-bg-surface); padding: 7px 10px; color: var(--hm-sem-fg-primary); cursor: pointer; font-size: 13px; }
|
||||
.hm-designer-source { padding-bottom: 20px; margin-bottom: 20px; border-bottom: 1px solid var(--hm-sem-stroke-input-default); }
|
||||
.hm-designer-source label { display: block; margin-bottom: 6px; }
|
||||
.hm-designer-source input { width: 100%; padding: 8px; margin-bottom: 8px; }
|
||||
.hm-designer button:disabled { opacity: 0.5; cursor: wait; }
|
||||
.hm-designer-transfer { margin-top: 24px; }
|
||||
.hm-designer-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; }
|
||||
.hm-designer-transfer textarea { width: 100%; padding: 10px; font-family: monospace; font-size: 12px; margin-bottom: 10px; }
|
||||
.hm-designer-transfer input[type=file] { width: 100%; margin-top: 12px; }
|
||||
.hm-designer-transfer output { display: block; margin-top: 12px; overflow-wrap: anywhere; }
|
||||
.hm-designer-preview > p { margin-bottom: 12px; }
|
||||
@media (max-width: 900px) { .hm-designer-layout { grid-template-columns: minmax(0, 1fr); } }
|
||||
@media (max-width: 480px) { .hm-designer-header { flex-direction: column; } .hm-designer-editor, .hm-designer-preview { padding: 12px; } }
|
||||
@@ -0,0 +1,248 @@
|
||||
import { useEffect, useRef, useState, useSyncExternalStore } from "react"
|
||||
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 "./designer-theme.css"
|
||||
|
||||
const roles = [
|
||||
["color.action.primary", "Primary / Brand", "Badge · 현재 페이지 · 내비게이션"],
|
||||
["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.on.accent", "On Accent", "강조 면의 글자"],
|
||||
["color.action.destructive", "Destructive", "오류 · Invalid · Destructive Button / Badge"],
|
||||
["color.bg.surface", "Surface", "입력 · Card · Popup"],
|
||||
["color.bg.content", "Content Surface", "콘텐츠 영역"],
|
||||
["color.bg.subtle", "Subtle Surface", "은은한 면 · Calendar today"],
|
||||
["color.fg.primary", "Text Primary", "주요 텍스트"],
|
||||
["color.fg.secondary", "Text Secondary", "보조 텍스트"],
|
||||
["color.fg.placeholder", "Placeholder", "입력 안내"],
|
||||
["color.fg.inverse", "Inverse Text", "반전 면의 텍스트"],
|
||||
["color.stroke.default", "Default Border", "컨트롤 · Badge 외곽선"],
|
||||
["color.stroke.input.default", "Input Border", "입력 · Popup 테두리"],
|
||||
["color.action.cancel", "Cancel", "취소 동작"],
|
||||
] as const satisfies readonly (readonly [RuntimeThemeToken, string, string])[]
|
||||
|
||||
function ColorField({ token, label, hint, value, onChange }: { token: RuntimeThemeToken; label: string; hint: string; value: string; onChange: (value: string) => void }) {
|
||||
const [draft, setDraft] = useState(value)
|
||||
const [previous, setPrevious] = useState(value)
|
||||
if (previous !== value) { setPrevious(value); setDraft(value) }
|
||||
const valid = /^#[\da-f]{6}$/i.test(draft)
|
||||
const update = (next: string) => { setDraft(next); if (/^#[\da-f]{6}$/i.test(next)) onChange(next.toLowerCase()) }
|
||||
return <div className="hm-designer-field">
|
||||
<label htmlFor={`theme-${token}`}>{label}<small>{hint}</small></label>
|
||||
<div className="hm-designer-inputs">
|
||||
<input type="color" value={value} aria-label={`${label} color picker`} onChange={event => update(event.target.value)} />
|
||||
<input id={`theme-${token}`} aria-label={`${label} HEX`} aria-invalid={!valid} spellCheck={false} value={draft} onChange={event => update(event.target.value)} />
|
||||
</div>
|
||||
{!valid && <small role="status">#123abc 형식의 6자리 HEX를 입력하세요. 마지막 유효값을 유지합니다.</small>}
|
||||
</div>
|
||||
}
|
||||
|
||||
function ThemeEditor() {
|
||||
const [navigationMode, setNavigationMode] = useState<"GNB" | "LNB">("GNB")
|
||||
const theme = useSyncExternalStore(designerThemeStore.subscribe, designerThemeStore.getSnapshot)
|
||||
const [json, setJson] = useState("")
|
||||
const [status, setStatus] = useState("")
|
||||
const [themeUrl, setThemeUrl] = useState("/themes/service-example.json")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const pending = useRef<AbortController | undefined>(undefined)
|
||||
useEffect(() => () => pending.current?.abort(), [])
|
||||
const [revision, setRevision] = useState(0)
|
||||
const [effective, setEffective] = useState(defaultRuntimeTheme.tokens)
|
||||
useEffect(() => {
|
||||
// Read resolved roles after the preview decorator's layout effect.
|
||||
// JSON Default + partial override is what the components actually consume.
|
||||
const style = getComputedStyle(document.documentElement)
|
||||
setEffective(Object.fromEntries(roles.map(([token]) => {
|
||||
const value = style.getPropertyValue(runtimeThemeContract[token].property).trim()
|
||||
// CSS minification may shorten #ffffff to #fff; the public input stays HEX6.
|
||||
return [token, /^#[\da-f]{3}$/i.test(value) ? `#${value.slice(1).split("").map(character => character.repeat(2)).join("")}` : value]
|
||||
})))
|
||||
}, [theme])
|
||||
const exported = JSON.stringify(theme, null, 2)
|
||||
const cancelUrl = () => { pending.current?.abort(); pending.current = undefined; setLoading(false) }
|
||||
const updateTheme = (value: unknown) => {
|
||||
const accepted = designerThemeStore.set(value)
|
||||
if (accepted) cancelUrl()
|
||||
return accepted
|
||||
}
|
||||
const loadUrl = async (url: string) => {
|
||||
pending.current?.abort()
|
||||
const controller = new AbortController()
|
||||
pending.current = controller
|
||||
setLoading(true)
|
||||
try {
|
||||
const fetched = await fetchRuntimeTheme(url, { cache: "no-store", signal: controller.signal })
|
||||
if (pending.current !== controller) return
|
||||
if (!designerThemeStore.set(fetched)) throw Error("Expected color-only Theme")
|
||||
setRevision(value => value + 1)
|
||||
setStatus(`Theme JSON 적용: ${url}`)
|
||||
} catch { if (pending.current === controller) { designerThemeStore.reset(); setRevision(value => value + 1); setStatus("Theme URL 로드 실패: HM Default로 복원했습니다.") } }
|
||||
finally { if (pending.current === controller) { pending.current = undefined; setLoading(false) } }
|
||||
}
|
||||
const reset = () => { cancelUrl(); designerThemeStore.reset(); setRevision(value => value + 1); setJson(""); setStatus("HM Default로 즉시 복원했습니다.") }
|
||||
const importTheme = () => {
|
||||
try {
|
||||
if (!updateTheme(JSON.parse(json))) throw Error("invalid")
|
||||
setRevision(value => value + 1)
|
||||
setStatus("Theme JSON을 즉시 적용했습니다.")
|
||||
} catch { setStatus("가져오기 실패: schemaVersion 1과 공개 컬러 token / 6자리 HEX를 확인하세요. Radius는 이 컬러 편집기에서 허용하지 않습니다. 기존 Theme를 유지합니다.") }
|
||||
}
|
||||
const copy = async () => {
|
||||
setJson(exported)
|
||||
try { await navigator.clipboard.writeText(exported); setStatus("JSON을 복사했습니다. 개발팀에 전달하세요.") }
|
||||
catch { setStatus("클립보드 접근이 제한되어 있습니다. JSON 입력란에서 직접 복사하세요.") }
|
||||
}
|
||||
const download = () => {
|
||||
const url = URL.createObjectURL(new Blob([exported], { type: "application/json" }))
|
||||
const link = document.createElement("a")
|
||||
link.href = url; link.download = "hm-color-theme.json"; link.hidden = true
|
||||
document.body.append(link); link.click(); link.remove()
|
||||
// Give the browser download handler time to acquire the Blob before release.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
setStatus("hm-color-theme.json 다운로드를 요청했습니다. 다운로드가 제한되면 Show JSON으로 직접 복사하세요.")
|
||||
}
|
||||
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>
|
||||
<button type="button" onClick={reset}>Reset to HM Default</button>
|
||||
</header>
|
||||
<div className="hm-designer-layout">
|
||||
<section className="hm-designer-editor" aria-label="Theme color editor">
|
||||
<section className="hm-designer-source" aria-label="Theme JSON source">
|
||||
<h2>Theme JSON Preview</h2><p>원본은 themes/*.json입니다. 파일을 Import하거나 서비스의 CDN URL을 입력하세요.</p>
|
||||
<div className="hm-designer-actions"><button type="button" disabled={loading} onClick={() => void loadUrl("/themes/default.json")}>Load Default JSON</button>
|
||||
<button type="button" disabled={loading} onClick={() => void loadUrl("/themes/service-example.json")}>Load Service Example JSON</button></div>
|
||||
<label htmlFor="hm-theme-url">Theme JSON URL</label><input id="hm-theme-url" value={themeUrl} onChange={event => setThemeUrl(event.target.value)} />
|
||||
<button type="button" disabled={loading || !themeUrl.trim()} onClick={() => void loadUrl(themeUrl)}>Load URL</button>
|
||||
</section>
|
||||
<h2>Theme 역할 편집</h2><p>HM Default를 출발점으로 편집합니다. 유효한 HEX를 입력하면 바로 적용됩니다.</p>
|
||||
{roles.map(([token, label, hint]) => <ColorField key={`${token}:${revision}`} token={token} label={label} hint={hint}
|
||||
value={effective[token] ?? defaultRuntimeTheme.tokens[token]!}
|
||||
onChange={value => updateTheme({ schemaVersion: 1, tokens: { ...theme.tokens, [token]: value } })} />)}
|
||||
<section className="hm-designer-transfer" aria-label="Theme JSON transfer">
|
||||
<h2>개발팀에 전달</h2><p>공식 Runtime Contract의 JSON입니다. 수정한 역할만 내보내며 나머지는 HM Default를 사용합니다.</p>
|
||||
<div className="hm-designer-actions"><button type="button" onClick={() => { setJson(exported); setStatus("현재 Theme JSON을 표시했습니다.") }}>Show JSON</button>
|
||||
<button type="button" onClick={() => void copy()}>Copy JSON</button><button type="button" onClick={download}>Export JSON</button></div>
|
||||
<textarea aria-label="Theme JSON" value={json} onChange={event => setJson(event.target.value)} placeholder={'{"schemaVersion":1,"tokens":{}}'} rows={8} spellCheck={false} />
|
||||
<button type="button" onClick={importTheme}>Import JSON</button>
|
||||
<input type="file" accept=".json,application/json" aria-label="Import Theme JSON file" onChange={event => {
|
||||
const file = event.target.files?.[0]
|
||||
if (file) void file.text().then(text => {
|
||||
setJson(text)
|
||||
try { if (!updateTheme(JSON.parse(text))) throw Error("invalid"); setRevision(value => value + 1); setStatus("Theme 파일을 즉시 적용했습니다.") }
|
||||
catch { setStatus("Theme 파일이 유효하지 않습니다. 기존 Theme를 유지합니다.") }
|
||||
}).catch(() => setStatus("Theme 파일을 읽지 못했습니다."))
|
||||
event.target.value = ""
|
||||
}} />
|
||||
<output aria-live="polite">{status}</output>
|
||||
</section>
|
||||
</section>
|
||||
<section className="hm-designer-preview" aria-label="Live component preview"><h2>Live Preview</h2>
|
||||
<p>Primary · Active · Focus · Accent · Destructive의 영향을 여러 컴포넌트에서 함께 확인하세요.</p>
|
||||
<label htmlFor="hm-preview-navigation-mode">Navigation Mode</label>
|
||||
<select id="hm-preview-navigation-mode" value={navigationMode} onChange={event => setNavigationMode(event.target.value === "LNB" ? "LNB" : "GNB")}>
|
||||
<option value="GNB">GNB ONLY</option><option value="LNB">LNB ONLY</option>
|
||||
</select>
|
||||
<Specimens navigationMode={navigationMode} /><DesignerExtras />
|
||||
</section>
|
||||
</div>
|
||||
</main></StoryPage>
|
||||
}
|
||||
|
||||
const meta = { title: "Theme/Designer Playground", 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 /></>,
|
||||
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 => {
|
||||
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 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 }))
|
||||
}
|
||||
const click = (label: string) => 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()
|
||||
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`)
|
||||
}
|
||||
const core = [
|
||||
["Primary / Brand", "--hm-sem-action-primary", "#6837a8"],
|
||||
["Active / Selected", "--hm-sem-state-active", "#007f73"],
|
||||
["Focus", "--hm-sem-stroke-input-focus", "#c75b00"],
|
||||
["Accent", "--hm-sem-accent", "#ffd36d"],
|
||||
["Destructive", "--hm-sem-action-destructive", "#d41466"],
|
||||
] 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()
|
||||
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")
|
||||
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")
|
||||
assert(style('[data-slot="progress-indicator"]', "backgroundColor") === "rgb(0, 127, 115)", "Progress uses Active")
|
||||
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%`)
|
||||
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()
|
||||
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 }))
|
||||
}
|
||||
fillJson(exported); await settle(); click("Import JSON"); await settle()
|
||||
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()
|
||||
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()
|
||||
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()
|
||||
assert(designerThemeStore.getSnapshot().tokens[token] === value, `${label}: editor role works`)
|
||||
}
|
||||
assert(JSON.stringify(geometry()) === initial, "All color roles leave component layout unchanged")
|
||||
result.textContent = `PASS — ${checks.length} designer checks; viewport ${window.innerWidth}; live input, role independence, default load, reset, partial, import/export, layout, reload 0.`
|
||||
} catch (error) { result.textContent = `FAIL after ${checks.length} checks: ${String(error)}`; throw error }
|
||||
finally { designerThemeStore.reset() }
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Storybook compatibility adapter: Preview and service apps share one contract/API.
|
||||
export { applyRuntimeTheme as applyPreviewTheme, resetRuntimeTheme as resetPreviewTheme } from "@hm-design/ui/theme"
|
||||
@@ -0,0 +1,91 @@
|
||||
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"
|
||||
|
||||
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>
|
||||
<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>
|
||||
<div data-testid="loader-preview"><Specimens /><DesignerExtras /></div>
|
||||
</main>,
|
||||
play: async ({ canvasElement }) => verifyLoader(canvasElement),
|
||||
}
|
||||
|
||||
async function verifyLoader(container: HTMLElement) {
|
||||
const root = document.documentElement
|
||||
const result = container.querySelector<HTMLOutputElement>('[data-testid="loader-result"]')!
|
||||
if (result.dataset.running === "true") return
|
||||
result.dataset.running = "true"
|
||||
const checks: string[] = []
|
||||
const assert = (ok: boolean, label: string) => { if (!ok) throw Error(label); checks.push(label) }
|
||||
const variable = (property: string) => getComputedStyle(root).getPropertyValue(property).trim().toLowerCase()
|
||||
const geometry = () => Array.from(container.querySelectorAll<HTMLElement>("[data-slot]")).map(element => {
|
||||
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()
|
||||
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")
|
||||
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")
|
||||
assert(await loadRuntimeTheme(root, "/themes/service-example.json", { defaults }), "Loader accepts separately fetched Default JSON")
|
||||
assert(variable("--hm-sem-action-primary") === "#6837a8", "Service override wins supplied Default")
|
||||
resetRuntimeTheme(root)
|
||||
assert(variable("--hm-sem-state-active") === "#266651", "Reset returns package Default immediately")
|
||||
const invalid = [null, {}, {schemaVersion:2,tokens:{}}, {schemaVersion:1,tokens:{"unknown":"#123456"}}, {schemaVersion:1,tokens:{"color.state.active":null}}, {schemaVersion:1,tokens:{"color.state.active":"var(--evil)"}}, {schemaVersion:1,tokens:{"color.state.active":"url(javascript:evil)"}}, {schemaVersion:1,tokens:{"color.state.active":"<script>evil</script>"}}, {schemaVersion:1,tokens:{"color.state.active":"#123456; color:red"}}, {schemaVersion:1,tokens:{"color.state.active":"#abc"}}, {schemaVersion:1,tokens:{"color.bg.brand.stop.0":"#123456"}}, {schemaVersion:1,tokens:{"--hm-checkbox-checked-background":"#123456"}}]
|
||||
for (const document of invalid) {
|
||||
applyRuntimeTheme(root, { schemaVersion:1,tokens:{"color.state.active":"#007f73"} })
|
||||
assert(!await loadRuntimeTheme(root, dataUrl(document)), "Invalid/schema/missing/unsafe input fails closed")
|
||||
assert(variable("--hm-sem-state-active") === "#266651", "Invalid URL JSON releases override to HM fallback")
|
||||
}
|
||||
for (const url of ["data:application/json,malformed", "/themes/missing-verification-theme.json"]) {
|
||||
applyRuntimeTheme(root, {schemaVersion:1,tokens:{"color.state.active":"#007f73"}})
|
||||
assert(!await loadRuntimeTheme(root, url) && variable("--hm-sem-state-active") === "#266651", "Malformed/HTTP failure keeps usable HM Default")
|
||||
}
|
||||
const controller = new AbortController(); controller.abort()
|
||||
assert(!await loadRuntimeTheme(root, "/themes/default.json", {request:{signal:controller.signal}}), "Aborted fetch falls back safely")
|
||||
assert(await loadRuntimeTheme(root, dataUrl({schemaVersion:1,tokens:{}})), "Empty JSON uses HM Default")
|
||||
assert(variable("--hm-sem-state-active") === "#266651", "Empty JSON leaves Default Active")
|
||||
// Deterministic delayed CDN response simulation; no production network monkeypatch.
|
||||
let finish: ((response: Response) => void) | undefined
|
||||
globalThis.fetch = (input, init) => String(input).includes("delayed-verification")
|
||||
? new Promise<Response>(resolve => { finish = resolve }) : originalFetch(input, init)
|
||||
const delayed = () => new Response(JSON.stringify({schemaVersion:1,tokens:{"color.state.active":"#111111"}}), {headers:{"Content-Type":"application/json"}})
|
||||
const old = loadRuntimeTheme(root, "/delayed-verification")
|
||||
await loadRuntimeTheme(root, "/themes/service-example.json")
|
||||
finish!(delayed())
|
||||
assert(!await old && variable("--hm-sem-state-active") === "#007f73", "Older delayed CDN response cannot replace newer Theme")
|
||||
const pendingReset = loadRuntimeTheme(root, "/delayed-verification")
|
||||
resetRuntimeTheme(root); finish!(delayed())
|
||||
assert(!await pendingReset && variable("--hm-sem-state-active") === "#266651", "Reset cancels pending load ownership")
|
||||
const pendingApply = loadRuntimeTheme(root, "/delayed-verification")
|
||||
applyRuntimeTheme(root, {schemaVersion:1,tokens:{"color.state.active":"#345678"}}); finish!(delayed())
|
||||
assert(!await pendingApply && variable("--hm-sem-state-active") === "#345678", "Direct Apply supersedes pending load")
|
||||
resetRuntimeTheme(root)
|
||||
assert(JSON.stringify(geometry()) === initial && performance.timeOrigin === origin, "All URL changes preserve geometry in same document; reload 0")
|
||||
result.textContent = `PASS — ${checks.length} loader checks; viewport ${innerWidth}; Default/External/partial/reset/invalid, HTTP simulation, abort/race, geometry, reload 0.`
|
||||
} catch(error) { result.textContent = `FAIL after ${checks.length} checks: ${String(error)}`; throw error }
|
||||
finally { globalThis.fetch = originalFetch; resetRuntimeTheme(root); result.dataset.running = "false" }
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { useLayoutEffect, useRef, useSyncExternalStore, type CSSProperties } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { DashboardHeader, DashboardLayout, DashboardMain, DashboardSidebar } from "@hm-design/ui"
|
||||
import { Specimens } from "./specimens"
|
||||
import { applyRuntimeTheme, resetRuntimeTheme, defaultRuntimeTheme, runtimeThemeContract, type RuntimeTheme } from "@hm-design/ui/theme"
|
||||
import baseline from "./verification-baseline.json"
|
||||
|
||||
// Verification values are deliberately distinct. They are never imported by production.
|
||||
const external: RuntimeTheme = { schemaVersion: 1, tokens: {
|
||||
"color.action.primary": "#6837a8", "color.state.active": "#007f73",
|
||||
"color.stroke.input.focus": "#c75b00", "color.accent": "#ffd36d",
|
||||
"color.action.destructive": "#d41466",
|
||||
} }
|
||||
const meta = { title: "Theme/Runtime Contract", tags: ["verification"], parameters: { layout: "fullscreen" } } satisfies Meta
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
function ThemeScope({ theme, label, showNavigation = true }: { theme?: RuntimeTheme; label: string; showNavigation?: boolean }) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
useLayoutEffect(() => {
|
||||
const root = ref.current!
|
||||
if (theme) applyRuntimeTheme(root, theme)
|
||||
return () => resetRuntimeTheme(root)
|
||||
}, [theme])
|
||||
return <section ref={ref} data-hm-theme="preset" data-testid={label}>
|
||||
<h2 style={{ padding: "12px 20px" }}>{label}</h2><Specimens showNavigation={showNavigation} />
|
||||
</section>
|
||||
}
|
||||
|
||||
export const Comparison: Story = { render: () => <>
|
||||
<p style={{ padding: 20 }}>Default / External: Primary purple / Active teal / Focus orange / Accent yellow / Destructive pink.</p>
|
||||
<ThemeScope label="HM Default" /><ThemeScope label="External" theme={external} />
|
||||
</> }
|
||||
|
||||
function GlobalSpecimens() {
|
||||
useLayoutEffect(() => {
|
||||
applyRuntimeTheme(document.documentElement, external)
|
||||
return () => resetRuntimeTheme(document.documentElement)
|
||||
}, [])
|
||||
return <><h1 style={{ padding: 20 }}>Global External — popup/Portal verification</h1><Specimens /></>
|
||||
}
|
||||
export const GlobalExternal: Story = { render: () => <GlobalSpecimens /> }
|
||||
|
||||
const properties = ["backgroundColor", "backgroundImage", "color", "borderTopColor", "borderRadius", "boxShadow", "width", "height", "padding", "gap", "fontSize", "lineHeight", "display"] as const
|
||||
// CSS Color 4 serializes color-mix as color(srgb); canonicalize it to legacy RGB for before/after parity.
|
||||
const normalize = (value: string) => value.replace(/color\(srgb ([\d.]+) ([\d.]+) ([\d.]+)(?: \/ ([\d.]+))?\)/g,
|
||||
(_, r, g, b, a) => a && Number(a) !== 1 ? `rgba(${Math.round(Number(r)*255)}, ${Math.round(Number(g)*255)}, ${Math.round(Number(b)*255)}, ${Number(a)})` : `rgb(${Math.round(Number(r)*255)}, ${Math.round(Number(g)*255)}, ${Math.round(Number(b)*255)})`)
|
||||
const snapshot = (root: HTMLElement) => Array.from(root.querySelectorAll<HTMLElement>("[data-slot]"))
|
||||
.map(el => { const s = getComputedStyle(el); return { slot: el.dataset.slot, values: properties.map(p => normalize(s[p])) } })
|
||||
const settle = async (root: HTMLElement) => {
|
||||
// Bound the wait even when the browser suspends a transition in an inactive Story tab.
|
||||
void root.offsetHeight
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 250))
|
||||
}
|
||||
const shadowHasColor = (shadow: string, hex: string) => {
|
||||
const canvas = document.createElement("canvas")
|
||||
canvas.width = canvas.height = 1
|
||||
const context = canvas.getContext("2d", { willReadFrequently: true })!
|
||||
const target = [1, 3, 5].map(start => parseInt(hex.slice(start, start+2), 16))
|
||||
return [...shadow.matchAll(/(?:oklab|rgba?|color)\([^)]*\)/g)].some(([color]) => {
|
||||
context.clearRect(0,0,1,1); context.fillStyle = color; context.fillRect(0,0,1,1)
|
||||
const pixel = context.getImageData(0,0,1,1).data
|
||||
return pixel[3] > 0 && target.every((channel,i) => Math.abs(channel-pixel[i]) <= 2)
|
||||
})
|
||||
}
|
||||
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
|
||||
return <div data-testid="before" data-hm-theme="baseline" style={values as CSSProperties}><Specimens /></div>
|
||||
}
|
||||
|
||||
export const Verification: Story = {
|
||||
render: () => <div>
|
||||
{/* 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>
|
||||
<output data-testid="result" style={{ display: "block", padding: 20 }}>Ready — Run verification</output>
|
||||
<BaselineSpecimens />
|
||||
<div data-testid="after" data-hm-theme="preset"><Specimens /></div>
|
||||
</div>,
|
||||
play: async ({ canvasElement }) => verifyRuntime(canvasElement),
|
||||
}
|
||||
|
||||
async function verifyRuntime(canvasElement: HTMLElement) {
|
||||
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"
|
||||
const checks: string[] = []
|
||||
const assert = (condition: boolean, label: string) => { if (!condition) throw Error(label); checks.push(label) }
|
||||
const variable = (name: string) => {
|
||||
const value = getComputedStyle(after).getPropertyValue(name).trim().toLowerCase()
|
||||
// Static CSS minification can shorten HEX6 to HEX3; compare the same color.
|
||||
return /^#[\da-f]{3}$/.test(value) ? `#${value.slice(1).split("").map(character => character.repeat(2)).join("")}` : value
|
||||
}
|
||||
result.textContent = "Running… waiting for fonts"
|
||||
await document.fonts.ready
|
||||
result.textContent = "Running… comparing rendered Default"
|
||||
await settle(after)
|
||||
const initial = snapshot(after)
|
||||
const parity = () => snapshot(after).flatMap((row,i) => row.values.flatMap((v,j) => v === initial[i]?.values[j] ? [] : [`${row.slot}.${properties[j]}: ${initial[i]?.values[j]} → ${v}`]))
|
||||
try {
|
||||
const old = snapshot(before)
|
||||
const differences = initial.flatMap((row, i) => row.values.flatMap((v,j) => v === old[i]?.values[j] ? [] : [`${row.slot}.${properties[j]}: ${old[i]?.values[j]} → ${v}`]))
|
||||
assert(differences.length === 0, `Default visual parity (${initial.length} rendered parts) ${differences.join("; ")}`)
|
||||
assert(applyRuntimeTheme(after, defaultRuntimeTheme), "Default preset satisfies public contract")
|
||||
assert(JSON.stringify(snapshot(after)) === JSON.stringify(initial), "Default preset injection equals CSS fallback")
|
||||
resetRuntimeTheme(after)
|
||||
assert(applyRuntimeTheme(after, external), "External partial theme accepted")
|
||||
await settle(after)
|
||||
const expected: Record<string, string> = {
|
||||
"--hm-checkbox-checked-background": "#007f73", "--hm-radio-dot-color": "#007f73",
|
||||
"--hm-switch-on-background": "#007f73", "--hm-slider-range-background": "#007f73",
|
||||
"--hm-progress-indicator-background": "#007f73", "--hm-calendar-selected-background": "#007f73",
|
||||
"--hm-calendar-range-edge-background": "#007f73", "--hm-badge-default-background": "#6837a8",
|
||||
"--hm-pagination-active-background": "#6837a8", "--hm-input-focus-border-color": "#c75b00",
|
||||
"--ring": "#c75b00", "--hm-input-error-border-color": "#d41466",
|
||||
"--hm-button-outline-red-ink": "#d41466", "--hm-button-outline-green-ink": "#6837a8",
|
||||
"--hm-gnb-current-text": "#6837a8", "--hm-lnb-current-text": "#6837a8",
|
||||
}
|
||||
for (const [key,value] of Object.entries(expected)) assert(variable(key) === value, `${key} independent role (${variable(key)})`)
|
||||
const checked = after.querySelector<HTMLElement>('[data-slot="checkbox"][data-state="checked"]')!
|
||||
const badge = after.querySelector<HTMLElement>('[data-slot="badge"]')!
|
||||
const selectedDay = after.querySelector<HTMLElement>('[data-slot="calendar"] [data-selected] .rdp-day_button')!
|
||||
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()
|
||||
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(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)) {
|
||||
const group = key.includes("stop.") && !key.startsWith("color.palette.")
|
||||
if (group) continue // Covered as complete groups by the full default preset and external gradient document below.
|
||||
const value = def.type === "radius" ? "7px" : "#345678"
|
||||
assert(applyRuntimeTheme(after, { schemaVersion: 1, tokens: { [key]: value } }) && variable(def.property) === value, `${key} override`)
|
||||
}
|
||||
const stops = Object.fromEntries(Object.keys(runtimeThemeContract).filter(key => key.includes("stop.")).map(key => [key, "#345678"]))
|
||||
assert(applyRuntimeTheme(after, { schemaVersion: 1, tokens: stops }), "External complete gradient groups accepted")
|
||||
assert(variable("--hm-button-brand-green-background").includes("#345678"), "External stops reach Brand Button gradient")
|
||||
assert(variable("--hm-gnb-background").includes("#345678"), "External stops reach GNB gradient")
|
||||
// One-role changes may not leak into other core roles.
|
||||
const roles = ["color.action.primary", "color.state.active", "color.stroke.input.focus", "color.accent", "color.action.destructive"] as const
|
||||
for (const key of roles) {
|
||||
applyRuntimeTheme(after, { schemaVersion: 1, tokens: { [key]: "#345678" } })
|
||||
for (const other of roles.filter(k => k !== key)) assert(variable(runtimeThemeContract[other].property) === runtimeThemeContract[other].defaultValue, `${key} leaves ${other} unchanged`)
|
||||
}
|
||||
assert(applyRuntimeTheme(after, { schemaVersion: 1, tokens: {} }), "Empty tokens accepted")
|
||||
await settle(after)
|
||||
assert(parity().length === 0, `Empty theme preserves Default ${parity().join("; ")}`)
|
||||
for (const invalid of [null, {}, {schemaVersion:2,tokens:{}}, {schemaVersion:1,tokens:{"color.state.active":"red"}}, JSON.parse('{"schemaVersion":1,"tokens":{"__proto__":"#123456","color.state.active":"#007f73"}}'), {schemaVersion:1,tokens:{"--hm-checkbox-checked-background":"#123456"}}, {schemaVersion:1,tokens:{"color.bg.brand.stop.0":"#123456"}}]) {
|
||||
applyRuntimeTheme(after, external)
|
||||
assert(!applyRuntimeTheme(after, invalid), "Invalid document rejected atomically")
|
||||
await settle(after)
|
||||
assert(parity().length === 0, `Invalid document releases override to Default ${parity().join("; ")}`)
|
||||
}
|
||||
applyRuntimeTheme(after, external)
|
||||
const geometry = snapshot(after).map(r => r.values.slice(4,5).concat(r.values.slice(6)))
|
||||
assert(JSON.stringify(geometry) === JSON.stringify(initial.map(r => r.values.slice(4,5).concat(r.values.slice(6)))), "External color theme preserves geometry")
|
||||
// Global A + local B remain separate, and cleanup restores host-owned inline values.
|
||||
const globalRoot = document.documentElement
|
||||
try {
|
||||
applyRuntimeTheme(globalRoot, external)
|
||||
applyRuntimeTheme(after, {schemaVersion:1,tokens:{"color.state.active":"#345678"}})
|
||||
assert(variable("--hm-checkbox-checked-background") === "#345678", "Scoped B overrides global A")
|
||||
assert(getComputedStyle(globalRoot).getPropertyValue("--hm-checkbox-checked-background").trim() === "#007f73", "Global A retains its own mapping")
|
||||
assert(variable("--hm-badge-default-background") === "#123328", "Explicit isolated scope falls back to HM preset")
|
||||
} finally { resetRuntimeTheme(globalRoot); resetRuntimeTheme(after) }
|
||||
after.style.setProperty("--hm-sem-state-active", "#112233", "important")
|
||||
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.`
|
||||
} catch (error) {
|
||||
result.textContent = `FAIL after ${checks.length} checks — ${String(error)}`
|
||||
throw error
|
||||
} finally { resetRuntimeTheme(after); result.dataset.running = "false" }
|
||||
}
|
||||
|
||||
export const Dashboard: Story = { render: () => <DashboardLayout
|
||||
header={<DashboardHeader><strong>Theme Dashboard</strong></DashboardHeader>}
|
||||
sidebar={<DashboardSidebar><nav aria-label="Dashboard 메뉴"><a href="#" aria-current="page">Current</a></nav></DashboardSidebar>}>
|
||||
<DashboardMain><ThemeScope label="External" theme={external} showNavigation={false} /></DashboardMain>
|
||||
</DashboardLayout> }
|
||||
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
Button, Input, Checkbox, RadioGroup, RadioGroupItem, Switch, Select, SelectTrigger, SelectValue,
|
||||
SelectContent, SelectItem, Slider, Progress, Calendar, DatePicker, Pagination, PaginationContent,
|
||||
PaginationItem, PaginationLink, Badge, Table, TableHeader, TableBody, TableRow, TableHead, TableCell,
|
||||
GNB, GNBBrand, GNBNav, GNBMobileMenu, GNBMobileLink, GNBUtilities, LNB, LNBBrand, LNBNav, LNBItem,
|
||||
LNBMobileHeader, LNBMobileBrand, LNBMobileMenu, LNBMobileLink,
|
||||
Command, CommandList, CommandItem,
|
||||
} from "@hm-design/ui"
|
||||
export function Specimens({ navigationMode = "GNB", showNavigation = true }: { navigationMode?: "GNB" | "LNB"; showNavigation?: boolean }) {
|
||||
return <div style={{ display: "grid", gap: 18, padding: 20 }}>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 12 }}>
|
||||
<Button appearance="outline">Primary</Button><Button>Brand gradient</Button>
|
||||
<Button variant="destructive" appearance="outline">Destructive</Button>
|
||||
<Badge>Badge</Badge><Badge variant="destructive">Error</Badge>
|
||||
<Checkbox defaultChecked aria-label="Checked" /><Checkbox aria-label="Focus checkbox" />
|
||||
<RadioGroup defaultValue="yes"><RadioGroupItem value="yes" aria-label="Selected radio" /></RadioGroup>
|
||||
<Switch defaultChecked aria-label="ON" />
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))", gap: 12 }}>
|
||||
<Input placeholder="Focus input" /><Input aria-invalid="true" placeholder="Invalid input" />
|
||||
<Select defaultValue="a"><SelectTrigger aria-label="Select"><SelectValue /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="a">Selected</SelectItem><SelectItem value="b">Highlighted</SelectItem></SelectContent>
|
||||
</Select>
|
||||
<DatePicker defaultValue={new Date(2026, 9, 15)} />
|
||||
<Slider defaultValue={[60]} aria-label="Active slider" /><Progress value={60} />
|
||||
</div>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 20 }}>
|
||||
<Calendar mode="single" defaultMonth={new Date(2026, 9, 1)} today={new Date(2026, 9, 2)} selected={new Date(2026, 9, 15)} />
|
||||
<Calendar mode="range" defaultMonth={new Date(2026, 9, 1)} today={new Date(2026, 9, 2)} selected={{ from: new Date(2026, 9, 8), to: new Date(2026, 9, 14) }} />
|
||||
</div>
|
||||
<Pagination><PaginationContent><PaginationItem><PaginationLink href="#" isActive>1</PaginationLink></PaginationItem>
|
||||
<PaginationItem><PaginationLink href="#">2</PaginationLink></PaginationItem></PaginationContent></Pagination>
|
||||
<Table><TableHeader><TableRow><TableHead>Status</TableHead><TableHead>Value</TableHead></TableRow></TableHeader>
|
||||
<TableBody><TableRow data-state="selected"><TableCell>Selected</TableCell><TableCell>60</TableCell></TableRow>
|
||||
<TableRow><TableCell>Hover</TableCell><TableCell>40</TableCell></TableRow></TableBody></Table>
|
||||
<Command><CommandList><CommandItem value="a">Menu highlight</CommandItem><CommandItem value="b">Menu second</CommandItem></CommandList></Command>
|
||||
{showNavigation && navigationMode === "GNB" ? <GNB><GNBMobileMenu><GNBMobileLink href="#" active>Current</GNBMobileLink></GNBMobileMenu>
|
||||
<GNBBrand href="#"><span><strong>Runtime Theme</strong></span></GNBBrand>
|
||||
<GNBNav><span>GNB</span></GNBNav><GNBUtilities><Badge>Primary</Badge></GNBUtilities></GNB> : null}
|
||||
{showNavigation && navigationMode === "LNB" ? <div>
|
||||
<LNB style={{ minHeight: 180 }}><LNBBrand><strong>LNB Standalone</strong></LNBBrand>
|
||||
<LNBNav aria-label="LNB Preview"><LNBItem href="#" active>Current</LNBItem><LNBItem href="#">Item</LNBItem></LNBNav></LNB>
|
||||
<LNBMobileHeader>
|
||||
<LNBMobileMenu title="LNB Preview"><LNBMobileLink href="#" active>Current</LNBMobileLink><LNBMobileLink href="#">Item</LNBMobileLink></LNBMobileMenu>
|
||||
<LNBMobileBrand href="#">LNB Standalone</LNBMobileBrand>
|
||||
</LNBMobileHeader>
|
||||
</div> : null}
|
||||
</div>
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in new issue
Block a user