Files
HMDesign/src/components/button.stories.tsx
T
shkim4 4c29894e56 refactor(storybook): simplify component and preview examples
Drop page / section descriptions, state notes and repeated captions from
the representative Stories of 44 components, the Patterns, Center
Graphic and OrgChart pages. Value readouts, spec lists and captions hide
only on the representative page; hidden verification Stories keep them.
Tabs Default API and the Filter Bar draft / applied counts move to the
hidden DefaultApi and DraftApplied Stories; Center Overview is hidden
with its direct URL kept. Story IDs are unchanged.

Align the component, pattern and OrgChart docs with the pages, point the
quality reference check at the DraftApplied Story, and make the P8
styles check expect no section header on the preview-only Expanded page.
2026-10-23 00:28:05 +09:00

332 lines
13 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 { 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"
? true
: ["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는 solid/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-bold"
const note = "text-sm text-muted-foreground"
const appearances = [
["brand", "Brand"],
["solid", "Standard Solid"],
["outline", "Standard Outline"],
] as const
function RoleRow({ appearance }: { appearance: (typeof appearances)[number][0] }) {
return (
<div className={row}>
{rolesFor(appearance).map(([variant, label]) => (
<Button key={variant} variant={variant} appearance={appearance}>
{label}
</Button>
))}
</div>
)
}
function RolesBoard() {
return (
<div className={stack}>
{appearances.map(([appearance, title]) => (
<section key={appearance}>
<h3 className={heading}>{title}</h3>
<div className="mt-3">
<RoleRow appearance={appearance} />
</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>
)
}
// `showNote={false}` keeps the representative page UI-only; the verification Story keeps the note and the Activated readout.
function BrandStates({ showNote = true }: { showNote?: boolean }) {
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>
{showNote ? <output aria-live="polite">Activated: {count}</output> : null}
{showNote ? (
<p className={note}>
Hover와 Pressed는 Storybook이 고정한 상태입니다. Interact 위에서 실제 Hover·Pressed·포인터 이탈을 확인합니다. Disabled는 실행되지 않습니다.
</p>
) : null}
</div>
)
}
const standardStates = [
["Default", {}],
["Hover", { "data-sb-hover": "" }],
["Pressed", { "data-sb-active": "" }],
["Disabled", { disabled: true }],
] as const
// Without `title` the table sits under a StateBoard label (representative page) and drops the Role column:
// every button already shows its role name, so the row header would repeat it.
function StandardStateTable({ appearance, title }: { appearance: "solid" | "outline"; title?: string }) {
return (
<section className="min-w-0">
{title ? <h3 className={heading}>{title}</h3> : null}
<div className={`${title ? "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>
{title ? <th scope="col">Role</th> : null}
{standardStates.map(([state]) => (
<th scope="col" key={state}>
{state}
</th>
))}
</tr>
</thead>
<tbody>
{rolesFor(appearance).map(([variant, label]) => (
<tr key={variant}>
{title ? <th scope="row">{label}</th> : null}
{standardStates.map(([state, props]) => (
<td key={state}>
<Button variant={variant} appearance={appearance} {...props}>
{label}
</Button>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</section>
)
}
function StandardStatesBoard({ showNote = true }: { showNote?: boolean }) {
return (
<div className={stack}>
<StandardStateTable appearance="solid" title="Standard Solid" />
<StandardStateTable appearance="outline" title="Standard Outline" />
{showNote ? (
<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>
) : null}
</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="solid">
Cancel
</Button>
<Button variant="destructive" appearance="solid">
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 title="Button" />
<StorySection title="Overview">
<Button>Button</Button>
</StorySection>
<StorySection title="Sizes">
<SizesBoard />
</StorySection>
<StorySection title="Variants">
<StateBoard states={appearances.map(([appearance, label]) => ({ id: `roles-${appearance}`, label, wide: true, children: <RoleRow appearance={appearance} /> }))} />
</StorySection>
<StorySection title="States">
<StateBoard
states={[
{ id: "brand-states", label: "Brand", wide: true, children: <BrandStates showNote={false} /> },
{ id: "standard-solid-states", label: "Standard Solid", wide: true, children: <StandardStateTable appearance="solid" /> },
{ id: "standard-outline-states", label: "Standard Outline", wide: true, children: <StandardStateTable appearance="outline" /> },
]}
/>
</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 Solid, Delete는 Red Solid이며 appearance와 size를 통일합니다. Solid Hover/Pressed는 기존 Black 20% overlay이며 Red 토큰을 사용합니다." } },
},
}
// 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>
),
}