- Button page shows Toggle; new Selection Controls page compares Checkbox, Radio Group and Switch states (specimens moved to *-examples.tsx) - Date Picker page includes Calendar; Combobox, Input and Slider layouts (Korean slider labels) tidied - Absorbed representative Stories keep their IDs as verification Stories - Component docs updated to the new representative pages
480 lines
21 KiB
TypeScript
480 lines
21 KiB
TypeScript
import { createContext, useCallback, useContext, useId, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react"
|
||
import { DownloadIcon } from "lucide-react"
|
||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||
import { Button, type ButtonAppearance } from "@hm-design/ui"
|
||
import { StoryPage, StoryPageHeader, StorySection, StorySectionGrid } from "@/components/story-page"
|
||
import { ToggleSections } from "./toggle-examples"
|
||
|
||
// 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>
|
||
)
|
||
}
|
||
|
||
// Verification "Brand States" (note and Activated readout). The representative page shows Brand states in StateMatrix.
|
||
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
|
||
|
||
// Verification "Standard States" table (rows = roles). The representative page uses StateMatrix (rows = states) instead.
|
||
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 [&_th]:text-left [&_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>
|
||
)
|
||
}
|
||
|
||
// Representative-page layout (2026-11-12 compact comparison). Page and section spacing come from the common Story page
|
||
// (story-page.css); only the arrangement below is local. Buttons are rendered with their public API and never restyled.
|
||
// Group label: the StateBoard H3 style (14/20/500, secondary). `data-sb-board` gives the block the common one-step section indent.
|
||
const groupLabel = "m-0 text-sm font-medium text-muted-foreground"
|
||
|
||
// 2026-11-13 (user request): one column per treatment, Sizes and States inside it. Each block is its H3 label (StateBoard H3
|
||
// style), 8px, then the specimens; Sizes and States are 24px apart. The blocks stay on the section edge (no data-sb-board
|
||
// indent) so the 420px Standard Solid matrix fits an equal third at the 1400px breakpoint without an inner scroll.
|
||
function GroupBlock({ id, label, children }: { id: string; label: string; children: ReactNode }) {
|
||
return (
|
||
<section data-overview-state={id} aria-label={label} className="grid min-w-0 content-start gap-2">
|
||
<h3 className={groupLabel}>{label}</h3>
|
||
{children}
|
||
</section>
|
||
)
|
||
}
|
||
|
||
// States: one matrix per treatment, rows = Default / Hover / Pressed / Disabled (standardStates, the same 4 rows for Brand;
|
||
// the live Interact button stays on the verification "Brand States"), columns = that treatment's colors in the roles order.
|
||
// Columns follow their buttons (auto table layout). 4px cell padding gives the same 8px gap as Sizes between buttons, rows,
|
||
// the state label and the color header; the outer edge keeps none (pl-0 / last:pr-0). Buttons sit centered under their color
|
||
// and are never resized. Matrix widths: Brand 301px, Standard Solid 420px, Standard Outline 338px.
|
||
// 2026-11-13 (user request, no inner scroll on narrow screens): when a matrix is wider than its column (the 420px Standard Solid
|
||
// matrix in a 358px column at 390px), every treatment switches together to the stacked layout: one row per state, its label
|
||
// above the buttons, which wrap with the same 8px gap. Each matrix measures its own table (the auto-layout width it needs) and
|
||
// reports whether it fits; MatrixFitProvider stacks all of them while any does not, so the three treatments keep one layout.
|
||
// Storybook presentation only: the buttons, their props and states are unchanged.
|
||
// Stacked layout state names (2026-11-13, user request): one step below the H3 "States" label (14/20/500) so the two levels
|
||
// read apart — 12/16/400, same secondary color. Only these names change; the common H2 / H3 styles are untouched.
|
||
const stackedStateLabel = "text-xs font-normal text-muted-foreground"
|
||
|
||
const MatrixFit = createContext<{ stacked: boolean; report: (id: string, overflow: boolean) => void }>({ stacked: false, report: () => {} })
|
||
|
||
function MatrixFitProvider({ children }: { children: ReactNode }) {
|
||
const [overflowing, setOverflowing] = useState<ReadonlySet<string>>(() => new Set())
|
||
const report = useCallback((id: string, overflow: boolean) => {
|
||
setOverflowing((current) => {
|
||
if (current.has(id) === overflow) return current
|
||
const next = new Set(current)
|
||
if (overflow) next.add(id)
|
||
else next.delete(id)
|
||
return next
|
||
})
|
||
}, [])
|
||
const value = useMemo(() => ({ stacked: overflowing.size > 0, report }), [overflowing, report])
|
||
return <MatrixFit.Provider value={value}>{children}</MatrixFit.Provider>
|
||
}
|
||
|
||
function StateMatrix({ appearance, states }: { appearance: ButtonAppearance; states: readonly (readonly [string, Record<string, unknown>])[] }) {
|
||
const colors = rolesFor(appearance)
|
||
const { stacked, report } = useContext(MatrixFit)
|
||
const frame = useRef<HTMLDivElement>(null)
|
||
// The table's own width, kept while the stacked layout is shown (no table to measure then).
|
||
const tableWidth = useRef(0)
|
||
const labelId = useId()
|
||
useLayoutEffect(() => {
|
||
const element = frame.current
|
||
if (!element) return
|
||
const check = () => {
|
||
const table = element.querySelector("table")
|
||
if (table) tableWidth.current = table.offsetWidth
|
||
report(appearance, tableWidth.current > element.clientWidth)
|
||
}
|
||
const observer = new ResizeObserver(check)
|
||
observer.observe(element)
|
||
check()
|
||
// The labels may get wider once the web font has loaded.
|
||
void document.fonts?.ready.then(check)
|
||
return () => {
|
||
observer.disconnect()
|
||
report(appearance, false)
|
||
}
|
||
}, [appearance, report])
|
||
if (stacked) {
|
||
return (
|
||
<div ref={frame} data-state-matrix="stacked" className="grid min-w-0 max-w-full gap-3">
|
||
{states.map(([state, props], index) => (
|
||
<div key={state} role="group" aria-labelledby={`${labelId}-${index}`} className="grid gap-1">
|
||
<span id={`${labelId}-${index}`} className={stackedStateLabel}>
|
||
{state}
|
||
</span>
|
||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||
{colors.map(([variant, color]) => (
|
||
<Button key={variant} variant={variant} appearance={appearance} {...props}>
|
||
{color}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
return (
|
||
<div ref={frame} data-state-matrix="table" className="min-w-0 max-w-full overflow-x-auto">
|
||
<table className="border-collapse">
|
||
<thead>
|
||
<tr>
|
||
<th scope="col" className={`${groupLabel} pb-1 pl-0 pr-1 text-left`}>
|
||
<span className="sr-only">State</span>
|
||
</th>
|
||
{colors.map(([variant, color]) => (
|
||
<th scope="col" key={variant} className={`${groupLabel} px-1 pb-1 text-center last:pr-0`}>
|
||
{color}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{states.map(([state, props]) => (
|
||
<tr key={state}>
|
||
<th scope="row" className={`${groupLabel} py-1 pl-0 pr-1 text-left whitespace-nowrap`}>
|
||
{state}
|
||
</th>
|
||
{colors.map(([variant, color]) => (
|
||
<td key={variant} className="p-1 text-center last:pr-0">
|
||
<Button variant={variant} appearance={appearance} {...props}>
|
||
{color}
|
||
</Button>
|
||
</td>
|
||
))}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// The Toggle Button section of the representative page, in the same stacked / matrix form as the state matrices.
|
||
function ToggleButtonSection() {
|
||
const { stacked } = useContext(MatrixFit)
|
||
return <ToggleSections titlePrefix="Toggle Button" idPrefix="toggle-" flat stacked={stacked} />
|
||
}
|
||
|
||
// Representative Story: the common Story page, one section per treatment (Brand | Standard Solid | Standard Outline).
|
||
// The former Overview button was the default Brand Medium button already shown under Sizes, so Overview is merged into Sizes.
|
||
// 2026-11-13 (user request): the Variants section and the page-level Sizes / States titles are removed; each treatment section
|
||
// (H2, common title → content 12px) holds its own Sizes and States (H3). The verification "Roles" Story keeps the role rows.
|
||
// 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" />
|
||
{/* Equal-width columns from a 1400px preview (the 420px Standard Solid matrix fits a third: 3 × 420 + 2 × 24 plus the page
|
||
margins and the scrollbar); below that Brand → Standard Solid → Standard Outline in one column, 28px apart (the
|
||
common section gap of StorySectionGrid). */}
|
||
<MatrixFitProvider>
|
||
<StorySectionGrid className="grid-cols-1 items-start min-[1400px]:grid-cols-3">
|
||
{appearances.map(([appearance, label]) => (
|
||
<StorySection key={appearance} id={`${appearance}-group`} title={label}>
|
||
<div className="grid min-w-0 gap-6">
|
||
<GroupBlock id={`sizes-${appearance}`} label="Sizes">
|
||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||
{sizes.map(([size, sizeLabel]) => (
|
||
<Button key={size} appearance={appearance} size={size}>
|
||
{sizeLabel}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
</GroupBlock>
|
||
<GroupBlock id={`${appearance}-states`} label="States">
|
||
<StateMatrix appearance={appearance} states={standardStates} />
|
||
</GroupBlock>
|
||
</div>
|
||
</StorySection>
|
||
))}
|
||
</StorySectionGrid>
|
||
{/* 2026-11-13 (user request): Toggle Button joins the Button page ("버튼") after the treatment columns, from
|
||
./toggle-examples unchanged; the Toggle Stories keep their IDs and direct URLs but are hidden from the sidebar.
|
||
`flat`: one "Toggle Button" H2 section, 36px below the columns above, with 3 equal group columns (Variants / States,
|
||
Groups, Sizes) from 1400px like the treatments, each row in the States matrix form (label left, 8px rhythm).
|
||
Inside MatrixFitProvider so its rows switch to the stacked form together with the matrices. */}
|
||
<ToggleButtonSection />
|
||
</MatrixFitProvider>
|
||
</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>
|
||
),
|
||
}
|