style(storybook): refine form component stories

This commit is contained in:
shkim4 committed 2026-11-09 15:33:23 +09:00
1 parent e47eb040f2
commit f6b92d781a
23 files changed
+471 -182

No files matched your search

+72 -17
View File
@@ -2,7 +2,7 @@ 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 { 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):
@@ -159,12 +159,13 @@ const standardStates = [
// 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.
function StandardStateTable({ appearance, title }: { appearance: "solid" | "outline"; title?: string }) {
// `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 [&_td]:p-3 [&_th]:p-3 [&_th]:text-left">
<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}
@@ -230,31 +231,85 @@ function DestructiveBoard() {
)
}
// Representative Story: the common Story page (Overview → Sizes → Variants → States) with the ui_viz Button "All" boards.
// 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>
<StoryPage className={compactPage}>
<StoryPageHeader title="Button" />
<StorySection title="Overview">
<Button>Button</Button>
</StorySection>
<StorySection title="Sizes">
<SizesBoard />
{/* 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">
<StateBoard states={appearances.map(([appearance, label]) => ({ id: `roles-${appearance}`, label, wide: true, children: <RoleRow appearance={appearance} /> }))} />
<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">
<StateBoard
states={[
{ id: "brand-states", label: "Brand", wide: true, children: <BrandStates showNote={false} /> },
{ id: "standard-solid-states", label: "Standard Solid", wide: true, children: <StandardStateTable appearance="solid" /> },
{ id: "standard-outline-states", label: "Standard Outline", wide: true, children: <StandardStateTable appearance="outline" /> },
]}
/>
<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>
),
+37 -32
View File
@@ -1,18 +1,48 @@
import { useId, useState, type ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite"
import { Checkbox, Label } from "@hm-design/ui"
import { StateBoard } from "@/components/story-board"
import { StateBoard, type BoardState } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// Current HM Checkbox. ui-viz has no Checkbox spec, so it was reference only.
// The option label is the HM Label; its foreground role for Checkbox options is still TBD in ui_viz.
// Story-only Label color. The shared Label sets `color: var(--hm-label-text)` from an unlayered theme rule, so a utility
// like `text-foreground` loses to it; overriding the Label's own token on the element works. Option labels use the default
// text color; a Label next to a disabled Checkbox (`peer-disabled`) keeps the secondary color. State titles are untouched.
const optionLabel = "[--hm-label-text:var(--foreground)] peer-disabled:[--hm-label-text:var(--hm-sem-fg-secondary)]"
function Option({ label, children }: { label: string; children: (id: string) => ReactNode }) {
const id = useId()
return (
<div className="flex items-center gap-2">
{children(id)}
<Label htmlFor={id}>{label}</Label>
<Label htmlFor={id} className={optionLabel}>{label}</Label>
</div>
)
}
// Representative-page layout (Storybook presentation only): 3 equal columns on PC (iframe width 1000px and up), shrinking
// to fit below that. Each specimen sits in its own single-column StateBoard (min larger than any column), so StateBoard's
// auto-fill grid cannot leave an empty or stretched track.
function StateGrid({ states }: { states: BoardState[] }) {
return (
<div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,220px),1fr))] min-[1000px]:grid-cols-3">
{states.map((state) => (
<StateBoard key={state.id} min={10000} states={[state]} />
))}
</div>
)
}
// The wrapping Long Label example (former Usage section of the representative page), also used by the hidden `LongLabel` Story.
function LongLabelExample() {
return (
<div className="max-w-[220px]">
<div className="flex items-start gap-2">
<Checkbox id="cb-long" className="mt-0.5" />
<Label htmlFor="cb-long" className={optionLabel}>A very long checkbox label that wraps onto multiple lines</Label>
</div>
</div>
)
}
@@ -49,20 +79,12 @@ export const All: Story = {
render: () => (
<StoryPage>
<StoryPageHeader title="Checkbox" />
<StorySection title="Overview">
<StateBoard
min={240}
<StorySection title="States">
<StateGrid
states={[
{ id: "unchecked", label: "Unchecked", children: <Option label="Option">{(id) => <Checkbox id={id} />}</Option> },
{ id: "checked", label: "Checked", children: <Option label="Option">{(id) => <Checkbox id={id} defaultChecked />}</Option> },
{ id: "indeterminate", label: "Indeterminate", children: <Controlled /> },
]}
/>
</StorySection>
<StorySection title="States">
<StateBoard
min={240}
states={[
{
id: "hover",
label: "Hover",
@@ -89,26 +111,7 @@ export const All: Story = {
/>
</StorySection>
<StorySection title="Validation">
<StateBoard min={240} states={[{ id: "error", label: "Error", children: <Option label="Option">{(id) => <Checkbox id={id} aria-invalid />}</Option> }]} />
</StorySection>
<StorySection title="Usage">
<StateBoard
min={240}
states={[
{
id: "long",
label: "Long Label",
children: (
<div className="max-w-[220px]">
<div className="flex items-start gap-2">
<Checkbox id="cb-long" className="mt-0.5" />
<Label htmlFor="cb-long">A very long checkbox label that wraps onto multiple lines</Label>
</div>
</div>
),
},
]}
/>
<StateGrid states={[{ id: "error", label: "Error", children: <Option label="Option">{(id) => <Checkbox id={id} aria-invalid />}</Option> }]} />
</StorySection>
</StoryPage>
),
@@ -123,6 +126,8 @@ export const Checked: Story = { tags: ["verification"], render: () => <Option la
export const Indeterminate: Story = { tags: ["verification"], render: () => <Option label="Option">{(id) => <Checkbox id={id} checked="indeterminate" />}</Option> }
export const Disabled: Story = { tags: ["verification"], render: () => <Option label="Option">{(id) => <Checkbox id={id} disabled defaultChecked />}</Option> }
export const Error: Story = { tags: ["verification"], render: () => <Option label="Option">{(id) => <Checkbox id={id} aria-invalid />}</Option> }
// Former Usage section of the representative page, kept as its own Story.
export const LongLabel: Story = { tags: ["verification"], name: "Long Label", render: () => <LongLabelExample /> }
export const Playground: Story = {
tags: ["verification"],
args: { disabled: false, defaultChecked: false },
+4 -3
View File
@@ -9,8 +9,9 @@ import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-pag
// separate Stories tagged `verification`, which the sidebar hides by default (see .storybook/main.ts).
//
// Label, Required star and Helper/Error message are not part of the Input component. Specimen below composes them with
// plain elements and shadcn Token utilities, the way a consumer would: Label 14/20/500 and Helper 12/18/400 in the
// secondary text color, Error message 11/18 in the destructive color, field gap 8.
// plain elements and shadcn Token utilities, the way a consumer would: Label 14/20/500 in the default text color
// (Disabled keeps the secondary color at 0.6) and Helper 12/18/400 in the secondary text color, Error message 11/18
// in the destructive color, field gap 8.
function Specimen({
label = "Project name",
@@ -25,7 +26,7 @@ function Specimen({
const messageId = `${fieldId}-message`
return (
<div className="grid gap-2">
<label htmlFor={fieldId} className={`justify-self-start text-sm font-medium text-muted-foreground${input.disabled ? " opacity-60" : ""}`}>
<label htmlFor={fieldId} className={`justify-self-start text-sm font-medium ${input.disabled ? "text-muted-foreground opacity-60" : "text-foreground"}`}>
{label}
{required ? (
<span aria-hidden="true" className="text-destructive">
+38 -19
View File
@@ -1,11 +1,30 @@
import type { Meta, StoryObj } from "@storybook/react-vite"
import { Input, Label, Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@hm-design/ui"
import { StateBoard } from "@/components/story-board"
import { StateBoard, type BoardState } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// Label has no standalone ui_viz spec. Its typography and color are the Field Label role owned by Input / Textarea
// (14/20/500, color.fg.secondary). Required star and messages are composed here as a consumer would (not part of Label).
// Story-only Label color. The shared Label sets `color: var(--hm-label-text)` from an unlayered theme rule, so a utility like
// `text-foreground` loses to it; overriding the Label's own token on the element works. The Disabled example keeps the
// shared secondary color (at 0.5 through `peer-disabled`). State titles are untouched.
const fieldLabel = "[--hm-label-text:var(--foreground)]"
// Representative-page layout (Storybook presentation only): 3 equal columns on PC (iframe width 1000px and up), shrinking to
// fit below that (240px minimum). Each specimen sits in its own single-column StateBoard (min larger than any column), so
// StateBoard's auto-fill grid cannot leave an empty or stretched track. A section with fewer than 3 specimens keeps the
// same column width and leaves the remaining tracks empty.
function SpecimenGrid({ states }: { states: BoardState[] }) {
return (
<div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,240px),1fr))] min-[1000px]:grid-cols-3">
{states.map((state) => (
<StateBoard key={state.id} min={10000} states={[state]} />
))}
</div>
)
}
const meta = {
title: "Components/Form/Label",
component: Label,
@@ -28,25 +47,19 @@ export const All: Story = {
render: () => (
<StoryPage>
<StoryPageHeader title="Label" />
<StorySection title="Overview">
<StateBoard
<StorySection title="States">
<SpecimenGrid
states={[
{
id: "default",
label: "Default",
children: (
<div className="grid max-w-sm gap-2">
<Label htmlFor="label-default">Project name</Label>
<Label htmlFor="label-default" className={fieldLabel}>Project name</Label>
<Input id="label-default" size="medium" placeholder="Enter a project name" />
</div>
),
},
]}
/>
</StorySection>
<StorySection title="States">
<StateBoard
states={[
{
id: "disabled",
label: "Disabled",
@@ -63,20 +76,26 @@ export const All: Story = {
/>
</StorySection>
<StorySection title="Usage">
<div className="mb-4 grid max-w-sm gap-2">
<Label htmlFor="label-select">부서 선택</Label>
<Select><SelectTrigger id="label-select" size="medium"><SelectValue placeholder="부서를 선택하세요" /></SelectTrigger>
<SelectContent><SelectItem value="design">디자인</SelectItem><SelectItem value="engineering">개발</SelectItem></SelectContent>
</Select>
</div>
<StateBoard
<SpecimenGrid
states={[
{
id: "select",
label: "Select",
children: (
<div className="grid max-w-sm gap-2">
<Label htmlFor="label-select" className={fieldLabel}>부서 선택</Label>
<Select><SelectTrigger id="label-select" size="medium"><SelectValue placeholder="부서를 선택하세요" /></SelectTrigger>
<SelectContent><SelectItem value="design">디자인</SelectItem><SelectItem value="engineering">개발</SelectItem></SelectContent>
</Select>
</div>
),
},
{
id: "required",
label: "Required",
children: (
<div className="grid max-w-sm gap-2">
<Label htmlFor="label-required">
<Label htmlFor="label-required" className={fieldLabel}>
Project name
<span aria-hidden="true" className="text-destructive">
*
@@ -91,7 +110,7 @@ export const All: Story = {
label: "Long Label",
children: (
<div className="grid max-w-[220px] gap-2">
<Label htmlFor="label-long">A very long label text that wraps to multiple lines</Label>
<Label htmlFor="label-long" className={fieldLabel}>A very long label text that wraps to multiple lines</Label>
<Input id="label-long" size="medium" />
</div>
),
+42 -9
View File
@@ -1,10 +1,37 @@
import type { Meta, StoryObj } from "@storybook/react-vite"
import { Label, RadioGroup, RadioGroupItem } from "@hm-design/ui"
import { StateBoard } from "@/components/story-board"
import type { CSSProperties } from "react"
import { StateBoard, type BoardState } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// Current HM Radio Group. ui-viz has no Radio spec, so it was reference only.
// Story-only Label color. The shared Label sets `color: var(--hm-label-text)` from an unlayered theme rule, so a utility
// like `text-foreground` loses to it; overriding the Label's own token on the element works. Option labels use the default
// text color; a Label next to a disabled item (`peer-disabled`) keeps the secondary color. State titles are untouched.
const optionLabel = "[--hm-label-text:var(--foreground)] peer-disabled:[--hm-label-text:var(--hm-sem-fg-secondary)]"
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), shrinking to
// fit below that (`min` is the narrowest column). Each specimen sits in its own single-column StateBoard (min larger than
// any column), so StateBoard's auto-fill grid cannot leave an empty track. Full class names are listed so Tailwind sees them.
const specimenColumns = {
2: "min-[1000px]:grid-cols-2",
4: "min-[1000px]:grid-cols-4",
} as const
function SpecimenGrid({ states, columns, min = 220 }: { states: BoardState[]; columns: keyof typeof specimenColumns; min?: number }) {
return (
<div
className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,var(--sb-col-min)),1fr))] ${specimenColumns[columns]}`}
style={{ "--sb-col-min": `${min}px` } as CSSProperties}
>
{states.map((state) => (
<StateBoard key={state.id} min={10000} states={[state]} />
))}
</div>
)
}
function Group({
prefix,
defaultValue = "a",
@@ -28,7 +55,7 @@ function Group({
{["a", "b", "c"].map((v) => (
<div key={v} className="flex items-center gap-2">
<RadioGroupItem value={v} id={`${prefix}-${v}`} disabled={disabledItem === v} aria-invalid={invalid ? true : undefined} data-sb-hover={hoverItem === v ? "" : undefined} />
<Label htmlFor={`${prefix}-${v}`}>Option {v.toUpperCase()}</Label>
<Label htmlFor={`${prefix}-${v}`} className={optionLabel}>Option {v.toUpperCase()}</Label>
</div>
))}
</RadioGroup>
@@ -57,15 +84,20 @@ export const All: Story = {
render: () => (
<StoryPage>
<StoryPageHeader title="Radio Group" />
<StorySection title="Overview">
<StateBoard min={240} states={[{ id: "vertical", label: "Vertical", children: <Group prefix="v" defaultValue="b" /> }]} />
</StorySection>
{/* Overview is merged into Variants: its Vertical example is the first Variants item. */}
<StorySection title="Variants">
<StateBoard min={240} states={[{ id: "horizontal", label: "Horizontal", children: <Group prefix="h" orientation="horizontal" /> }]} />
<SpecimenGrid
columns={2}
min={320}
states={[
{ id: "vertical", label: "Vertical", children: <Group prefix="v" defaultValue="b" /> },
{ id: "horizontal", label: "Horizontal", children: <Group prefix="h" orientation="horizontal" /> },
]}
/>
</StorySection>
<StorySection title="States">
<StateBoard
min={240}
<SpecimenGrid
columns={4}
states={[
{ id: "hover", label: "Hover", children: <Group prefix="hv" defaultValue="" hoverItem="a" /> },
{ id: "focus", label: "Focus", children: <Group prefix="f" /> },
@@ -75,7 +107,8 @@ export const All: Story = {
/>
</StorySection>
<StorySection title="Validation">
<StateBoard min={240} states={[{ id: "error", label: "Error", children: <Group prefix="e" invalid /> }]} />
{/* Same 4-column track as States, so the Error example keeps the width of one States column. */}
<SpecimenGrid columns={4} states={[{ id: "error", label: "Error", children: <Group prefix="e" invalid /> }]} />
</StorySection>
</StoryPage>
),
+33 -8
View File
@@ -1,7 +1,7 @@
import { useId, type ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, type SelectSize } from "@hm-design/ui"
import { StateBoard } from "@/components/story-board"
import { StateBoard, type BoardState } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// Story structure follows ui_viz (implementations/web/storybook/Select.stories.jsx and SelectSizes.stories.jsx):
@@ -42,9 +42,11 @@ type SpecimenProps = {
function SelectSpecimen({ listSize, label = "Select", placeholder = "Choose a status", size, defaultValue, defaultOpen, disabled, "aria-invalid": ariaInvalid, options = statusOptions }: SpecimenProps) {
const id = useId()
// A Label next to an enabled Select uses the default text color; disabled keeps the secondary color at 0.6.
const labelTone = disabled ? "text-muted-foreground opacity-60" : "text-foreground"
return (
<div className="grid gap-2">
<label htmlFor={id} className={`justify-self-start text-sm font-medium text-muted-foreground${disabled ? " opacity-60" : ""}`}>
<label htmlFor={id} className={`justify-self-start text-sm font-medium ${labelTone}`}>
{label}
</label>
<Select defaultValue={defaultValue} defaultOpen={defaultOpen} disabled={disabled}>
@@ -109,6 +111,23 @@ function SizeComparison({ showSpec = true }: { showSpec?: boolean }) {
)
}
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), shrinking to
// fit below that. Each specimen sits in its own single-column StateBoard (min larger than any column), so StateBoard's
// auto-fill grid cannot leave an empty track. Full class names are listed so Tailwind can see them.
const specimenColumns = {
3: "min-[1000px]:grid-cols-3",
} as const
function SpecimenGrid({ states, columns }: { states: BoardState[]; columns: keyof typeof specimenColumns }) {
return (
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,240px),1fr))] ${specimenColumns[columns]}`}>
{states.map((state) => (
<StateBoard key={state.id} min={10000} states={[state]} />
))}
</div>
)
}
function DisabledStates() {
return (
<div className="grid max-w-[480px] gap-6">
@@ -157,11 +176,16 @@ export const All: Story = {
<StoryPage>
<StoryPageHeader title="Select" />
<StorySection title="Sizes">
<SizeComparison showSpec={false} />
{/* Same 3-column track as States / Validation: Medium and Large take the first two columns, the third stays empty. */}
<div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,240px),1fr))] min-[1000px]:grid-cols-3">
{sizes.map((spec) => (
<SizeCard key={spec.size} spec={spec} showSpec={false} />
))}
</div>
</StorySection>
<StorySection title="States">
<StateBoard
fixed
<SpecimenGrid
columns={3}
states={[
{ id: "selected", label: "Selected", children: <SelectSpecimen size="medium" label="Status" defaultValue="active" /> },
{ id: "disabled", label: "Disabled", children: <SelectSpecimen size="medium" label="Status" defaultValue="active" disabled /> },
@@ -169,9 +193,10 @@ export const All: Story = {
]}
/>
</StorySection>
<StorySection title="Invalid / Focus">
<StateBoard
fixed
<StorySection title="Validation">
{/* Same 3-column track as States: the two specimens take the first two columns (same Select width) and the third stays empty. */}
<SpecimenGrid
columns={3}
states={[
{ id: "invalid", label: "Invalid", children: <SelectSpecimen size="medium" aria-invalid label="Status" defaultValue="active" /> },
{ id: "invalid-disabled", label: "Invalid · Disabled", children: <SelectSpecimen size="medium" aria-invalid disabled label="Status" /> },
+24 -9
View File
@@ -12,7 +12,7 @@ function Labeled({ label, children, valueText }: { label: string; children: Reac
return (
<div className="grid gap-3">
<div className="flex justify-between text-sm">
<span className="font-medium">{label}</span>
<span className="font-medium text-foreground">{label}</span>
<span className="text-muted-foreground" data-testid="value">{valueText}</span>
</div>
{children}
@@ -32,8 +32,8 @@ function Single() {
function Range() {
const [value, setValue] = useState([25, 75])
return (
<Labeled label="가격 범위" valueText={`${value[0]} – ${value[1]}`}>
<Slider aria-label="가격 범위" value={value} onValueChange={setValue} max={100} step={1} minStepsBetweenThumbs={5} />
<Labeled label="범위" valueText={`${value[0]} – ${value[1]}`}>
<Slider aria-label="범위"value={value} onValueChange={setValue} max={100} step={1} minStepsBetweenThumbs={5} />
</Labeled>
)
}
@@ -71,20 +71,35 @@ const meta = { title: "Components/Form/Slider", component: Slider, parameters: {
export default meta
type Story = StoryObj<typeof meta>
const board = (...ids: string[]) => (
<StateBoard min={320} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} />
)
// Representative-page layout (Storybook presentation only): 4 equal columns on PC (iframe width 1000px and up), shrinking to
// fit below that (200px minimum per column). Each specimen sits in its own single-column StateBoard (min larger than any
// column), so StateBoard's auto-fill grid cannot leave an empty or stretched track. A section with fewer than 4 specimens
// keeps the same column width and leaves the remaining tracks empty.
function SpecimenGrid({ ids }: { ids: string[] }) {
return (
<div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,200px),1fr))] min-[1000px]:grid-cols-4">
{ids.map((id) => {
const s = sections.find((section) => section.id === id)!
return <StateBoard key={id} min={10000} states={[{ id: s.id, label: s.label, children: s.node }]} />
})}
</div>
)
}
// Representative Story: the common Story page; the sections group the same specimens.
// Overview is merged into Variants (Single is its first item). `Vertical` centers its Slider in its column.
export const All: Story = {
name: "Slider",
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader title="Slider" />
<StorySection title="Overview">{board("single")}</StorySection>
<StorySection title="Variants">{board("range", "step", "vertical")}</StorySection>
<StorySection title="States">{board("disabled")}</StorySection>
<StorySection title="Variants">
<SpecimenGrid ids={["single", "range", "step", "vertical"]} />
</StorySection>
<StorySection title="States">
<SpecimenGrid ids={["disabled"]} />
</StorySection>
</StoryPage>
),
}
+22 -12
View File
@@ -1,15 +1,33 @@
import { useId, type ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite"
import { Label, Switch } from "@hm-design/ui"
import { StateBoard } from "@/components/story-board"
import { StateBoard, type BoardState } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// Story-only Label color. The shared Label sets `color: var(--hm-label-text)` from an unlayered theme rule, so a utility
// like `text-foreground` loses to it; overriding the Label's own token on the element works. Option labels use the default
// text color; a Label next to a disabled Switch (`peer-disabled`) keeps the secondary color. State titles are untouched.
const optionLabel = "[--hm-label-text:var(--foreground)] peer-disabled:[--hm-label-text:var(--hm-sem-fg-secondary)]"
function Option({ label, children }: { label: string; children: (id: string) => ReactNode }) {
const id = useId()
return (
<div className="flex items-center gap-2">
{children(id)}
<Label htmlFor={id}>{label}</Label>
<Label htmlFor={id} className={optionLabel}>{label}</Label>
</div>
)
}
// Representative-page layout (Storybook presentation only): 5 equal columns on PC (iframe width 1000px and up), shrinking
// to fit below that (150px minimum per column). Each specimen sits in its own single-column StateBoard (min larger than
// any column), so StateBoard's auto-fill grid cannot leave an empty or stretched track.
function StateGrid({ states }: { states: BoardState[] }) {
return (
<div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,150px),1fr))] min-[1000px]:grid-cols-5">
{states.map((state) => (
<StateBoard key={state.id} min={10000} states={[state]} />
))}
</div>
)
}
@@ -36,19 +54,11 @@ export const All: Story = {
render: () => (
<StoryPage>
<StoryPageHeader title="Switch" />
<StorySection title="Overview">
<StateBoard
min={240}
<StorySection title="States">
<StateGrid
states={[
{ id: "off", label: "Off", children: <Option label="Option">{(id) => <Switch id={id} />}</Option> },
{ id: "on", label: "On", children: <Option label="Option">{(id) => <Switch id={id} defaultChecked />}</Option> },
]}
/>
</StorySection>
<StorySection title="States">
<StateBoard
min={240}
states={[
{
id: "hover",
label: "Hover",
+40 -17
View File
@@ -1,7 +1,7 @@
import { useId, type ComponentProps, type ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite"
import { Textarea } from "@hm-design/ui"
import { StateBoard } from "@/components/story-board"
import { StateBoard, type BoardState } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// Story structure follows Input: one representative "Textarea" Story shows every state on one page; the same specimens
@@ -9,8 +9,8 @@ import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-pag
// ui_viz docs/components/textarea.md §6 Reference Viewer: Default, Hover, Focus, Error, Error + Focus, Readonly, Disabled and Multiline.
//
// Label, Required star and Helper/Error message are not part of the Textarea component. Specimen composes them with plain
// elements and shadcn Token utilities, the way a consumer would: Label 14/20/500 and Helper 12/18/400 in the secondary
// text color, Error message 11/18 in the destructive color, field gap 8.
// elements and shadcn Token utilities, the way a consumer would: Label 14/20/500 in the default text color (Disabled keeps
// the secondary color) and Helper 12/18/400 in the secondary text color, Error message 11/18 in the destructive color, field gap 8.
function Specimen({
label = "Description",
@@ -25,7 +25,7 @@ function Specimen({
const messageId = `${fieldId}-message`
return (
<div className="grid gap-2">
<label htmlFor={fieldId} className="justify-self-start text-sm font-medium text-muted-foreground">
<label htmlFor={fieldId} className={`justify-self-start text-sm font-medium ${textarea.disabled ? "text-muted-foreground" : "text-foreground"}`}>
{label}
{required ? (
<span aria-hidden="true" className="text-destructive">
@@ -49,6 +49,23 @@ function Specimen({
)
}
// Representative-page layout (Storybook presentation only). StateBoard's own auto-fill grid cannot give equal columns that
// fill a cell, so each specimen sits in its own single-column StateBoard (min larger than any column) inside this grid.
// Full class names are listed so Tailwind can see them.
const specimenColumns = {
4: "min-[1000px]:grid-cols-4",
} as const
function SpecimenGrid({ states, columns }: { states: BoardState[]; columns: keyof typeof specimenColumns }) {
return (
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,220px),1fr))] ${specimenColumns[columns]}`}>
{states.map((state) => (
<StateBoard key={state.id} min={10000} states={[state]} />
))}
</div>
)
}
const narrow = (children: ReactNode) => <div className="max-w-sm">{children}</div>
const placeholder = "Enter a description"
const multiline = "First line\nSecond line keeps its line break.\nThird line."
@@ -86,12 +103,13 @@ export const All: Story = {
render: () => (
<StoryPage>
<StoryPageHeader title="Textarea" />
<StorySection title="Overview">
<StateBoard states={[{ id: "default", label: "Default", children: <Specimen placeholder={placeholder} /> }]} />
</StorySection>
{/* PC (iframe width 1000px and up): States in 4 equal columns, Validation's 2 specimens on the same column width.
Below that the columns shrink to fit (220px minimum, down to 1). Multiline and Resize live in their own hidden Stories. */}
<StorySection title="States">
<StateBoard
<SpecimenGrid
columns={4}
states={[
{ id: "default", label: "Default", children: <Specimen placeholder={placeholder} /> },
{ id: "focus", label: "Focus", children: <Specimen placeholder={placeholder} /> },
{ id: "readonly", label: "Readonly", children: <Specimen readOnly defaultValue="Confirmed description" /> },
{ id: "disabled", label: "Disabled", children: <Specimen disabled defaultValue="Unavailable description" /> },
@@ -99,21 +117,15 @@ export const All: Story = {
/>
</StorySection>
<StorySection title="Validation">
<StateBoard
{/* Same 4-column track as States, so the two specimens keep the States width instead of stretching across the page. */}
<SpecimenGrid
columns={4}
states={[
{ id: "error", label: "Error", children: <Specimen error defaultValue="Invalid value" /> },
{ id: "error-message", label: "Error Message", children: <Specimen error message="설명을 입력하세요." /> },
]}
/>
</StorySection>
<StorySection title="Usage">
<StateBoard
states={[
{ id: "multiline", label: "Multiline · Wrap", children: <Specimen defaultValue={multiline} /> },
{ id: "resize", label: "Resize · none", children: <Specimen resize="none" defaultValue="This field cannot be resized." /> },
]}
/>
</StorySection>
</StoryPage>
),
play: async ({ canvasElement }) => {
@@ -151,6 +163,17 @@ export const Required: Story = {
tags: ["verification"],
render: () => narrow(<Specimen required />),
}
// Former Usage section of the representative page, kept as separate Stories.
export const Multiline: Story = {
tags: ["verification"],
name: "Multiline · Wrap",
render: () => narrow(<Specimen defaultValue={multiline} />),
}
export const ResizeNone: Story = {
tags: ["verification"],
name: "Resize · none",
render: () => narrow(<Specimen resize="none" defaultValue="This field cannot be resized." />),
}
export const Readonly: Story = {
tags: ["verification"],
render: () => narrow(<Specimen readOnly defaultValue="Confirmed description" />),
+46 -27
View File
@@ -43,21 +43,24 @@ function Multiple({ showValue = true }: { showValue?: boolean }) {
)
}
function Disabled() {
function DisabledGroup() {
return (
<div className="grid gap-3">
<ToggleGroup type="single" variant="outline" defaultValue="a" disabled aria-label="비활성 그룹">
<ToggleGroupItem value="a">A</ToggleGroupItem>
<ToggleGroupItem value="b">B</ToggleGroupItem>
</ToggleGroup>
<ToggleGroup type="single" variant="outline" defaultValue="a" aria-label="비활성 항목">
<ToggleGroupItem value="a">A</ToggleGroupItem>
<ToggleGroupItem value="b" disabled>
B (비활성)
</ToggleGroupItem>
<ToggleGroupItem value="c">C</ToggleGroupItem>
</ToggleGroup>
</div>
<ToggleGroup type="single" variant="outline" defaultValue="a" disabled aria-label="비활성 그룹">
<ToggleGroupItem value="a">A</ToggleGroupItem>
<ToggleGroupItem value="b">B</ToggleGroupItem>
</ToggleGroup>
)
}
function DisabledItem() {
return (
<ToggleGroup type="single" variant="outline" defaultValue="a" aria-label="비활성 항목">
<ToggleGroupItem value="a">A</ToggleGroupItem>
<ToggleGroupItem value="b" disabled>
B (비활성)
</ToggleGroupItem>
<ToggleGroupItem value="c">C</ToggleGroupItem>
</ToggleGroup>
)
}
@@ -99,7 +102,8 @@ function Many() {
function Sizes() {
return (
<div className="grid gap-3">
// 3 equal columns on PC (iframe width 1000px and up): sm / default / lg; fewer columns below that (240px minimum).
<div className="grid items-start justify-items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,240px),1fr))] min-[1000px]:grid-cols-3">
{(["sm", "default", "lg"] as const).map((size) => (
<ToggleGroup key={size} type="multiple" size={size} variant="outline" aria-label={`${size} content kinds`}>
<ToggleGroupItem value="icon" aria-label={`${size} icon-only`}><BoldIcon /></ToggleGroupItem>
@@ -116,29 +120,42 @@ const meta = { title: "Components/Form/Toggle Group", parameters: { layout: "pad
export default meta
type Story = StoryObj<typeof meta>
// The representative page shows the groups without example titles: the section title names them.
const examples = (...nodes: ReactNode[]) => (
<div className="flex flex-wrap items-start gap-x-10 gap-y-6">
{nodes.map((node, i) => (
<div key={i}>{node}</div>
))}
</div>
)
// The representative page shows the groups without example titles: the section title names them. Equal columns on PC
// (iframe width 1000px and up), fewer below that (200px minimum); the groups keep their own size (they are not stretched to the
// column). Full class names are listed so Tailwind can see them.
const specimenColumns = {
3: "min-[1000px]:grid-cols-3",
4: "min-[1000px]:grid-cols-4",
} as const
function Examples({ columns, nodes }: { columns: keyof typeof specimenColumns; nodes: ReactNode[] }) {
return (
<div className={`grid items-start justify-items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,200px),1fr))] ${specimenColumns[columns]}`}>
{nodes.map((node, i) => (
<div key={i}>{node}</div>
))}
</div>
)
}
// Representative Story: the common Story page; the sections group the same specimens.
// Overview is merged into Variants (the default group is its first item). The 10-option Usage example is the hidden `ManyOptions` Story.
export const All: Story = {
name: "Toggle Group",
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader title="Toggle Group" />
<StorySection title="Overview">{examples(<Single showValue={false} />)}</StorySection>
<StorySection title="Variants">
<Examples columns={4} nodes={[<Single key="single" showValue={false} />, <Multiple key="multiple" showValue={false} />, <Vertical key="vertical" />, <Spaced key="spaced" />]} />
</StorySection>
<StorySection title="Sizes">
<Sizes />
</StorySection>
<StorySection title="Variants">{examples(<Multiple showValue={false} />, <Vertical />, <Spaced />)}</StorySection>
<StorySection title="States">{examples(<Disabled />)}</StorySection>
<StorySection title="Usage">{examples(<div className="max-w-sm"><Many /></div>)}</StorySection>
<StorySection title="States">
{/* First two columns of a 3-column grid; the third stays empty. */}
<Examples columns={3} nodes={[<DisabledGroup key="group" />, <DisabledItem key="item" />]} />
</StorySection>
</StoryPage>
),
}
@@ -146,6 +163,8 @@ export const All: Story = {
export const SingleSelection: Story = { tags: ["verification"], name: "Single", render: () => <Single /> }
export const MultipleSelection: Story = { tags: ["verification"], name: "Multiple", render: () => <Multiple /> }
export const VerticalGroup: Story = { tags: ["verification"], name: "Vertical", render: () => <Vertical /> }
// Former Usage section of the representative page (10 options that wrap), kept as its own Story.
export const ManyOptions: Story = { tags: ["verification"], name: "Many Options", render: () => <div className="max-w-sm"><Many /></div> }
export const ContentNodes: Story = {
tags: ["verification"],
+29 -9
View File
@@ -1,7 +1,7 @@
import type { Meta, StoryObj } from "@storybook/react-vite"
import { BoldIcon, ItalicIcon, StarIcon } from "lucide-react"
import { Toggle, ToggleGroup, ToggleGroupItem } from "@hm-design/ui"
import { StateBoard } from "@/components/story-board"
import { StateBoard, type BoardState } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// Toggle is a persistent pressed-state control. The representative Story shows each state with real, clickable toggles; the same
@@ -39,7 +39,8 @@ function WithIcon() {
function Sizes() {
return (
<div className="grid gap-3">
// 3 equal columns on PC (iframe width 1000px and up): sm / default / lg; fewer columns below that (240px minimum).
<div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,240px),1fr))] min-[1000px]:grid-cols-3">
{(["sm", "default", "lg"] as const).map((size) => (
<div key={size} data-toggle-size={size} className="flex flex-wrap items-center gap-3">
<Toggle size={size} variant="outline" aria-label={`${size} icon-only`}><StarIcon /></Toggle>
@@ -71,21 +72,41 @@ const meta = { title: "Components/Form/Toggle", component: Toggle, parameters: {
export default meta
type Story = StoryObj<typeof meta>
const board = (...ids: string[]) => (
<StateBoard min={360} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} />
)
// Representative-page layout (Storybook presentation only). StateBoard's own auto-fill grid cannot give equal columns that
// fill a cell, so each specimen sits in its own single-column StateBoard (min larger than any column) inside a grid.
const specimen = (id: string): BoardState => {
const s = sections.find((section) => section.id === id)!
return { id: s.id, label: s.label, children: s.node }
}
function SpecimenGrid({ states }: { states: BoardState[] }) {
return (
<div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,240px),1fr))] min-[1000px]:grid-cols-3">
{states.map((state) => (
<StateBoard key={state.id} min={10000} states={[state]} />
))}
</div>
)
}
// Representative Story: the common Story page; the sections group the same specimens.
// Overview is renamed Variants. On PC (iframe width 1000px and up) Variants and States use the first two of the same 3 equal
// columns as the rows below, and the third column stays empty; below that they stack.
export const All: Story = {
name: "Toggle",
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader title="Toggle" />
<StorySection title="Overview">{board("offon")}</StorySection>
<div className="grid gap-x-6 min-[1000px]:grid-cols-3">
<StorySection title="Variants">
<StateBoard min={10000} states={[specimen("offon")]} />
</StorySection>
<StorySection title="States">
<StateBoard min={10000} states={[specimen("disabled")]} />
</StorySection>
</div>
<StorySection title="단독 · 연결 그룹">
<StateBoard
min={360}
<SpecimenGrid
states={[
{ id: "single-toggle", label: "단독", children: <Toggle variant="outline">알림</Toggle> },
{ id: "group-single", label: "연결 그룹 · 단일 선택", children: <ToggleGroup type="single" variant="outline" defaultValue="left" aria-label="정렬"><ToggleGroupItem value="left">왼쪽</ToggleGroupItem><ToggleGroupItem value="center">가운데</ToggleGroupItem><ToggleGroupItem value="right">오른쪽</ToggleGroupItem></ToggleGroup> },
@@ -96,7 +117,6 @@ export const All: Story = {
<StorySection title="Sizes">
<Sizes />
</StorySection>
<StorySection title="States">{board("disabled")}</StorySection>
</StoryPage>
),
}