387 lines
16 KiB
TypeScript
387 lines
16 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, type BoardState } 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.
|
|
// `compact` (representative page only) tightens the cell padding so a 5x4 / 4x4 matrix stays short; buttons are untouched.
|
|
function StandardStateTable({ appearance, title, compact = false }: { appearance: "solid" | "outline"; title?: string; compact?: boolean }) {
|
|
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 [&_th]:text-left ${compact ? "[&_td]:px-2 [&_td]:py-1 [&_th]:px-2 [&_th]:py-1" : "[&_td]:p-3 [&_th]:p-3"}`}>
|
|
<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>
|
|
)
|
|
}
|
|
|
|
// Compact PC layout of the representative page (Storybook presentation only; Button itself is unchanged).
|
|
// Page and section spacing are tightened for this Story only through `compactPage`; the shared story-page.css is not edited.
|
|
// The important modifier is required because the shared rules use the same specificity.
|
|
const compactPage = [
|
|
"py-4!",
|
|
"[&_.sb-page-header]:pb-3!",
|
|
"[&_.sb-page-title]:mt-1!",
|
|
"[&_.sb-page-title]:mb-2!",
|
|
"[&_.sb-page-section]:mt-5!",
|
|
"[&_.sb-page-section-header]:pb-2!",
|
|
"[&_.sb-page-section-header]:mb-3!",
|
|
].join(" ")
|
|
// 8px gap so the three Brand sizes stay on one line in the narrowest (Brand) column, same as the Variants rows.
|
|
const compactRow = "flex flex-wrap items-center gap-2"
|
|
// Equal two columns over the whole content width. StateBoard's own auto-fill grid left an empty third column
|
|
// (2 items in a grid that fits 3), so the pairs are laid out here and each StateBoard holds one specimen.
|
|
const twoColumns = "grid gap-6 min-[900px]:grid-cols-[repeat(2,minmax(0,1fr))]"
|
|
// StateBoard lays its states out in an auto-fill grid. A board that holds one specimen inside a wide column would put it
|
|
// in the first of several inner columns (about half width at 1920px), so `min` is set larger than any column to force one.
|
|
const SingleColumnBoard = ({ states }: { states: BoardState[] }) => <StateBoard min={10000} states={states} />
|
|
const columnsAuto = (min: number) => `[grid-template-columns:repeat(auto-fit,minmax(min(100%,${min}px),1fr))]`
|
|
|
|
// 8px gap keeps all three Variants groups on one line at the 1280px PC width (Brand 3 + Solid 5 + Outline 4 buttons in a 3-column grid); narrower widths wrap.
|
|
function CompactRoleRow({ appearance }: { appearance: (typeof appearances)[number][0] }) {
|
|
return (
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{rolesFor(appearance).map(([variant, label]) => (
|
|
<Button key={variant} variant={variant} appearance={appearance}>
|
|
{label}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function CompactSizeRow({ appearance }: { appearance: "brand" | "solid" }) {
|
|
return (
|
|
<div className={compactRow}>
|
|
{sizes.map(([size, label]) => (
|
|
<Button key={size} appearance={appearance} size={size}>
|
|
{label}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Representative Story: the common Story page (Sizes → Variants → States) with the ui_viz Button "All" boards.
|
|
// The former Overview button was the default Brand Medium button already shown under Sizes, so Overview is merged into Sizes.
|
|
// 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 className={compactPage}>
|
|
<StoryPageHeader title="Button" />
|
|
<StorySection title="Sizes">
|
|
{/* Same columns as Variants below (Brand / Standard Solid / Standard Outline): Brand and Standard Solid sizes line up with
|
|
their Variants columns and the third column stays empty. */}
|
|
<div className={`grid gap-6 ${columnsAuto(260)} min-[960px]:[grid-template-columns:minmax(0,234fr)_minmax(0,353fr)_minmax(0,271fr)]`}>
|
|
<SingleColumnBoard states={[{ id: "sizes-brand", label: "Brand", children: <CompactSizeRow appearance="brand" /> }]} />
|
|
<SingleColumnBoard states={[{ id: "sizes-solid", label: "Standard Solid", children: <CompactSizeRow appearance="solid" /> }]} />
|
|
</div>
|
|
</StorySection>
|
|
<StorySection title="Variants">
|
|
<div className={`grid gap-6 ${columnsAuto(260)} min-[960px]:[grid-template-columns:minmax(0,234fr)_minmax(0,353fr)_minmax(0,271fr)]`}>
|
|
{appearances.map(([appearance, label]) => (
|
|
<SingleColumnBoard key={appearance} states={[{ id: `roles-${appearance}`, label, children: <CompactRoleRow appearance={appearance} /> }]} />
|
|
))}
|
|
</div>
|
|
</StorySection>
|
|
<StorySection title="States">
|
|
<div className="grid gap-6">
|
|
<SingleColumnBoard states={[{ id: "brand-states", label: "Brand", wide: true, children: <BrandStates showNote={false} /> }]} />
|
|
<div className={twoColumns}>
|
|
<SingleColumnBoard states={[{ id: "standard-solid-states", label: "Standard Solid", children: <StandardStateTable appearance="solid" compact /> }]} />
|
|
<SingleColumnBoard states={[{ id: "standard-outline-states", label: "Standard Outline", children: <StandardStateTable appearance="outline" compact /> }]} />
|
|
</div>
|
|
</div>
|
|
</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>
|
|
),
|
|
}
|