style(storybook): refine form component stories
This commit is contained in:
1 parent
e47eb040f2
commit
f6b92d781a
23 files changed
+471
-182
No files matched your search
@@ -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>
|
||||
),
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
|
||||
@@ -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" /> },
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" />),
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user