321 lines
12 KiB
TypeScript
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"
|
|
? 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"
|
|
|
|
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="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
|
|
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 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>
|
|
),
|
|
}
|