refactor(storybook): simplify component and preview examples

Drop page / section descriptions, state notes and repeated captions from
the representative Stories of 44 components, the Patterns, Center
Graphic and OrgChart pages. Value readouts, spec lists and captions hide
only on the representative page; hidden verification Stories keep them.
Tabs Default API and the Filter Bar draft / applied counts move to the
hidden DefaultApi and DraftApplied Stories; Center Overview is hidden
with its direct URL kept. Story IDs are unchanged.

Align the component, pattern and OrgChart docs with the pages, point the
quality reference check at the DraftApplied Story, and make the P8
styles check expect no section header on the preview-only Expanded page.
This commit is contained in:
shkim4 committed 2026-10-23 00:28:05 +09:00
1 parent 48977e7823
commit 4c29894e56
81 files changed
+520 -671

No files matched your search

+7 -10
View File
@@ -103,11 +103,11 @@ function LongSpecimen() {
}
const sections = [
{ id: "single", label: "Single", note: "type=\"single\": 한 번에 하나, 기본으로 첫 항목이 열림", node: <SingleSpecimen /> },
{ id: "collapsible-single", label: "Collapsible Single", note: "type=\"single\" collapsible", node: <CollapsibleSingleSpecimen /> },
{ id: "multiple", label: "Multiple", note: "type=\"multiple\": 동시에 여러 항목", node: <MultipleSpecimen /> },
{ id: "disabled", label: "Disabled Item", note: "disabled 항목은 열리지 않고 키보드 이동에서 제외", node: <DisabledSpecimen /> },
{ id: "long", label: "Long Content", note: "여러 줄 Trigger와 긴 콘텐츠", node: <LongSpecimen /> },
{ id: "single", label: "Single", node: <SingleSpecimen /> },
{ id: "collapsible-single", label: "Collapsible Single", node: <CollapsibleSingleSpecimen /> },
{ id: "multiple", label: "Multiple", node: <MultipleSpecimen /> },
{ id: "disabled", label: "Disabled Item", node: <DisabledSpecimen /> },
{ id: "long", label: "Long Content", node: <LongSpecimen /> },
]
// No `component` here: Accordion props are a union (single | multiple), so Stories without args would not type-check.
@@ -116,7 +116,7 @@ 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, note: s.note, children: s.node }))} />
<StateBoard min={360} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} />
)
// Representative Story: the common Story page; the sections group the same specimens.
@@ -125,10 +125,7 @@ export const All: Story = {
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader
title="Accordion"
description="동작은 Radix API · HM variant prop 없음"
/>
<StoryPageHeader title="Accordion" />
<StorySection title="Overview">{board("single")}</StorySection>
<StorySection title="Variants">
{board("collapsible-single", "multiple")}
+3 -6
View File
@@ -73,14 +73,11 @@ export const All: Story = {
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader
title="Alert Dialog"
description="닫기(X)·바깥 클릭 닫기 없음"
/>
<StorySection title="Overview" description="같은 크기 Solid 버튼 세트">
<StoryPageHeader title="Alert Dialog" />
<StorySection title="Overview">
<Confirmation />
</StorySection>
<StorySection title="Variants" description="취소·삭제 같은 크기 Standard Solid">
<StorySection title="Variants">
<Destructive />
</StorySection>
</StoryPage>
+1 -4
View File
@@ -26,10 +26,7 @@ export const All: Story = {
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader
title="Alert"
description="인라인 메시지(Toast 아님) · default / destructive만 · 닫기 없음"
/>
<StoryPageHeader title="Alert" />
<StorySection title="Overview">
<StateBoard
min={320}
+5 -8
View File
@@ -49,8 +49,8 @@ const sections = [
{ id: "r169", label: "16:9", node: <Image ratio={16 / 9} label="16:9" /> },
{ id: "r43", label: "4:3", node: <Image ratio={4 / 3} label="4:3" /> },
{ id: "r11", label: "1:1", node: <Image ratio={1} label="1:1" /> },
{ id: "video", label: "Video-like placeholder", node: <Placeholder /> },
{ id: "card", label: "Composed in a Card (consumer composition)", node: <InCard /> },
{ id: "video", label: "Video", node: <Placeholder /> },
{ id: "card", label: "Card", node: <InCard /> },
]
const meta = { title: "Components/Data & Display/Aspect Ratio", component: AspectRatio, parameters: { layout: "padded" } } satisfies Meta<typeof AspectRatio>
@@ -67,12 +67,9 @@ export const All: Story = {
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader
title="Aspect Ratio"
description="폭에 따라 비율대로 높이 결정"
/>
<StorySection title="Overview" description="ratio = 16 / 9.">{board("r169")}</StorySection>
<StorySection title="Variants" description="ratio 4 / 3 · 1 · 영상 자리">{board("r43", "r11", "video")}</StorySection>
<StoryPageHeader title="Aspect Ratio" />
<StorySection title="Overview">{board("r169")}</StorySection>
<StorySection title="Variants">{board("r43", "r11", "video")}</StorySection>
<StorySection title="Usage">{board("card")}</StorySection>
</StoryPage>
),
+7 -9
View File
@@ -28,7 +28,8 @@ function Sizes() {
)
}
function Fallbacks() {
// `showCaption={false}` drops the captions on the representative page (they restate the visible fallback text); the verification Story keeps them.
function Fallbacks({ showCaption = true }: { showCaption?: boolean }) {
const items = [
{ label: "한글 두 글자", text: "김한" },
{ label: "영문 두 글자", text: "HM" },
@@ -43,7 +44,7 @@ function Fallbacks() {
<AvatarImage src="/broken-avatar-image.png" alt="" />
<AvatarFallback>{i.text}</AvatarFallback>
</Avatar>
<span className="text-xs text-muted-foreground">{i.label}</span>
{showCaption ? <span className="text-xs text-muted-foreground">{i.label}</span> : null}
</div>
))}
</div>
@@ -66,8 +67,8 @@ function Group() {
}
const sections = [
{ id: "image", label: "Image", note: "이미지가 로드되면 fallback은 나타나지 않습니다", node: <Sizes /> },
{ id: "fallback", label: "Fallback (image error)", note: "이미지 로드 실패 시 initials", node: <Fallbacks /> },
{ id: "image", label: "Image", node: <Sizes /> },
{ id: "fallback", label: "Fallback", node: <Fallbacks showCaption={false} /> },
{ id: "composition", label: "Composition", node: <Group /> },
]
@@ -76,7 +77,7 @@ 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, note: s.note, children: s.node }))} />
<StateBoard min={320} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} />
)
// Representative Story: the common Story page; the sections group the same specimens.
@@ -85,10 +86,7 @@ export const All: Story = {
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader
title="Avatar"
description="이미지 실패 시 fallback 글자"
/>
<StoryPageHeader title="Avatar" />
<StorySection title="Overview">{board("image")}</StorySection>
<StorySection title="States">{board("fallback")}</StorySection>
<StorySection title="Usage">{board("composition")}</StorySection>
+4 -7
View File
@@ -28,14 +28,11 @@ export const All: Story = {
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader
title="Badge"
description="Button variant와 별도인 컴포넌트"
/>
<StorySection title="Overview" description="기본값: primary">
<StoryPageHeader title="Badge" />
<StorySection title="Overview">
<Badge>Badge</Badge>
</StorySection>
<StorySection title="Variants" description="default(primary) / secondary(neutral) / destructive / outline.">
<StorySection title="Variants">
<StateBoard
min={280}
states={[
@@ -76,7 +73,7 @@ export const All: Story = {
{ id: "long", label: "Long text", children: <div className="max-w-[160px]"><Badge>A very long badge label</Badge></div> },
{
id: "link",
label: "asChild (link)",
label: "Link",
children: (
<Badge asChild variant="outline">
<a href="#badge">Link badge</a>
+7 -10
View File
@@ -112,10 +112,10 @@ function CustomLink() {
}
const sections = [
{ id: "basic", label: "Basic (3 depth)", node: <Basic /> },
{ id: "ellipsis", label: "Ellipsis", note: "BreadcrumbEllipsis는 표시용입니다. 메뉴 연결은 소비 조합(Dropdown Menu)", node: <Ellipsis /> },
{ id: "long", label: "Long path", note: "좁은 폭에서 줄바꿈", node: <Long /> },
{ id: "custom", label: "Custom link (asChild) and separator", node: <CustomLink /> },
{ id: "basic", label: "Basic", node: <Basic /> },
{ id: "ellipsis", label: "Ellipsis", node: <Ellipsis /> },
{ id: "long", label: "Long path", node: <Long /> },
{ id: "custom", label: "Custom link · separator", node: <CustomLink /> },
]
const meta = { title: "Components/Navigation/Breadcrumb", component: Breadcrumb, parameters: { layout: "padded" } } satisfies Meta<typeof Breadcrumb>
@@ -123,7 +123,7 @@ export default meta
type Story = StoryObj<typeof meta>
const board = (...ids: string[]) => (
<StateBoard min={420} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, note: s.note, children: narrow(s.node) }))} />
<StateBoard min={420} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: narrow(s.node) }))} />
)
// Representative Story: the common Story page; the sections group the same specimens.
@@ -132,12 +132,9 @@ export const All: Story = {
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader
title="Breadcrumb"
description="마지막 항목 = 현재 페이지"
/>
<StoryPageHeader title="Breadcrumb" />
<StorySection title="Overview">{board("basic")}</StorySection>
<StorySection title="Variants" description="Ellipsis · 사용자 링크 · 구분자">{board("ellipsis", "custom")}</StorySection>
<StorySection title="Variants">{board("ellipsis", "custom")}</StorySection>
<StorySection title="Usage">{board("long")}</StorySection>
</StoryPage>
),
+39 -29
View File
@@ -71,24 +71,32 @@ const stack = "grid gap-6"
const heading = "text-base font-bold"
const note = "text-sm text-muted-foreground"
const appearances = [
["brand", "Brand"],
["solid", "Standard Solid"],
["outline", "Standard Outline"],
] as const
function RoleRow({ appearance }: { appearance: (typeof appearances)[number][0] }) {
return (
<div className={row}>
{rolesFor(appearance).map(([variant, label]) => (
<Button key={variant} variant={variant} appearance={appearance}>
{label}
</Button>
))}
</div>
)
}
function RolesBoard() {
return (
<div className={stack}>
{(
[
["brand", "Brand"],
["solid", "Standard Solid"],
["outline", "Standard Outline"],
] as const
).map(([appearance, title]) => (
{appearances.map(([appearance, title]) => (
<section key={appearance}>
<h3 className={heading}>{title}</h3>
<div className={`${row} mt-3`}>
{rolesFor(appearance).map(([variant, label]) => (
<Button key={variant} variant={variant} appearance={appearance}>
{label}
</Button>
))}
<div className="mt-3">
<RoleRow appearance={appearance} />
</div>
</section>
))}
@@ -118,7 +126,7 @@ function SizesBoard() {
)
}
// `showNote={false}` keeps the representative page UI-only; the verification Stories keep the state notes.
// `showNote={false}` keeps the representative page UI-only; the verification Story keeps the note and the Activated readout.
function BrandStates({ showNote = true }: { showNote?: boolean }) {
const [count, setCount] = useState(0)
return (
@@ -132,7 +140,7 @@ function BrandStates({ showNote = true }: { showNote?: boolean }) {
Disabled
</Button>
</div>
<output aria-live="polite">Activated: {count}</output>
{showNote ? <output aria-live="polite">Activated: {count}</output> : null}
{showNote ? (
<p className={note}>
Hover와 Pressed는 Storybook이 고정한 상태입니다. Interact 위에서 실제 Hover·Pressed·포인터 이탈을 확인합니다. Disabled는 실행되지 않습니다.
@@ -149,15 +157,17 @@ const standardStates = [
["Disabled", { disabled: true }],
] as const
function StandardStateTable({ appearance, title }: { appearance: "solid" | "outline"; title: string }) {
// 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 }) {
return (
<section className="min-w-0">
<h3 className={heading}>{title}</h3>
<div className="mt-3 min-w-0 max-w-full overflow-x-auto">
{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">
<thead>
<tr>
<th scope="col">Role</th>
{title ? <th scope="col">Role</th> : null}
{standardStates.map(([state]) => (
<th scope="col" key={state}>
{state}
@@ -168,7 +178,7 @@ function StandardStateTable({ appearance, title }: { appearance: "solid" | "outl
<tbody>
{rolesFor(appearance).map(([variant, label]) => (
<tr key={variant}>
<th scope="row">{label}</th>
{title ? <th scope="row">{label}</th> : null}
{standardStates.map(([state, props]) => (
<td key={state}>
<Button variant={variant} appearance={appearance} {...props}>
@@ -227,22 +237,22 @@ export const All: Story = {
parameters: { layout: "fullscreen" },
render: () => (
<StoryPage>
<StoryPageHeader title="Button" description="variant = 역할(색) · appearance = Brand / Standard Solid / Standard Outline · size = 높이" />
<StorySection title="Overview" description="기본값: Brand · Primary · Medium 34px">
<StoryPageHeader title="Button" />
<StorySection title="Overview">
<Button>Button</Button>
</StorySection>
<StorySection title="Sizes" description="28 / 34 / 42px · 위 Brand, 아래 Standard Solid">
<StorySection title="Sizes">
<SizesBoard />
</StorySection>
<StorySection title="Variants" description="appearance별 허용 역할 조합">
<RolesBoard />
<StorySection title="Variants">
<StateBoard states={appearances.map(([appearance, label]) => ({ id: `roles-${appearance}`, label, wide: true, children: <RoleRow appearance={appearance} /> }))} />
</StorySection>
<StorySection title="States" description="Hover·Pressed는 Storybook 고정 상태">
<StorySection title="States">
<StateBoard
min={1200}
states={[
{ id: "brand-states", label: "Brand", children: <BrandStates showNote={false} /> },
{ id: "standard-states", label: "Standard Solid · Standard Outline", children: <StandardStatesBoard showNote={false} /> },
{ 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" /> },
]}
/>
</StorySection>
+8 -10
View File
@@ -14,12 +14,13 @@ const month = new Date(2026, 9, 1)
const box = (node: ReactNode) => <div className="rounded-md border bg-card w-fit">{node}</div>
function Single() {
// `showValue={false}` hides the value readout on the representative page; the verification Stories keep it.
function Single({ showValue = true }: { showValue?: boolean }) {
const [date, setDate] = useState<Date | undefined>(new Date(2026, 9, 15))
return (
<div className="grid gap-2">
{box(<Calendar mode="single" selected={date} onSelect={setDate} defaultMonth={month} today={today} />)}
<span className="text-sm text-muted-foreground" data-testid="single">{`선택: ${date ? date.toLocaleDateString("ko-KR") : "없음"}`}</span>
{showValue ? <span className="text-sm text-muted-foreground" data-testid="single">{`선택: ${date ? date.toLocaleDateString("ko-KR") : "없음"}`}</span> : null}
</div>
)
}
@@ -48,8 +49,8 @@ function TwoMonths() {
}
const sections = [
{ id: "single", label: "Single (today = 10/2, selected = 10/15)", node: <Single /> },
{ id: "disabled", label: "Disabled dates (before 10/5, Sundays)", node: <DisabledDates /> },
{ id: "single", label: "Single", node: <Single showValue={false} /> },
{ id: "disabled", label: "Disabled dates", node: <DisabledDates /> },
{ id: "range", label: "Range", node: <Range /> },
{ id: "months", label: "Two months", node: <TwoMonths /> },
]
@@ -69,12 +70,9 @@ export const All: Story = {
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader
title="Calendar"
description="기본 locale 한국어 · 오늘은 2026-10-02로 고정"
/>
<StorySection title="Overview" description='mode="single"'>{board("single")}</StorySection>
<StorySection title="Variants" description='mode="range"와 여러 달 표시(numberOfMonths=2)입니다.'>{board("range", "months")}</StorySection>
<StoryPageHeader title="Calendar" />
<StorySection title="Overview">{board("single")}</StorySection>
<StorySection title="Variants">{board("range", "months")}</StorySection>
<StorySection title="States">{board("disabled")}</StorySection>
</StoryPage>
),
+6 -9
View File
@@ -70,19 +70,16 @@ export const All: Story = {
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader
title="Card"
description="Default 상태만 정의"
/>
<StorySection title="Overview" description="기본값: White">
<StateBoard min={360} states={[{ id: "surface", label: "Surface · White (기본)", children: <Specimen /> }]} />
<StoryPageHeader title="Card" />
<StorySection title="Overview">
<StateBoard min={360} states={[{ id: "surface", label: "White", children: <Specimen /> }]} />
</StorySection>
<StorySection title="Variants" description="content(Beige) · 제목 옆 Action">
<StorySection title="Variants">
<StateBoard
min={360}
states={[
{ id: "content", label: "Surface · Content (Beige)", children: <Specimen surface="content" /> },
{ id: "action", label: "Title + Action", noteAfter: true, note: "Action은 Title 반대쪽 끝, 교차축 중앙 정렬. Title과 20px 이상 간격.", children: <Specimen action /> },
{ id: "content", label: "Content (Beige)", children: <Specimen surface="content" /> },
{ id: "action", label: "Title + Action", children: <Specimen action /> },
]}
/>
</StorySection>
+11 -13
View File
@@ -37,7 +37,8 @@ function useReadout() {
return { setApi, current, count }
}
function Basic() {
// `showValue={false}` hides the value readout on the representative page; the verification Stories keep it.
function Basic({ showValue = true }: { showValue?: boolean }) {
const { setApi, current, count } = useReadout()
return frame(
<div className="grid gap-2">
@@ -52,7 +53,7 @@ function Basic() {
<CarouselPrevious />
<CarouselNext />
</Carousel>
<span className="text-center text-sm text-muted-foreground" data-testid="readout">{`slide ${current} / ${count}`}</span>
{showValue ? <span className="text-center text-sm text-muted-foreground" data-testid="readout">{`slide ${current} / ${count}`}</span> : null}
</div>
)
}
@@ -73,7 +74,7 @@ function Multiple() {
)
}
function Loop() {
function Loop({ showValue = true }: { showValue?: boolean }) {
const { setApi, current, count } = useReadout()
return frame(
<div className="grid gap-2">
@@ -88,7 +89,7 @@ function Loop() {
<CarouselPrevious />
<CarouselNext />
</Carousel>
<span className="text-center text-sm text-muted-foreground" data-testid="loop-readout">{`slide ${current} / ${count}`}</span>
{showValue ? <span className="text-center text-sm text-muted-foreground" data-testid="loop-readout">{`slide ${current} / ${count}`}</span> : null}
</div>
)
}
@@ -130,11 +131,11 @@ function Media() {
}
const sections = [
{ id: "basic", label: "Basic (single item) + setApi readout", node: <Basic /> },
{ id: "multiple", label: "Multiple items (basis-1/2)", node: <Multiple /> },
{ id: "loop", label: "Loop (controls never disable)", node: <Loop /> },
{ id: "basic", label: "Basic", node: <Basic showValue={false} /> },
{ id: "multiple", label: "Multiple items", node: <Multiple /> },
{ id: "loop", label: "Loop", node: <Loop showValue={false} /> },
{ id: "vertical", label: "Vertical", node: <Vertical /> },
{ id: "media", label: "Media (AspectRatio composition)", node: <Media /> },
{ id: "media", label: "Media", node: <Media /> },
]
const meta = { title: "Components/Data & Display/Carousel", component: Carousel, parameters: { layout: "padded" } } satisfies Meta<typeof Carousel>
@@ -151,12 +152,9 @@ export const All: Story = {
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader
title="Carousel"
description="슬라이드 폭(basis-*)은 CarouselItem className"
/>
<StoryPageHeader title="Carousel" />
<StorySection title="Overview">{board("basic")}</StorySection>
<StorySection title="Variants" description="여러 항목 · loop · 세로">{board("multiple", "loop", "vertical")}</StorySection>
<StorySection title="Variants">{board("multiple", "loop", "vertical")}</StorySection>
<StorySection title="Usage">{board("media")}</StorySection>
</StoryPage>
),
+16 -9
View File
@@ -17,10 +17,11 @@ function Option({ label, children }: { label: string; children: (id: string) =>
)
}
// Starts indeterminate; a click makes it checked (Radix), so the Indeterminate example is also the interactive one.
function Controlled() {
const [checked, setChecked] = useState<boolean | "indeterminate">("indeterminate")
return (
<Option label={`Select all (${String(checked)})`}>
<Option label="Option">
{(id) => <Checkbox id={id} checked={checked} onCheckedChange={setChecked} />}
</Option>
)
@@ -47,18 +48,14 @@ export const All: Story = {
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader
title="Checkbox"
description="18px 박스 · Checked·Indeterminate는 state-active 색"
/>
<StoryPageHeader title="Checkbox" />
<StorySection title="Overview">
<StateBoard
min={240}
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: <Option label="Option">{(id) => <Checkbox id={id} checked="indeterminate" />}</Option> },
{ id: "interactive", label: "Controlled · click toggles", noteAfter: true, note: "Indeterminate에서 클릭하면 checked가 됩니다(Radix).", children: <Controlled /> },
{ id: "indeterminate", label: "Indeterminate", children: <Controlled /> },
]}
/>
</StorySection>
@@ -66,7 +63,17 @@ export const All: Story = {
<StateBoard
min={240}
states={[
{ id: "hover", label: "Hover", noteAfter: true, note: "Unchecked는 테두리가 primary 색, Checked는 Black 20% overlay. 포인터를 올려 확인합니다.", children: <Option label="Option">{(id) => <Checkbox id={id} />}</Option> },
{
id: "hover",
label: "Hover",
// data-sb-hover pins :hover (Storybook only, .storybook/pseudo-states.ts).
children: (
<div className="grid gap-3">
<Option label="Unchecked">{(id) => <Checkbox id={id} data-sb-hover="" />}</Option>
<Option label="Checked">{(id) => <Checkbox id={id} defaultChecked data-sb-hover="" />}</Option>
</div>
),
},
{ id: "focus", label: "Focus", children: <Option label="Option">{(id) => <Checkbox id={id} />}</Option> },
{
id: "disabled",
@@ -81,7 +88,7 @@ export const All: Story = {
]}
/>
</StorySection>
<StorySection title="Validation" description="aria-invalid → Error 테두리">
<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">
+3 -6
View File
@@ -63,7 +63,7 @@ const sections = [
{ id: "open", label: "Open", node: <OpenSpecimen /> },
{ id: "disabled", label: "Disabled", node: <DisabledSpecimen /> },
{ id: "long", label: "Long Content", node: <LongSpecimen /> },
{ id: "controlled", label: "Controlled + Button trigger", note: "asChild: Button이 trigger이며 HM Button 스타일 유지", node: <ControlledSpecimen /> },
{ id: "controlled", label: "Button trigger", node: <ControlledSpecimen /> },
]
const meta = { title: "Components/Disclosure/Collapsible", component: Collapsible, parameters: { layout: "padded" } } satisfies Meta<typeof Collapsible>
@@ -71,7 +71,7 @@ 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, note: s.note, children: s.node }))} />
<StateBoard min={360} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} />
)
// Representative Story: the common Story page; the sections group the same specimens.
@@ -80,10 +80,7 @@ export const All: Story = {
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader
title="Collapsible"
description="구분선·애니메이션 없음"
/>
<StoryPageHeader title="Collapsible" />
<StorySection title="Overview">{board("closed")}</StorySection>
<StorySection title="States">{board("open", "disabled")}</StorySection>
<StorySection title="Usage">{board("long", "controlled")}</StorySection>
+15 -18
View File
@@ -40,14 +40,15 @@ function Empty() {
)
}
function Controlled() {
// `showValue={false}` hides the value readout on the representative page; the verification Stories keep it.
function Controlled({ showValue = true }: { showValue?: boolean }) {
const [value, setValue] = useState("nuxt")
return (
<div className="grid gap-2">
<Field label="선택됨 (controlled)" id="cb-controlled">
<Combobox id="cb-controlled" controlled options={frameworks} value={value} onValueChange={setValue} />
</Field>
<span className="text-sm text-muted-foreground" data-testid="controlled">{`value = ${value}`}</span>
{showValue ? <span className="text-sm text-muted-foreground" data-testid="controlled">{`value = ${value}`}</span> : null}
</div>
)
}
@@ -99,9 +100,8 @@ function SelectComparison() {
}
const sections = [
{ id: "empty", label: "Empty placeholder + search (Remix is a disabled option)", node: <Empty /> },
{ id: "controlled", label: "Selected, controlled", node: <Controlled /> },
{ id: "medium", label: "Medium size", node: <Medium /> },
{ id: "empty", label: "Placeholder", node: <Empty /> },
{ id: "controlled", label: "Selected", node: <Controlled showValue={false} /> },
{ id: "long", label: "Long option", node: <LongLabel /> },
{ id: "disabled", label: "Disabled", node: <Disabled /> },
]
@@ -121,19 +121,16 @@ export const All: Story = {
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader
title="Combobox"
description="Popover + Command · 짧은 목록은 Select 사용"
/>
<StorySection title="Overview" description="Escape로 닫으면 Trigger로 focus 복귀">{board("empty")}</StorySection>
<StorySection title="Sizes" description="기본값: Large · 같은 size의 Select와 비교">
<StateBoard
min={320}
states={[
...sections.filter((s) => s.id === "medium").map((s) => ({ id: s.id, label: s.label, children: s.node })),
{ id: "select-comparison", label: "Select / Combobox visual comparison", wide: true, children: <SelectComparison /> },
]}
/>
<StoryPageHeader title="Combobox" />
<StorySection title="Overview">{board("empty")}</StorySection>
<StorySection title="Sizes">
{/* The field label already names the size, so the Medium specimen has no separate example title. */}
<div className="grid gap-10">
<div className="max-w-sm">
<Medium />
</div>
<StateBoard min={320} states={[{ id: "select-comparison", label: "Select / Combobox", wide: true, children: <SelectComparison /> }]} />
</div>
</StorySection>
<StorySection title="States">{board("controlled", "disabled")}</StorySection>
<StorySection title="Usage">{board("long")}</StorySection>
+7 -9
View File
@@ -11,7 +11,8 @@ import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-pag
const box = (node: ReactNode) => <div className="w-full max-w-sm rounded-md border">{node}</div>
function Basic() {
// `showValue={false}` hides the value readout on the representative page; the verification Stories keep it.
function Basic({ showValue = true }: { showValue?: boolean }) {
const [last, setLast] = useState("-")
return (
<div className="grid gap-2">
@@ -29,7 +30,7 @@ function Basic() {
</CommandList>
</Command>
)}
<span className="text-sm text-muted-foreground" data-testid="last">{`선택: ${last}`}</span>
{showValue ? <span className="text-sm text-muted-foreground" data-testid="last">{`선택: ${last}`}</span> : null}
</div>
)
}
@@ -80,9 +81,9 @@ function Long() {
}
const sections = [
{ id: "basic", label: "Search + disabled item", node: <Basic /> },
{ id: "grouped", label: "Groups, icons, shortcut", node: <Grouped /> },
{ id: "long", label: "Long list (list scrolls)", node: <Long /> },
{ id: "basic", label: "Search", node: <Basic showValue={false} /> },
{ id: "grouped", label: "Groups", node: <Grouped /> },
{ id: "long", label: "Long list", node: <Long /> },
]
const meta = { title: "Components/Search & Date/Command", component: Command, parameters: { layout: "padded" } } satisfies Meta<typeof Command>
@@ -99,10 +100,7 @@ export const All: Story = {
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader
title="Command"
description="Combobox와 명령 팔레트의 기반"
/>
<StoryPageHeader title="Command" />
<StorySection title="Overview">{board("basic")}</StorySection>
<StorySection title="Variants">{board("grouped")}</StorySection>
<StorySection title="Usage">{board("long")}</StorySection>
+2 -5
View File
@@ -82,11 +82,8 @@ export const All: Story = {
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader
title="Context Menu"
description="오른쪽 클릭 · Shift+F10 · 길게 누르기로 열기"
/>
<StorySection title="Overview" description="점선 영역에서 오른쪽 클릭">
<StoryPageHeader title="Context Menu" />
<StorySection title="Overview">
<Full />
</StorySection>
</StoryPage>
+13 -11
View File
@@ -52,14 +52,15 @@ function FormatExamples() {
)
}
function Controlled() {
// `showValue={false}` hides the value readout on the representative page; the verification Stories keep it.
function Controlled({ showValue = true }: { showValue?: boolean }) {
const [date, setDate] = useState<Date | undefined>()
return (
<div className="grid gap-2">
<Field label="마감일 (controlled)" id="dp-controlled">
<DatePicker id="dp-controlled" controlled value={date} onValueChange={setDate} calendarProps={calendarProps} />
</Field>
<span className="text-sm text-muted-foreground" data-testid="controlled">{`value = ${date ? date.toLocaleDateString("ko-KR") : "undefined"}`}</span>
{showValue ? <span className="text-sm text-muted-foreground" data-testid="controlled">{`value = ${date ? date.toLocaleDateString("ko-KR") : "undefined"}`}</span> : null}
</div>
)
}
@@ -92,11 +93,10 @@ function Disabled() {
}
const sections = [
{ id: "empty", label: "Empty (placeholder)", node: <Empty /> },
{ id: "empty", label: "Empty", node: <Empty /> },
{ id: "selected", label: "Selected", node: <Selected /> },
{ id: "format", label: "Default format / custom override", node: <FormatExamples /> },
{ id: "controlled", label: "Controlled", node: <Controlled /> },
{ id: "medium", label: "Medium size", node: <Medium /> },
{ id: "format", label: "Format", node: <FormatExamples /> },
{ id: "controlled", label: "Controlled", node: <Controlled showValue={false} /> },
{ id: "disabled-dates", label: "Disabled dates", node: <DisabledDates /> },
{ id: "disabled", label: "Disabled", node: <Disabled /> },
]
@@ -115,12 +115,14 @@ export const All: Story = {
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader
title="Date Picker"
description="Popover + Calendar · 날짜 선택 시 닫힘"
/>
<StoryPageHeader title="Date Picker" />
<StorySection title="Overview">{board("empty")}</StorySection>
<StorySection title="Sizes" description="기본값: Large">{board("medium")}</StorySection>
{/* The field label already names the size, so the Medium specimen has no separate example title. */}
<StorySection title="Sizes">
<div className="max-w-sm">
<Medium />
</div>
</StorySection>
<StorySection title="States">{board("selected", "controlled", "disabled-dates", "disabled")}</StorySection>
<StorySection title="Usage">{board("format")}</StorySection>
</StoryPage>
+3 -3
View File
@@ -184,17 +184,17 @@ export const All: Story = {
<StoryPageHeader
title="Dialog"
/>
<StorySection title="Overview" description="내용 기준 크기">
<StorySection title="Overview">
<Basic />
</StorySection>
<StorySection title="Variants" description="긴 내용은 Body만 scroll · Header 70 / Footer 48px 제외">
<StorySection title="Variants">
<div className="flex flex-wrap gap-3">
<LongContent />
<WideContent />
<BodyScroll />
</div>
</StorySection>
<StorySection title="Usage" description="취소·실행은 Standard Solid 세트">
<StorySection title="Usage">
<div className="flex flex-wrap gap-3">
<DeleteConfirmation />
<FormContent />
+3 -6
View File
@@ -100,14 +100,11 @@ export const All: Story = {
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader
title="Drawer"
description="핸들·패널을 아래로 끌어 닫기"
/>
<StorySection title="Overview" description="데스크톱: 우측 가로 취소 → 실행 · 640px 이하: 세로 실행 위 / 취소 아래">
<StoryPageHeader title="Drawer" />
<StorySection title="Overview">
<Basic />
</StorySection>
<StorySection title="Usage" description="긴 내용은 Body만 scroll">
<StorySection title="Usage">
<div className="flex flex-wrap gap-3">
<FormCase />
<LongCase />
+1 -1
View File
@@ -87,7 +87,7 @@ export const All: Story = {
<StoryPageHeader
title="Dropdown Menu"
/>
<StorySection title="Overview" description="항목 · Disabled · 구분선 · Checkbox · Radio · 하위 메뉴 · 위험 항목">
<StorySection title="Overview">
<Full />
</StorySection>
</StoryPage>
+2 -5
View File
@@ -53,14 +53,11 @@ export const All: Story = {
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader
title="Hover Card"
description="링크 hover / focus 시 미리보기"
/>
<StoryPageHeader title="Hover Card" />
<StorySection title="Overview">
<Preview />
</StorySection>
<StorySection title="Usage" description="카드 폭 고정 · 높이만 늘어남">
<StorySection title="Usage">
<LongContent />
</StorySection>
</StoryPage>
+30 -30
View File
@@ -59,35 +59,41 @@ const sizes: { size: InputSize; name: string; height: number; padding: number; t
{ size: "large", name: "Large", height: 42, padding: 12, typography: "16 / 24 / 400", usage: "Component default" },
]
function SizeCard({ spec }: { spec: (typeof sizes)[number] }) {
function SizeCard({ spec, showSpec = true }: { spec: (typeof sizes)[number]; showSpec?: boolean }) {
return (
<section className="grid content-start gap-4" data-input-size={spec.size}>
<Specimen size={spec.size} label={spec.name} defaultValue={`${spec.name} ${spec.height}px`} />
<dl className="m-0 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm">
<dt className="text-muted-foreground">Height</dt>
<dd className="m-0">{spec.height}px</dd>
<dt className="text-muted-foreground">Padding</dt>
<dd className="m-0">0 / {spec.padding}px</dd>
<dt className="text-muted-foreground">Typography</dt>
<dd className="m-0">{spec.typography}</dd>
<dt className="text-muted-foreground">Usage</dt>
<dd className="m-0">{spec.usage}</dd>
</dl>
{/* Without the spec list the value would repeat the size name, so it shows sample text instead. */}
<Specimen size={spec.size} label={spec.name} defaultValue={showSpec ? `${spec.name} ${spec.height}px` : "Hanmac Project"} />
{showSpec ? (
<dl className="m-0 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm">
<dt className="text-muted-foreground">Height</dt>
<dd className="m-0">{spec.height}px</dd>
<dt className="text-muted-foreground">Padding</dt>
<dd className="m-0">0 / {spec.padding}px</dd>
<dt className="text-muted-foreground">Typography</dt>
<dd className="m-0">{spec.typography}</dd>
<dt className="text-muted-foreground">Usage</dt>
<dd className="m-0">{spec.usage}</dd>
</dl>
) : null}
</section>
)
}
function SizeComparison() {
// `showSpec={false}` keeps the representative page UI-only; the verification Story keeps the spec lists and the size note.
function SizeComparison({ showSpec = true }: { showSpec?: boolean }) {
return (
<div className="grid max-w-[1120px] gap-6">
<div className="grid gap-6 [grid-template-columns:repeat(auto-fit,minmax(240px,1fr))]">
{sizes.map((spec) => (
<SizeCard key={spec.size} spec={spec} />
<SizeCard key={spec.size} spec={spec} showSpec={showSpec} />
))}
</div>
<p className="text-sm text-muted-foreground">
높이는 입력 컨트롤의 외곽 높이입니다. 값·Placeholder 글자는 size를 따르고 Label 14/20/500은 공통입니다. 업무 폼·조회 조합은 Medium을 쓰며, size를 생략하면 Large입니다.
</p>
{showSpec ? (
<p className="text-sm text-muted-foreground">
높이는 입력 컨트롤의 외곽 높이입니다. 값·Placeholder 글자는 size를 따르고 Label 14/20/500은 공통입니다. 업무 폼·조회 조합은 Medium을 쓰며, size를 생략하면 Large입니다.
</p>
) : null}
</div>
)
}
@@ -118,33 +124,27 @@ const meta = {
export default meta
type Story = StoryObj<typeof meta>
// Representative Story: the common Story page (Overview → Sizes → States → Validation). Focus is placed by the play function.
// Representative Story: the common Story page (Sizes → States → Validation). Focus is placed by the play function.
// Named after the component, as in ui_viz.
export const All: Story = {
name: "Input",
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader
title="Input"
description="Label·Helper·Error 문구는 소비 조합"
/>
<StorySection title="Overview" description="기본값: Large 42px">
<StateBoard states={[{ id: "default", label: "Default · size 생략(Large)", children: <Specimen placeholder={placeholder} /> }]} />
<StoryPageHeader title="Input" />
<StorySection title="Sizes">
<SizeComparison showSpec={false} />
</StorySection>
<StorySection title="Sizes" description="28 / 34 / 42px · 업무 폼·조회는 Medium">
<SizeComparison />
</StorySection>
<StorySection title="States" description="Focus·Error 1.5px 테두리 · Hover 없음 · Disabled 60%">
<StorySection title="States">
<StateBoard
states={[
{ id: "focus", label: "Focus", children: <Specimen size="medium" placeholder={placeholder} /> },
{ id: "disabled", label: "Disabled", children: <Specimen disabled defaultValue="Unavailable project" /> },
{ id: "readonly", label: "Readonly", note: readonlyNote, noteAfter: true, children: <Specimen readOnly value="Confirmed project" /> },
{ id: "readonly", label: "Readonly", children: <Specimen readOnly value="Confirmed project" /> },
]}
/>
</StorySection>
<StorySection title="Validation" description="aria-invalid → Error 테두리 · 오류 문구는 소비 조합">
<StorySection title="Validation">
<StateBoard
states={[
{ id: "error", label: "Error", children: <Specimen size="medium" error defaultValue="Invalid value" /> },
+5 -12
View File
@@ -27,18 +27,13 @@ export const All: Story = {
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader
title="Label"
description="Field Label 14/20/500 · htmlFor 연결 시 클릭으로 focus 이동"
/>
<StoryPageHeader title="Label" />
<StorySection title="Overview">
<StateBoard
states={[
{
id: "default",
label: "Default · htmlFor",
noteAfter: true,
note: "라벨을 클릭하면 연결된 입력으로 focus가 이동합니다.",
label: "Default",
children: (
<div className="grid max-w-sm gap-2">
<Label htmlFor="label-default">Project name</Label>
@@ -54,9 +49,7 @@ export const All: Story = {
states={[
{
id: "disabled",
label: "Disabled (peer-disabled, shadcn 기본)",
noteAfter: true,
note: "Disabled 연동은 ui_viz Label에 정의가 없어 shadcn 기본(peer-disabled)을 유지합니다.",
label: "Disabled",
children: (
<div className="grid max-w-sm gap-2">
<Input id="label-disabled" size="medium" disabled defaultValue="Unavailable" className="peer order-2" />
@@ -69,7 +62,7 @@ export const All: Story = {
]}
/>
</StorySection>
<StorySection title="Usage" description="Required 별표는 소비 조합">
<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>
@@ -80,7 +73,7 @@ export const All: Story = {
states={[
{
id: "required",
label: "Required (소비 조합)",
label: "Required",
children: (
<div className="grid max-w-sm gap-2">
<Label htmlFor="label-required">
+2 -5
View File
@@ -96,11 +96,8 @@ export const All: Story = {
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader
title="Menubar"
description="열린 상태에서 좌우 방향키로 이웃 메뉴 이동"
/>
<StorySection title="Overview" description="단축키 · Disabled · 하위 메뉴 · 위험 항목 · Checkbox · Radio">
<StoryPageHeader title="Menubar" />
<StorySection title="Overview">
<div style={{ minHeight: 384 }}>
<Specimen />
</div>
+3 -6
View File
@@ -101,15 +101,12 @@ export const All: Story = {
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader
title="Navigation Menu"
description="링크 이동용 · 명령 실행에는 쓰지 않음"
/>
<StoryPageHeader title="Navigation Menu" />
<StorySection title="Overview">
<StateBoard
min={520}
states={[
{ id: "viewport", label: "Viewport (default)", note: "제품 / 자료는 content, 회사는 현재 위치(active), 문의는 일반 링크, 준비 중은 disabled trigger", children: frame(<Specimen />) },
{ id: "viewport", label: "Viewport", children: frame(<Specimen />) },
]}
/>
</StorySection>
@@ -117,7 +114,7 @@ export const All: Story = {
<StateBoard
min={520}
states={[
{ id: "no-viewport", label: "viewport={false}", note: "각 content가 자기 trigger 아래에 열립니다", children: frame(<Specimen viewport={false} />) },
{ id: "no-viewport", label: "No viewport", children: frame(<Specimen viewport={false} />) },
]}
/>
</StorySection>
+10 -12
View File
@@ -29,7 +29,8 @@ function pageRange(page: number, total: number): (number | "start" | "end")[] {
return out
}
function Specimen({ total, initial }: { total: number; initial: number }) {
// `showValue={false}` hides the value readout on the representative page; the verification Stories keep it.
function Specimen({ total, initial, showValue = true }: { total: number; initial: number; showValue?: boolean }) {
const [page, setPage] = useState(initial)
const go = (p: number) => (event: React.MouseEvent) => {
event.preventDefault()
@@ -60,7 +61,7 @@ function Specimen({ total, initial }: { total: number; initial: number }) {
</PaginationItem>
</PaginationContent>
</Pagination>
<p className="text-center text-sm text-muted-foreground" data-testid="page-state">{`현재 ${page} / ${total}`}</p>
{showValue ? <p className="text-center text-sm text-muted-foreground" data-testid="page-state">{`현재 ${page} / ${total}`}</p> : null}
</div>
)
}
@@ -68,10 +69,10 @@ function Specimen({ total, initial }: { total: number; initial: number }) {
const wrap = (node: ReactNode) => <div className="w-full max-w-xl">{node}</div>
const sections = [
{ id: "first", label: "First page", note: "Previous가 aria-disabled", node: <Specimen total={5} initial={1} /> },
{ id: "middle", label: "Middle page", node: <Specimen total={5} initial={3} /> },
{ id: "last", label: "Last page", note: "Next가 aria-disabled", node: <Specimen total={5} initial={5} /> },
{ id: "ellipsis", label: "Long range with ellipsis", note: "클릭하면 실제로 현재 페이지가 바뀝니다", node: <Specimen total={20} initial={10} /> },
{ id: "first", label: "First page", node: <Specimen total={5} initial={1} showValue={false} /> },
{ id: "middle", label: "Middle page", node: <Specimen total={5} initial={3} showValue={false} /> },
{ id: "last", label: "Last page", node: <Specimen total={5} initial={5} showValue={false} /> },
{ id: "ellipsis", label: "Long range", node: <Specimen total={20} initial={10} showValue={false} /> },
]
const meta = { title: "Components/Navigation/Pagination", component: Pagination, parameters: { layout: "padded" } } satisfies Meta<typeof Pagination>
@@ -79,7 +80,7 @@ export default meta
type Story = StoryObj<typeof meta>
const board = (...ids: string[]) => (
<StateBoard min={420} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, note: s.note, children: wrap(s.node) }))} />
<StateBoard min={420} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: wrap(s.node) }))} />
)
// Representative Story: the common Story page; the sections group the same specimens.
@@ -88,12 +89,9 @@ export const All: Story = {
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader
title="Pagination"
description="현재 페이지와 href는 소비 코드가 소유"
/>
<StoryPageHeader title="Pagination" />
<StorySection title="Overview">{board("middle")}</StorySection>
<StorySection title="States" description="처음·끝에서 이전·다음 aria-disabled">{board("first", "last")}</StorySection>
<StorySection title="States">{board("first", "last")}</StorySection>
<StorySection title="Usage">{board("ellipsis")}</StorySection>
</StoryPage>
),
+2 -5
View File
@@ -83,17 +83,14 @@ export const All: Story = {
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader
title="Popover"
description="비모달 · 바깥 클릭이나 Esc로 닫힘"
/>
<StoryPageHeader title="Popover" />
<StorySection title="Overview">
<Info />
</StorySection>
<StorySection title="Variants">
<Aligned />
</StorySection>
<StorySection title="Usage" description="취소·저장은 PopoverClose로 닫음">
<StorySection title="Usage">
<FormPanel />
</StorySection>
</StoryPage>
+7 -9
View File
@@ -7,12 +7,13 @@ import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-pag
// Progress follows `value` (0..max). The representative Story shows fixed values plus one interactive value; the same specimens
// also exist as Stories tagged `verification` (hidden from the sidebar). No status colors are defined, so there is one look.
function Interactive() {
// `showValue={false}` hides the value readout on the representative page; the verification Stories keep it.
function Interactive({ showValue = true }: { showValue?: boolean }) {
const [value, setValue] = useState(30)
return (
<div className="grid gap-3">
<Progress value={value} aria-label="진행률" />
<p className="text-sm text-muted-foreground" data-testid="value">{`value = ${value}`}</p>
{showValue ? <p className="text-sm text-muted-foreground" data-testid="value">{`value = ${value}`}</p> : null}
<div className="flex gap-2">
<Button variant="cancel" appearance="solid" size="sm" onClick={() => setValue((v) => Math.max(0, v - 25))}>
-25
@@ -40,8 +41,8 @@ const sections = [
{ id: "zero", label: "0%", node: <Progress value={0} aria-label="0%" /> },
{ id: "middle", label: "50%", node: <Progress value={50} aria-label="50%" /> },
{ id: "full", label: "100%", node: <Progress value={100} aria-label="100%" /> },
{ id: "custom-maximum", label: "max = 200", note: "0 / 50 / 200과 값 없음: ARIA 범위와 실제 채움 비율 확인", node: <CustomMaximum /> },
{ id: "interactive", label: "Interactive value", note: "버튼으로 value를 바꾸면 indicator 폭과 aria-valuenow가 함께 변합니다", node: <Interactive /> },
{ id: "custom-maximum", label: "max = 200", node: <CustomMaximum /> },
{ id: "interactive", label: "Interactive", node: <Interactive showValue={false} /> },
]
const meta = { title: "Components/Data & Display/Progress", component: Progress, parameters: { layout: "padded" } } satisfies Meta<typeof Progress>
@@ -49,7 +50,7 @@ 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, note: s.note, noteAfter: true, children: s.node }))} />
<StateBoard min={320} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} />
)
// Representative Story: the common Story page; the sections group the same specimens.
@@ -58,10 +59,7 @@ export const All: Story = {
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader
title="Progress"
description="value(0..max) 비율로 채움"
/>
<StoryPageHeader title="Progress" />
<StorySection title="Overview">{board("middle")}</StorySection>
<StorySection title="States">{board("zero", "full")}</StorySection>
<StorySection title="Usage">{board("custom-maximum", "interactive")}</StorySection>
+9 -9
View File
@@ -12,6 +12,7 @@ function Group({
disabledItem,
invalid,
orientation,
hoverItem,
}: {
prefix: string
defaultValue?: string
@@ -19,12 +20,14 @@ function Group({
disabledItem?: string
invalid?: boolean
orientation?: "horizontal" | "vertical"
// Pins :hover on one item with the Storybook-only data-sb-hover attribute (.storybook/pseudo-states.ts).
hoverItem?: string
}) {
return (
<RadioGroup defaultValue={defaultValue} disabled={disabled} orientation={orientation} aria-label={prefix}>
{["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} />
<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>
</div>
))}
@@ -53,12 +56,9 @@ export const All: Story = {
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader
title="Radio Group"
description="그룹 안에서 방향키로 이동·선택"
/>
<StoryPageHeader title="Radio Group" />
<StorySection title="Overview">
<StateBoard min={240} states={[{ id: "vertical", label: "Vertical (default) · Checked / Unchecked", children: <Group prefix="v" defaultValue="b" /> }]} />
<StateBoard min={240} states={[{ id: "vertical", label: "Vertical", children: <Group prefix="v" defaultValue="b" /> }]} />
</StorySection>
<StorySection title="Variants">
<StateBoard min={240} states={[{ id: "horizontal", label: "Horizontal", children: <Group prefix="h" orientation="horizontal" /> }]} />
@@ -67,14 +67,14 @@ export const All: Story = {
<StateBoard
min={240}
states={[
{ id: "hover", label: "Hover", noteAfter: true, note: "Hover한 항목의 테두리가 primary 색이 됩니다.", children: <Group prefix="hv" defaultValue="" /> },
{ id: "focus", label: "Focus", noteAfter: true, note: "그룹에 Tab으로 들어가면 선택된 항목에 focus ring이 표시됩니다.", children: <Group prefix="f" /> },
{ id: "hover", label: "Hover", children: <Group prefix="hv" defaultValue="" hoverItem="a" /> },
{ id: "focus", label: "Focus", children: <Group prefix="f" /> },
{ id: "disabled-item", label: "Disabled · 항목", children: <Group prefix="di" disabledItem="b" /> },
{ id: "disabled-group", label: "Disabled · 그룹", children: <Group prefix="dg" disabled /> },
]}
/>
</StorySection>
<StorySection title="Validation" description="aria-invalid → Error 테두리">
<StorySection title="Validation">
<StateBoard min={240} states={[{ id: "error", label: "Error", children: <Group prefix="e" invalid /> }]} />
</StorySection>
</StoryPage>
+3 -3
View File
@@ -88,10 +88,10 @@ function Collapsible() {
}
const sections = [
{ id: "horizontal", label: "Horizontal (list min 15%)", node: <Horizontal /> },
{ id: "horizontal", label: "Horizontal", node: <Horizontal /> },
{ id: "vertical", label: "Vertical with grip", node: <Vertical /> },
{ id: "handle", label: "Horizontal with grip", node: <WithHandle /> },
{ id: "minmax", label: "Min / max (25%–60%)", node: <MinMax /> },
{ id: "minmax", label: "Min / max", node: <MinMax /> },
{ id: "collapsible", label: "Collapsible panel", node: <Collapsible /> },
]
@@ -114,7 +114,7 @@ export const All: Story = {
/>
<StorySection title="Overview">{board("horizontal")}</StorySection>
<StorySection title="Variants">{board("vertical", "handle")}</StorySection>
<StorySection title="Usage" description="최소·최대 크기 · 접기">{board("minmax", "collapsible")}</StorySection>
<StorySection title="Usage">{board("minmax", "collapsible")}</StorySection>
</StoryPage>
),
}
+1 -4
View File
@@ -87,10 +87,7 @@ export const All: Story = {
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader
title="Scroll Area"
description="스크롤바는 hover·스크롤 시 표시"
/>
<StoryPageHeader title="Scroll Area" />
<StorySection title="Overview">{board("vertical")}</StorySection>
<StorySection title="Variants">{board("horizontal", "both")}</StorySection>
<StorySection title="Usage">{board("table-like")}</StorySection>
+31 -46
View File
@@ -71,35 +71,40 @@ const sizes = [
{ size: "large" as const, name: "Large", outer: "44px / 42px", typography: "16 / 24 / 400", option: "42px minimum · 16 / 24 / 400", usage: "Component default" },
]
function SizeCard({ spec }: { spec: (typeof sizes)[number] }) {
function SizeCard({ spec, showSpec = true }: { spec: (typeof sizes)[number]; showSpec?: boolean }) {
return (
<section className="grid content-start gap-4" data-select-size={spec.size}>
<SelectSpecimen size={spec.size} label={spec.name} placeholder="Choose a status" />
<dl className="m-0 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm">
<dt className="text-muted-foreground">Outer Height / Trigger</dt>
<dd className="m-0">{spec.outer}</dd>
<dt className="text-muted-foreground">Trigger Typography</dt>
<dd className="m-0">{spec.typography}</dd>
<dt className="text-muted-foreground">Option</dt>
<dd className="m-0">{spec.option}</dd>
<dt className="text-muted-foreground">Usage</dt>
<dd className="m-0">{spec.usage}</dd>
</dl>
{showSpec ? (
<dl className="m-0 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm">
<dt className="text-muted-foreground">Outer Height / Trigger</dt>
<dd className="m-0">{spec.outer}</dd>
<dt className="text-muted-foreground">Trigger Typography</dt>
<dd className="m-0">{spec.typography}</dd>
<dt className="text-muted-foreground">Option</dt>
<dd className="m-0">{spec.option}</dd>
<dt className="text-muted-foreground">Usage</dt>
<dd className="m-0">{spec.usage}</dd>
</dl>
) : null}
</section>
)
}
function SizeComparison() {
// `showSpec={false}` keeps the representative page UI-only; the verification Story keeps the spec lists and the size note.
function SizeComparison({ showSpec = true }: { showSpec?: boolean }) {
return (
<div className="grid max-w-[1120px] gap-6">
<div className="grid gap-6 [grid-template-columns:repeat(auto-fit,minmax(240px,1fr))]">
{sizes.map((spec) => (
<SizeCard key={spec.size} spec={spec} />
<SizeCard key={spec.size} spec={spec} showSpec={showSpec} />
))}
</div>
<p className="text-sm text-muted-foreground">
Select는 Input과 독립된 두 크기(Medium 34 / Large 44)입니다. 각 Trigger를 클릭해 연결된 목록과 선택 상태를 확인하세요. 목록의 Option은 Trigger의 size를 그대로 따르며(최소 높이 42px, Medium 14/20/400, Large 16/24/400) 폭은 항상 Trigger와 같습니다.
</p>
{showSpec ? (
<p className="text-sm text-muted-foreground">
Select는 Input과 독립된 두 크기(Medium 34 / Large 44)입니다. 각 Trigger를 클릭해 연결된 목록과 선택 상태를 확인하세요. 목록의 Option은 Trigger의 size를 그대로 따르며(최소 높이 42px, Medium 14/20/400, Large 16/24/400) 폭은 항상 Trigger와 같습니다.
</p>
) : null}
</div>
)
}
@@ -144,52 +149,32 @@ export default meta
type Story = StoryObj<typeof meta>
// Representative Story: every state on one page. Named after the component, as in ui_viz.
// The open list is a Radix popover: press Escape or click outside to close it, click the trigger to reopen.
// It starts at Sizes; each trigger opens the real list (the open-on-load list is the hidden `Open` Story).
export const All: Story = {
name: "Select",
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader
title="Select"
description="Option은 Trigger size를 따르고 목록 폭 = Trigger 폭"
/>
{/* Open stays in the first section: the list opens on load, so its trigger must be on the first screen. */}
<StorySection title="Overview" description="기본값: Large 44px · 열린 목록">
<StateBoard
fixed
states={[
{ id: "default", label: "Default · Large (size 생략)", children: <SelectSpecimen /> },
{
id: "open",
label: "Open",
children: (
<div className="min-h-64">
<SelectSpecimen size="medium" defaultOpen />
</div>
),
},
]}
/>
</StorySection>
<StorySection title="Sizes" description="34 / 44px · Input과 별도 크기">
<SizeComparison />
<StoryPageHeader title="Select" />
<StorySection title="Sizes">
<SizeComparison showSpec={false} />
</StorySection>
<StorySection title="States">
<StateBoard
fixed
states={[
{ id: "selected", label: "Selected", children: <SelectSpecimen size="medium" defaultValue="active" /> },
{ id: "disabled", label: "Disabled", children: <DisabledStates /> },
{ 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 /> },
{ id: "disabled-placeholder", label: "Disabled · Placeholder", children: <SelectSpecimen size="medium" label="Status" disabled /> },
]}
/>
</StorySection>
<StorySection title="Invalid / Focus" description="aria-invalid → Error 테두리">
<StorySection title="Invalid / Focus">
<StateBoard
fixed
states={[
{ id: "invalid", label: "Invalid", children: <SelectSpecimen size="medium" aria-invalid label="Invalid Select" defaultValue="active" /> },
{ id: "invalid-disabled", label: "Invalid · Disabled", children: <SelectSpecimen size="medium" aria-invalid disabled label="Disabled Invalid Select" /> },
{ 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" /> },
]}
/>
</StorySection>
+3 -8
View File
@@ -25,10 +25,7 @@ export const All: Story = {
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader
title="Separator"
description="구분선은 묶음 뒤 한 곳 · 기본 decorative"
/>
<StoryPageHeader title="Separator" />
<StorySection title="Overview">
<StateBoard
min={280}
@@ -53,7 +50,7 @@ export const All: Story = {
states={[
{
id: "vertical",
label: "Vertical (flex row)",
label: "Vertical",
children: (
<div className="flex h-6 items-center gap-3 text-sm">
<span>Left</span>
@@ -66,9 +63,7 @@ export const All: Story = {
},
{
id: "semantic",
label: "Semantic (decorative=false)",
noteAfter: true,
note: "decorative=false는 role=separator와 aria-orientation을 노출합니다.",
label: "Semantic",
children: (
<div className="grid gap-3 text-sm">
<span>Section A</span>
+2 -2
View File
@@ -101,7 +101,7 @@ export const All: Story = {
<StoryPageHeader
title="Sheet"
/>
<StorySection title="Overview" description="기본값: right">
<StorySection title="Overview">
<SideCase side="right" />
</StorySection>
<StorySection title="Variants">
@@ -111,7 +111,7 @@ export const All: Story = {
<SideCase side="bottom" />
</div>
</StorySection>
<StorySection title="Usage" description="긴 내용은 Body만 scroll">
<StorySection title="Usage">
<div className="flex flex-wrap gap-3">
<FormCase />
<LongCase />
+1 -4
View File
@@ -63,10 +63,7 @@ export const All: Story = {
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader
title="Skeleton"
description="크기·모양은 className으로 지정"
/>
<StoryPageHeader title="Skeleton" />
<StorySection title="Overview">{board("text")}</StorySection>
<StorySection title="Usage">{board("avatar", "card")}</StorySection>
</StoryPage>
+2 -5
View File
@@ -61,7 +61,7 @@ function Vertical() {
const sections = [
{ id: "single", label: "Single", node: <Single /> },
{ id: "range", label: "Range (two thumbs)", node: <Range /> },
{ id: "range", label: "Range", node: <Range /> },
{ id: "step", label: "Step", node: <Step /> },
{ id: "disabled", label: "Disabled", node: <Disabled /> },
{ id: "vertical", label: "Vertical", node: <Vertical /> },
@@ -81,10 +81,7 @@ export const All: Story = {
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader
title="Slider"
description="끌기 또는 화살표 키로 값 변경"
/>
<StoryPageHeader title="Slider" />
<StorySection title="Overview">{board("single")}</StorySection>
<StorySection title="Variants">{board("range", "step", "vertical")}</StorySection>
<StorySection title="States">{board("disabled")}</StorySection>
+1 -8
View File
@@ -27,8 +27,6 @@ function Board({ ids }: { ids: string[] }) {
{
id: "types",
label: "Types",
noteAfter: true,
note: "Default / Success / Error / Warning / Info / Loading. Only Error has its own color; the other types are neutral (status colors are undecided).",
children: (
<div className="flex flex-wrap gap-2">
<Trigger onClick={() => toast("알림이 도착했습니다", { description: "기본 알림입니다." })}>Default</Trigger>
@@ -67,8 +65,6 @@ function Board({ ids }: { ids: string[] }) {
{
id: "promise",
label: "Promise",
noteAfter: true,
note: "Loading, then Success or Error (local timeout, no network).",
children: (
<div className="flex flex-wrap gap-2">
<Trigger onClick={() => toast.promise(wait(1500), { loading: "저장 중...", success: "저장했습니다", error: "저장하지 못했습니다" })}>
@@ -124,10 +120,7 @@ export const All: Story = {
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader
title="Sonner"
description="Toaster는 앱 루트에 한 번"
/>
<StoryPageHeader title="Sonner" />
<StorySection title="Variants">
<Board ids={["types"]} />
</StorySection>
+13 -12
View File
@@ -1,4 +1,4 @@
import { useId, useState, type ReactNode } from "react"
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"
@@ -14,11 +14,6 @@ function Option({ label, children }: { label: string; children: (id: string) =>
)
}
function Controlled() {
const [on, setOn] = useState(false)
return <Option label={`Notifications (${on ? "on" : "off"})`}>{(id) => <Switch id={id} checked={on} onCheckedChange={setOn} />}</Option>
}
const meta = {
title: "Components/Form/Switch",
component: Switch,
@@ -40,17 +35,13 @@ export const All: Story = {
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader
title="Switch"
description="트랙 36×20 · 썸 16 · On은 state-active 색"
/>
<StoryPageHeader title="Switch" />
<StorySection title="Overview">
<StateBoard
min={240}
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> },
{ id: "interactive", label: "Controlled · click toggles", children: <Controlled /> },
]}
/>
</StorySection>
@@ -58,7 +49,17 @@ export const All: Story = {
<StateBoard
min={240}
states={[
{ id: "hover", label: "Hover", noteAfter: true, note: "Black 20% overlay. 포인터를 올려 확인합니다.", children: <Option label="Option">{(id) => <Switch id={id} />}</Option> },
{
id: "hover",
label: "Hover",
// data-sb-hover pins :hover (Storybook only, .storybook/pseudo-states.ts).
children: (
<div className="grid gap-3">
<Option label="Off">{(id) => <Switch id={id} data-sb-hover="" />}</Option>
<Option label="On">{(id) => <Switch id={id} defaultChecked data-sb-hover="" />}</Option>
</div>
),
},
{ id: "focus", label: "Focus", children: <Option label="Option">{(id) => <Switch id={id} />}</Option> },
{
id: "disabled",
+10 -11
View File
@@ -15,9 +15,11 @@ const projects = [
]
const won = (n: number) => n.toLocaleString("ko-KR")
function BasicTable() {
// `showCaption={false}` drops the visible caption on the representative page; the caption text becomes the table's
// accessible name. The verification Story keeps the TableCaption.
function BasicTable({ showCaption = true }: { showCaption?: boolean }) {
return (
<Table>
<Table aria-label={showCaption ? undefined : "프로젝트별 계약 금액(원)"}>
<TableHeader>
<TableRow>
<TableHead>번호</TableHead>
@@ -44,7 +46,7 @@ function BasicTable() {
<TableCell className="text-right">{won(projects.reduce((s, p) => s + p.amount, 0))}</TableCell>
</TableRow>
</TableFooter>
<TableCaption>프로젝트별 계약 금액(원)</TableCaption>
{showCaption ? <TableCaption>프로젝트별 계약 금액(원)</TableCaption> : null}
</Table>
)
}
@@ -132,10 +134,10 @@ function WideTable() {
const box = (node: ReactNode) => <div style={{ width: "100%", maxWidth: 720, minWidth: 0 }}>{node}</div>
const sections = [
{ id: "basic", label: "Basic + Badge + numeric + footer + caption", node: <BasicTable /> },
{ id: "selected", label: "Selected row", note: "Checkbox와 data-state=\"selected\"는 Story가 조합합니다(Table에는 선택 로직이 없음)", node: <SelectableTable /> },
{ id: "basic", label: "Basic", node: <BasicTable showCaption={false} /> },
{ id: "selected", label: "Selected row", node: <SelectableTable /> },
{ id: "long", label: "Long text", node: <LongTextTable /> },
{ id: "wide", label: "Horizontal overflow", note: "container가 가로로 scroll 하고 페이지는 밀리지 않습니다", node: <WideTable /> },
{ id: "wide", label: "Horizontal overflow", node: <WideTable /> },
]
const meta = { title: "Components/Data & Display/Table", component: Table, parameters: { layout: "padded" } } satisfies Meta<typeof Table>
@@ -143,7 +145,7 @@ export default meta
type Story = StoryObj<typeof meta>
const board = (...ids: string[]) => (
<StateBoard min={460} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, note: s.note, wide: true, children: box(s.node) }))} />
<StateBoard min={460} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, wide: true, children: box(s.node) }))} />
)
// Representative Story: the common Story page; the sections group the same specimens.
@@ -152,10 +154,7 @@ export const All: Story = {
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader
title="Table"
description="정적 표시 · 정렬·선택·페이지는 Data Table Pattern"
/>
<StoryPageHeader title="Table" />
<StorySection title="Overview">{board("basic")}</StorySection>
<StorySection title="States">{board("selected")}</StorySection>
<StorySection title="Usage">{board("long", "wide")}</StorySection>
+22 -22
View File
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"
import { useId, useState, type ComponentProps } from "react"
import {
Card, CardContent, CardHeader, CardTitle, Input, Label, Textarea,
Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow,
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
Tabs, TabsContent, TabsList, TabsTrigger,
} from "@hm-design/ui"
import { StateBoard } from "@/components/story-board"
@@ -86,8 +86,7 @@ function SurfaceUsage({ inCard = false }: { inCard?: boolean }) {
</div>
</TabsContent>
<TabsContent value="schedule" className="px-0 py-6">
<Table>
<TableCaption>프로젝트 산출물 일정 예시</TableCaption>
<Table aria-label="프로젝트 산출물 일정">
<TableHeader><TableRow><TableHead scope="col">산출물</TableHead><TableHead scope="col">마감일</TableHead></TableRow></TableHeader>
<TableBody><TableRow><TableCell>화면 설계서</TableCell><TableCell>10월 14일</TableCell></TableRow></TableBody>
</Table>
@@ -133,7 +132,7 @@ const meta = {
docs: {
description: {
component:
"기본 TabsList는 Primary(fill=true, panelSurface=\"content\")입니다. 대표 Story는 기본 API와 명시적 Primary, Secondary(fill + panelSurface=\"surface\"), Primary Overflow를 비교합니다. 모든 Fill 예제는 중첩 박스 없는 TabsContent(px-0 py-6)를 사용하며 Usage는 Card 내부 surface와 페이지 content 위의 조합입니다. panelSurface는 List의 선택 면만 바꾸므로 실제 상위 배경에 맞춰 선택합니다. Primary 선택 탭의 gradient는 상단 White alpha 0.8 → content이며 전체 opacity는 1입니다. Secondary는 기존 불투명 White를 유지합니다. Legacy non-fill은 fill={false}로 유지하며 숨김 Compatibility에서 확인합니다. 높이 34, 좌우 16, 상단 radius 4, 16/24/700과 8px 곡선 연결은 기존 값 그대로입니다. Hover는 비선택에만 Black 20%이며 넘칠 때만 이전/다음 이동 버튼이 나타납니다. aria-label과 연결된 입력 Label을 제공하고, 방향키·Home/End·포커스·Disabled·모바일 내부 스크롤을 확인합니다. 곡선 반경 8px과 비선택 합성색의 과거 REVIEW / TEMPORARY 이력은 유지합니다.",
"기본 TabsList는 Primary(fill=true, panelSurface=\"content\")입니다. 대표 Story는 기본 Primary, Secondary(fill + panelSurface=\"surface\"), Primary Overflow를 비교하며 명시적 Primary는 숨김 Default API에서 확인합니다. 모든 Fill 예제는 중첩 박스 없는 TabsContent(px-0 py-6)를 사용하며 Usage는 Card 내부 surface와 페이지 content 위의 조합입니다. panelSurface는 List의 선택 면만 바꾸므로 실제 상위 배경에 맞춰 선택합니다. Primary 선택 탭의 gradient는 상단 White alpha 0.8 → content이며 전체 opacity는 1입니다. Secondary는 기존 불투명 White를 유지합니다. Legacy non-fill은 fill={false}로 유지하며 숨김 Compatibility에서 확인합니다. 높이 34, 좌우 16, 상단 radius 4, 16/24/700과 8px 곡선 연결은 기존 값 그대로입니다. Hover는 비선택에만 Black 20%이며 넘칠 때만 이전/다음 이동 버튼이 나타납니다. aria-label과 연결된 입력 Label을 제공하고, 방향키·Home/End·포커스·Disabled·모바일 내부 스크롤을 확인합니다. 곡선 반경 8px과 비선택 합성색의 과거 REVIEW / TEMPORARY 이력은 유지합니다.",
},
},
},
@@ -146,49 +145,50 @@ export const All: Story = {
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader
title="Tabs"
description="선택 탭은 콘텐츠 면과 이어짐 · 넘칠 때만 이전/다음 버튼"
/>
<StoryPageHeader title="Tabs" />
<StorySection title="Overview">
<StateBoard
states={[
{ id: "primary", label: "Primary · 기본 TabsList", noteAfter: true, note: "fill / panelSurface 생략 → Primary · 비선택 Hover Black 20%", children: <Specimen /> },
]}
/>
</StorySection>
<StorySection title="Default API">
<StateBoard
states={[
{ id: "primary-fill", label: "Primary · TabsList fill", noteAfter: true, note: "panelSurface 생략 → content. 기본 TabsList와 같은 Primary입니다.", children: <Specimen listProps={{ fill: true }} /> },
{ id: "primary-explicit", label: "Primary · fill + panelSurface content", noteAfter: true, note: "두 Props를 명시해도 기본 API와 같은 Primary입니다.", children: <Specimen listProps={{ fill: true, panelSurface: "content" }} /> },
{ id: "primary", label: "Primary", children: <Specimen /> },
]}
/>
</StorySection>
<StorySection title="Variants">
<StateBoard
states={[
{ id: "secondary", label: "Secondary · Fill + panelSurface surface", noteAfter: true, note: "서브형 · Surface(White) 위에서 사용", children: <Specimen variant="secondary" /> },
{ id: "secondary", label: "Secondary", children: <Specimen variant="secondary" /> },
]}
/>
</StorySection>
<StorySection title="States">
<StateBoard
states={[
{ id: "overflow", wide: true, label: "Overflow · Primary + 이동 버튼", noteAfter: true, note: "20개 탭 · 이전/다음은 선택을 바꾸지 않고 목록만 이동", children: <div className="w-full min-w-0 max-w-4xl"><Specimen count={20} /></div> },
{ id: "overflow", wide: true, label: "Overflow", children: <div className="w-full min-w-0 max-w-4xl"><Specimen count={20} /></div> },
]}
/>
</StorySection>
<StorySection title="Usage" description="panelSurface를 상위 표면에 맞춤 · px-0 py-6은 예시이며 공통 규칙 아님">
<StorySection title="Usage">
<StateBoard states={[
{ id: "usage-card", label: "Card 내부 · Secondary", noteAfter: true, note: "바깥 Card 유지 · 내부 패널 transparent · border 0 · padding 24px 0", children: <SurfaceUsage inCard /> },
{ id: "usage-page", label: "페이지 직접 배치 · Primary", noteAfter: true, note: "Layout content 배경 사용 · 추가 흰색 박스 없음", children: <SurfaceUsage /> },
{ id: "usage-card", label: "Card 내부 · Secondary", children: <SurfaceUsage inCard /> },
{ id: "usage-page", label: "페이지 직접 배치 · Primary", children: <SurfaceUsage /> },
]} />
</StorySection>
</StoryPage>
),
}
// Verification: `fill`만, `fill` + `panelSurface="content"`를 명시해도 기본 Primary와 같다(대표 페이지의 이전 Default API 구역).
export const DefaultApi: Story = {
tags: ["verification"],
name: "Default API",
render: () => (
<div className="grid max-w-md gap-6">
<Specimen listProps={{ fill: true }} />
<Specimen listProps={{ fill: true, panelSurface: "content" }} />
</div>
),
}
// Verification: Primary 기준 상태와 Secondary 비교. Secondary도 같은 CSS 경로를 쓴다.
export const Secondary: Story = { tags: ["verification"], render: () => <div className="max-w-md"><Specimen variant="secondary" /></div> }
export const SelectedFirst: Story = { tags: ["verification"], name: "Selected First", render: () => <div className="max-w-md"><Specimen defaultValue="menu1" /></div> }
+4 -9
View File
@@ -53,7 +53,6 @@ const narrow = (children: ReactNode) => <div className="max-w-sm">{children}</di
const placeholder = "Enter a description"
const multiline = "First line\nSecond line keeps its line break.\nThird line."
const hoverNote = "Hover는 Default와 같습니다(사용자 결정 TEXTAREA-05). 포인터를 올려도 면, 테두리, 글자색이 바뀌지 않습니다."
const readonlyNote =
"Readonly는 Default의 면, 테두리, typography를 유지하고 흐리게 표시하지 않습니다. 값 변경은 막고 선택·복사는 허용하며 resize 표시가 없습니다."
@@ -86,24 +85,20 @@ export const All: Story = {
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader
title="Textarea"
description="최소 높이 112px · resize vertical · size 없음 · Label·Helper·Error는 소비 조합"
/>
<StoryPageHeader title="Textarea" />
<StorySection title="Overview">
<StateBoard states={[{ id: "default", label: "Default", children: <Specimen placeholder={placeholder} /> }]} />
</StorySection>
<StorySection title="States" description="Focus 1.5px 테두리(shadow 없음) · Hover = Default">
<StorySection title="States">
<StateBoard
states={[
{ id: "hover", label: "Hover (= Default)", note: hoverNote, noteAfter: true, children: <Specimen defaultValue="Hover over this field" /> },
{ id: "focus", label: "Focus", children: <Specimen placeholder={placeholder} /> },
{ id: "readonly", label: "Readonly", note: readonlyNote, noteAfter: true, children: <Specimen readOnly defaultValue="Confirmed description" /> },
{ id: "readonly", label: "Readonly", children: <Specimen readOnly defaultValue="Confirmed description" /> },
{ id: "disabled", label: "Disabled", children: <Specimen disabled defaultValue="Unavailable description" /> },
]}
/>
</StorySection>
<StorySection title="Validation" description="aria-invalid → Error 테두리 · 오류 문구는 소비 조합">
<StorySection title="Validation">
<StateBoard
states={[
{ id: "error", label: "Error", children: <Specimen error defaultValue="Invalid value" /> },
+18 -26
View File
@@ -1,15 +1,15 @@
import { useState } from "react"
import { useState, type ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite"
import { AlignCenterIcon, AlignLeftIcon, AlignRightIcon, BoldIcon, ItalicIcon, UnderlineIcon } from "lucide-react"
import { ToggleGroup, ToggleGroupItem } from "@hm-design/ui"
import { StateBoard } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// Toggle Group is a group of pressed-state items (toolbar-style), not a content switcher (that is Tabs). The representative Story
// shows single / multiple / icon-only / disabled / vertical / spaced groups with real controls; the same specimens also exist as
// Stories tagged `verification` (hidden from the sidebar). Icon-only items carry an `aria-label`.
function Single() {
// `showValue={false}` hides the selected-value readout on the representative page; the verification Stories keep it.
function Single({ showValue = true }: { showValue?: boolean }) {
const [value, setValue] = useState("left")
return (
<div className="grid gap-2">
@@ -18,12 +18,12 @@ function Single() {
<ToggleGroupItem value="center">가운데</ToggleGroupItem>
<ToggleGroupItem value="right">오른쪽</ToggleGroupItem>
</ToggleGroup>
<span className="text-sm text-muted-foreground" data-testid="single">{`value = ${value}`}</span>
{showValue ? <span className="text-sm text-muted-foreground" data-testid="single">{`value = ${value}`}</span> : null}
</div>
)
}
function Multiple() {
function Multiple({ showValue = true }: { showValue?: boolean }) {
const [value, setValue] = useState<string[]>(["bold"])
return (
<div className="grid gap-2">
@@ -38,7 +38,7 @@ function Multiple() {
<UnderlineIcon />
</ToggleGroupItem>
</ToggleGroup>
<span className="text-sm text-muted-foreground" data-testid="multiple">{`value = [${value.join(", ")}]`}</span>
{showValue ? <span className="text-sm text-muted-foreground" data-testid="multiple">{`value = [${value.join(", ")}]`}</span> : null}
</div>
)
}
@@ -111,23 +111,18 @@ function Sizes() {
)
}
const sections = [
{ id: "single", label: "Single selection", node: <Single /> },
{ id: "multiple", label: "Multiple selection (icon-only)", node: <Multiple /> },
{ id: "sizes", label: "Sizes · Icon-only / Icon + Text / Text-only", node: <Sizes /> },
{ id: "disabled", label: "Disabled group / item", node: <Disabled /> },
{ id: "vertical", label: "Vertical", node: <Vertical /> },
{ id: "spaced", label: "spacing={2} (separated)", node: <Spaced /> },
{ id: "many", label: "Many items (spacing + flex-wrap, consumer opts in)", node: <Many /> },
]
// No `component`: ToggleGroup props are a union (single | multiple), so Stories without args would not type-check.
const meta = { title: "Components/Form/Toggle Group", parameters: { layout: "padded" } } satisfies Meta
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 }))} />
// 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>
)
// Representative Story: the common Story page; the sections group the same specimens.
@@ -136,17 +131,14 @@ export const All: Story = {
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader
title="Toggle Group"
description="툴바형 눌림 항목 묶음 · 내용 전환은 Tabs 사용"
/>
<StorySection title="Overview">{board("single")}</StorySection>
<StoryPageHeader title="Toggle Group" />
<StorySection title="Overview">{examples(<Single showValue={false} />)}</StorySection>
<StorySection title="Sizes">
<Sizes />
</StorySection>
<StorySection title="Variants">{board("multiple", "vertical", "spaced")}</StorySection>
<StorySection title="States">{board("disabled")}</StorySection>
<StorySection title="Usage">{board("many")}</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>
</StoryPage>
),
}
+11 -39
View File
@@ -1,4 +1,3 @@
import { useState } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite"
import { BoldIcon, ItalicIcon, StarIcon } from "lucide-react"
import { Toggle, ToggleGroup, ToggleGroupItem } from "@hm-design/ui"
@@ -63,34 +62,9 @@ function Disabled() {
)
}
function Controlled() {
const [pressed, setPressed] = useState(false)
return (
<div className="flex items-center gap-3">
<Toggle variant="outline" pressed={pressed} onPressedChange={setPressed}>
알림
</Toggle>
<span className="text-sm text-muted-foreground" data-testid="controlled">{`pressed = ${String(pressed)}`}</span>
</div>
)
}
// className extension: the consumer's width / rounded utilities win over the default look.
function Extended() {
return (
<Toggle variant="outline" className="w-40 rounded-full">
w-40 rounded-full
</Toggle>
)
}
const sections = [
{ id: "offon", label: "Off / On", node: <OffOn /> },
{ id: "icon", label: "Icon-only and icon + text", node: <WithIcon /> },
{ id: "sizes", label: "Sizes", node: <Sizes /> },
{ id: "disabled", label: "Disabled", node: <Disabled /> },
{ id: "controlled", label: "Controlled", node: <Controlled /> },
{ id: "extended", label: "className extension", node: <Extended /> },
]
const meta = { title: "Components/Form/Toggle", component: Toggle, parameters: { layout: "padded" } } satisfies Meta<typeof Toggle>
@@ -107,24 +81,22 @@ export const All: Story = {
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader
title="Toggle"
description="Toggle Group과 같은 눌림(On) 상태 기준"
/>
<StoryPageHeader title="Toggle" />
<StorySection title="Overview">{board("offon")}</StorySection>
<StorySection title="단독 · 연결 그룹" description="단독 = 독립 선택 · spacing=0 그룹 = 연결 형태">
<div className="grid gap-4">
<div><p className="mb-2">단독 · 독립 선택</p><Toggle variant="outline">알림</Toggle></div>
<div><p className="mb-2">연결 그룹 · 단일 선택</p><ToggleGroup type="single" variant="outline" defaultValue="left" aria-label="정렬"><ToggleGroupItem value="left">왼쪽</ToggleGroupItem><ToggleGroupItem value="center">가운데</ToggleGroupItem><ToggleGroupItem value="right">오른쪽</ToggleGroupItem></ToggleGroup></div>
<div><p className="mb-2">연결 그룹 · 다중 선택</p><ToggleGroup type="multiple" variant="outline" defaultValue={["bold"]} aria-label="글자 스타일"><ToggleGroupItem value="bold">굵게</ToggleGroupItem><ToggleGroupItem value="italic">기울임</ToggleGroupItem></ToggleGroup></div>
</div>
<StorySection title="단독 · 연결 그룹">
<StateBoard
min={360}
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> },
{ id: "group-multiple", label: "연결 그룹 · 다중 선택", children: <ToggleGroup type="multiple" variant="outline" defaultValue={["bold"]} aria-label="글자 스타일"><ToggleGroupItem value="bold">굵게</ToggleGroupItem><ToggleGroupItem value="italic">기울임</ToggleGroupItem></ToggleGroup> },
]}
/>
</StorySection>
<StorySection title="Sizes">
<Sizes />
</StorySection>
<StorySection title="Variants">{board("icon")}</StorySection>
<StorySection title="States">{board("disabled", "controlled")}</StorySection>
<StorySection title="Usage">{board("extended")}</StorySection>
<StorySection title="States">{board("disabled")}</StorySection>
</StoryPage>
),
}
+2 -5
View File
@@ -76,17 +76,14 @@ export const All: Story = {
render: () => (
<TooltipProvider>
<StoryPage>
<StoryPageHeader
title="Tooltip"
description="상호작용 없는 짧은 설명 · hover 또는 focus로 열림"
/>
<StoryPageHeader title="Tooltip" />
<StorySection title="Overview">
<Basic />
</StorySection>
<StorySection title="Variants">
<Placements />
</StorySection>
<StorySection title="Usage" description="Disabled 버튼은 감싼 요소를 Trigger로 사용">
<StorySection title="Usage">
<div className="flex flex-wrap gap-3">
<LongText />
<Disabled />