Files
HMDesign/src/components/button.stories.tsx
T

321 lines
12 KiB
TypeScript

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>
),
}