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 export default meta type Story = StoryObj 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 (
{rolesFor(appearance).map(([variant, label]) => ( ))}
) } function RolesBoard() { return (
{appearances.map(([appearance, title]) => (

{title}

))}
) } const sizes = [ ["sm", "Small"], ["default", "Medium"], ["lg", "Large"], ] as const function SizesBoard() { return (
{(["brand", "solid"] as const).map((appearance) => (
{sizes.map(([size, label]) => ( ))}
))}
) } // Verification "Brand States" (note and Activated readout). The representative page shows Brand states in StateMatrix. function BrandStates() { const [count, setCount] = useState(0) return (
Activated: {count}

Hover와 Pressed는 Storybook이 고정한 상태입니다. Interact 위에서 실제 Hover·Pressed·포인터 이탈을 확인합니다. Disabled는 실행되지 않습니다.

) } 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 (
{title ?

{title}

: null}
{title ? : null} {standardStates.map(([state]) => ( ))} {rolesFor(appearance).map(([variant, label]) => ( {title ? : null} {standardStates.map(([state, props]) => ( ))} ))}
Role {state}
{label}
) } function StandardStatesBoard({ showNote = true }: { showNote?: boolean }) { return (
{showNote ? (

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에서 확인합니다.

) : null}
) } function LongLabelBoard() { return (
) } function DestructiveBoard() { return (
) } // 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 (

{label}

{children}
) } // 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>(() => 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 {children} } function StateMatrix({ appearance, states }: { appearance: ButtonAppearance; states: readonly (readonly [string, Record])[] }) { const colors = rolesFor(appearance) const { stacked, report } = useContext(MatrixFit) const frame = useRef(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 (
{states.map(([state, props], index) => (
{state}
{colors.map(([variant, color]) => ( ))}
))}
) } return (
{colors.map(([variant, color]) => ( ))} {states.map(([state, props]) => ( {colors.map(([variant, color]) => ( ))} ))}
State {color}
{state}
) } // 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 } // 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: () => ( {/* 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). */} {appearances.map(([appearance, label]) => (
{sizes.map(([size, sizeLabel]) => ( ))}
))}
{/* 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. */}
), } // 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: () => } export const Sizes: Story = { tags: ["verification"], render: () => } export const States: Story = { tags: ["verification"], name: "Brand States", render: () => } export const StandardStates: Story = { tags: ["verification"], name: "Standard States", render: () => } export const LongLabel: Story = { tags: ["verification"], render: () => , parameters: { docs: { description: { story: "일반 Label은 한 줄입니다. 긴 Label은 기존 className API의 whitespace-normal로 줄바꿈을 명시하며, 폭이 부족하면 높이가 늘어나고 각 줄은 가운데 정렬됩니다." } }, }, } export const KoreanLabels: Story = { tags: ["verification"], render: () => (
{["저장", "변경사항 적용", "설정 저장", "파일 다운로드"].map((label) =>
)}
), } export const IconTextNarrow: Story = { tags: ["verification"], render: () => (
{(["sm", "default", "lg"] as const).map((size) =>
)}
), } export const Destructive: Story = { tags: ["verification"], render: () => , 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: () => (

shadcn compatibility

ui_viz 공식 Button 규격이 아닙니다. shadcn 기본 스타일을 그대로 씁니다.

alias

기존 shadcn 사용 코드를 위한 별칭입니다. default는 primary, outline은 primary + outline과 같습니다.

), }