Files
HMDesign/src/components/button.stories.tsx
T
shkim4 71d504407d style(storybook): group form, selection and date picker story pages
- 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
2026-11-13 17:57:44 +09:00

480 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
),
}