Initial commit: HMDesignSystem baseline

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

No files matched your search

+147
View File
@@ -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 /> }
+92
View File
@@ -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 /> }
+133
View File
@@ -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>
),
}
+83
View File
@@ -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" /> }
+101
View File
@@ -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 /> }
+106
View File
@@ -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" } }
+149
View File
@@ -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 />) }
+320
View File
@@ -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>
),
}
+86
View File
@@ -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 /> }
+138
View File
@@ -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>
),
}
+169
View File
@@ -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 /> }
+124
View File
@@ -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>,
}
+98
View File
@@ -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 /> }
+156
View File
@@ -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>,
}
+115
View File
@@ -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 /> }
+114
View File
@@ -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 }),
)
},
}
+145
View File
@@ -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 /> }
+212
View File
@@ -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 /> }
+121
View File
@@ -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 /> }
+108
View File
@@ -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>
),
}
+79
View File
@@ -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>
),
}
+221
View File
@@ -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} />),
}
+106
View File
@@ -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>
),
}
+148
View File
@@ -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()
},
}
+155
View File
@@ -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>
),
}))} />
),
}
+105
View File
@@ -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} />) }
+112
View File
@@ -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>
),
}
+93
View File
@@ -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")
}
},
}
+92
View File
@@ -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 /> }
+126
View File
@@ -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 /> }
+103
View File
@@ -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 /> }
+312
View File
@@ -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} />),
}
+89
View File
@@ -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> }
+130
View File
@@ -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 /> }
+78
View File
@@ -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 /> }
+148
View File
@@ -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)
},
}
+140
View File
@@ -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>
),
}
+36
View File
@@ -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>
)
}
+63
View File
@@ -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; }
+56
View File
@@ -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
+87
View File
@@ -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> }
+170
View File
@@ -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 />) }
+172
View File
@@ -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>
),
}
+192
View File
@@ -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} />),
}
+169
View File
@@ -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>
),
}
+140
View File
@@ -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>
),
}
+109
View File
@@ -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>
),
}