Initial commit: HMDesignSystem baseline
This commit is contained in:
commit
b6a0a61103
486 files changed
+68432
No files matched your search
@@ -0,0 +1,147 @@
|
||||
import type { ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// The representative "Accordion" Story puts each Radix mode on one page with real, clickable items. The same sections also exist
|
||||
// as Stories tagged `verification`, which the sidebar hides by default (see .storybook/main.ts). Modes use the Radix API
|
||||
// (type, collapsible, defaultValue, disabled); there are no HM variant props.
|
||||
|
||||
const longText = "긴 텍스트는 줄바꿈으로 전체를 표시합니다. 공백 없는 긴 문자열도 너비를 밀어내지 않고 줄바꿈합니다. ".repeat(6)
|
||||
|
||||
const narrow = (children: ReactNode) => <div className="w-full max-w-md">{children}</div>
|
||||
|
||||
function SingleSpecimen() {
|
||||
return narrow(
|
||||
<Accordion type="single" defaultValue="a">
|
||||
<AccordionItem value="a">
|
||||
<AccordionTrigger>배송은 얼마나 걸리나요?</AccordionTrigger>
|
||||
<AccordionContent>주문 후 2~3일 안에 도착합니다.</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="b">
|
||||
<AccordionTrigger>반품은 어떻게 하나요?</AccordionTrigger>
|
||||
<AccordionContent>수령 후 7일 이내에 마이페이지에서 신청합니다.</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="c">
|
||||
<AccordionTrigger>결제 수단은 무엇이 있나요?</AccordionTrigger>
|
||||
<AccordionContent>카드와 계좌이체를 지원합니다.</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleSingleSpecimen() {
|
||||
return narrow(
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="a">
|
||||
<AccordionTrigger>열린 항목을 다시 눌러 닫을 수 있습니다</AccordionTrigger>
|
||||
<AccordionContent>collapsible 속성이 있어 모두 닫힌 상태가 가능합니다.</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="b">
|
||||
<AccordionTrigger>다른 항목</AccordionTrigger>
|
||||
<AccordionContent>한 번에 하나만 열립니다.</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
)
|
||||
}
|
||||
|
||||
function MultipleSpecimen() {
|
||||
return narrow(
|
||||
<Accordion type="multiple" defaultValue={["a", "b"]}>
|
||||
<AccordionItem value="a">
|
||||
<AccordionTrigger>기본 정보</AccordionTrigger>
|
||||
<AccordionContent>여러 항목을 동시에 열 수 있습니다.</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="b">
|
||||
<AccordionTrigger>상세 정보</AccordionTrigger>
|
||||
<AccordionContent>처음부터 열린 항목입니다.</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="c">
|
||||
<AccordionTrigger>기타</AccordionTrigger>
|
||||
<AccordionContent>접힌 항목입니다.</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
)
|
||||
}
|
||||
|
||||
function DisabledSpecimen() {
|
||||
return narrow(
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="a">
|
||||
<AccordionTrigger>사용 가능한 항목</AccordionTrigger>
|
||||
<AccordionContent>키보드 이동은 비활성 항목을 건너뜁니다.</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="b" disabled>
|
||||
<AccordionTrigger>비활성 항목</AccordionTrigger>
|
||||
<AccordionContent>열 수 없습니다.</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="c">
|
||||
<AccordionTrigger>다음 항목</AccordionTrigger>
|
||||
<AccordionContent>비활성 항목 다음입니다.</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
)
|
||||
}
|
||||
|
||||
function LongSpecimen() {
|
||||
return narrow(
|
||||
<Accordion type="single" defaultValue="a">
|
||||
<AccordionItem value="a">
|
||||
<AccordionTrigger>{`여러 줄로 이어지는 긴 Trigger 문구입니다. ${longText.slice(0, 60)}`}</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<p>{longText}</p>
|
||||
<p className="mt-2">AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA</p>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="b">
|
||||
<AccordionTrigger>두 번째 항목</AccordionTrigger>
|
||||
<AccordionContent>짧은 내용</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
)
|
||||
}
|
||||
|
||||
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 /> },
|
||||
]
|
||||
|
||||
// No `component` here: Accordion props are a union (single | multiple), so Stories without args would not type-check.
|
||||
const meta = { title: "Components/Disclosure/Accordion", 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, note: s.note, children: s.node }))} />
|
||||
)
|
||||
|
||||
// Representative Story: the common Story page; the sections group the same specimens.
|
||||
export const All: Story = {
|
||||
name: "Accordion",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Accordion"
|
||||
description="제목을 눌러 관련 내용을 펼치고 접는 목록입니다. 동작은 Radix API(type, collapsible, defaultValue, disabled)로 정하며 HM variant prop은 없습니다."
|
||||
/>
|
||||
<StorySection title="Overview" description="기본 Accordion입니다.">{board("single")}</StorySection>
|
||||
<StorySection title="Variants" description="모두 접을 수 있는 single과, 여러 항목을 동시에 여는 multiple입니다.">
|
||||
{board("collapsible-single", "multiple")}
|
||||
</StorySection>
|
||||
<StorySection title="States">{board("disabled")}</StorySection>
|
||||
<StorySection title="Usage">{board("long")}</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
// Verification Stories: the same specimens, hidden from the sidebar (tag `verification`).
|
||||
export const Single: Story = { tags: ["verification"], render: () => <SingleSpecimen /> }
|
||||
export const Multiple: Story = { tags: ["verification"], render: () => <MultipleSpecimen /> }
|
||||
export const Disabled: Story = { tags: ["verification"], render: () => <DisabledSpecimen /> }
|
||||
export const LongContent: Story = { tags: ["verification"], name: "Long Content", render: () => <LongSpecimen /> }
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
Button,
|
||||
} from "@hm-design/ui"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Alert Dialog is a modal Radix overlay (Portal) for a decision that must be confirmed or cancelled: no Close (X), no outside
|
||||
// dismiss. The representative Story opens the real dialogs; the same cases start open in the verification Stories.
|
||||
// Action / Cancel are the HM Button: ordinary confirmation uses a Solid pair; deletion uses an Outline pair.
|
||||
|
||||
function Confirmation({ defaultOpen }: { defaultOpen?: boolean }) {
|
||||
return (
|
||||
<AlertDialog defaultOpen={defaultOpen}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="primary" appearance="outline">
|
||||
로그아웃
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>로그아웃하시겠습니까?</AlertDialogTitle>
|
||||
<AlertDialogDescription>저장하지 않은 변경 사항은 사라집니다.</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel appearance="solid">취소</AlertDialogCancel>
|
||||
<AlertDialogAction variant="primary" appearance="solid">로그아웃</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
|
||||
function Destructive({ defaultOpen }: { defaultOpen?: boolean }) {
|
||||
return (
|
||||
<AlertDialog defaultOpen={defaultOpen}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" appearance="outline">
|
||||
삭제
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>프로젝트를 삭제하시겠습니까?</AlertDialogTitle>
|
||||
<AlertDialogDescription>삭제하면 되돌릴 수 없습니다. 프로젝트의 모든 데이터가 함께 삭제됩니다.</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel appearance="outline">취소</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" appearance="outline">
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = { title: "Components/Overlay/Alert Dialog", component: AlertDialog, parameters: { layout: "centered" } } satisfies Meta<typeof AlertDialog>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Representative Story: the common Story page; each trigger opens the real dialog.
|
||||
export const All: Story = {
|
||||
name: "Alert Dialog",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Alert Dialog"
|
||||
description="반드시 확인하거나 취소해야 하는 결정을 묻는 모달입니다. 닫기(X)와 바깥 클릭 닫기가 없습니다."
|
||||
/>
|
||||
<StorySection title="Overview" description="일반 확인창은 같은 크기의 Solid 버튼 세트입니다. 버튼을 눌러 엽니다.">
|
||||
<Confirmation />
|
||||
</StorySection>
|
||||
<StorySection title="Variants" description="삭제 확인창은 취소와 삭제 모두 같은 크기의 Outline 세트입니다.">
|
||||
<Destructive />
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const ConfirmationOpen: Story = { tags: ["verification"], name: "Confirmation", render: () => <Confirmation defaultOpen /> }
|
||||
export const DestructiveOpen: Story = { tags: ["verification"], name: "Destructive", render: () => <Destructive defaultOpen /> }
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { InfoIcon, TriangleAlertIcon } from "lucide-react"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
const meta = {
|
||||
title: "Components/Feedback/Alert",
|
||||
component: Alert,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"현재 HM 규격: 인라인 메시지(Toast 아님). padding 12/16, radius 4, border 1px, Title 14/20/500, Description 14/20/400. variant는 default(neutral)와 destructive만 둡니다. success·warning·information은 HM 상태 색이 정해지지 않아 만들지 않았습니다. 닫기 동작은 없습니다.",
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: { variant: { control: "inline-radio", options: ["default", "destructive"] } },
|
||||
} satisfies Meta<typeof Alert>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const All: Story = {
|
||||
name: "Alert",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Alert"
|
||||
description="화면 안에 놓이는 인라인 메시지입니다(Toast 아님). variant는 default(neutral)와 destructive만 두며 닫기 동작은 없습니다."
|
||||
/>
|
||||
<StorySection title="Overview">
|
||||
<StateBoard
|
||||
min={320}
|
||||
states={[
|
||||
{
|
||||
id: "default",
|
||||
label: "Default",
|
||||
children: (
|
||||
<Alert>
|
||||
<InfoIcon aria-hidden="true" />
|
||||
<AlertTitle>Heads up</AlertTitle>
|
||||
<AlertDescription>You can update your project settings at any time.</AlertDescription>
|
||||
</Alert>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Variants">
|
||||
<StateBoard
|
||||
min={320}
|
||||
states={[
|
||||
{
|
||||
id: "destructive",
|
||||
label: "Destructive",
|
||||
children: (
|
||||
<Alert variant="destructive">
|
||||
<TriangleAlertIcon aria-hidden="true" />
|
||||
<AlertTitle>Unable to save</AlertTitle>
|
||||
<AlertDescription>Check the project name and try again.</AlertDescription>
|
||||
</Alert>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Usage" description="아이콘 없음, 제목만, 긴 내용 구성입니다.">
|
||||
<StateBoard
|
||||
min={320}
|
||||
states={[
|
||||
{
|
||||
id: "no-icon",
|
||||
label: "No icon",
|
||||
children: (
|
||||
<Alert>
|
||||
<AlertTitle>Title only layout</AlertTitle>
|
||||
<AlertDescription>Without an icon the text starts at the left edge.</AlertDescription>
|
||||
</Alert>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "title-only",
|
||||
label: "Title only",
|
||||
children: (
|
||||
<Alert>
|
||||
<InfoIcon aria-hidden="true" />
|
||||
<AlertTitle>Saved</AlertTitle>
|
||||
</Alert>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "long",
|
||||
label: "Long content",
|
||||
children: (
|
||||
<div className="max-w-xs">
|
||||
<Alert>
|
||||
<InfoIcon aria-hidden="true" />
|
||||
<AlertTitle>Scheduled maintenance</AlertTitle>
|
||||
<AlertDescription>The service will be unavailable for a short time during the maintenance window. Please save your work before it starts.</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const Default: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => (
|
||||
<Alert>
|
||||
<InfoIcon aria-hidden="true" />
|
||||
<AlertTitle>Heads up</AlertTitle>
|
||||
<AlertDescription>You can update your project settings at any time.</AlertDescription>
|
||||
</Alert>
|
||||
),
|
||||
}
|
||||
export const Destructive: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => (
|
||||
<Alert variant="destructive">
|
||||
<TriangleAlertIcon aria-hidden="true" />
|
||||
<AlertTitle>Unable to save</AlertTitle>
|
||||
<AlertDescription>Check the project name and try again.</AlertDescription>
|
||||
</Alert>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { AspectRatio, Card, CardContent } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// AspectRatio is a layout primitive: it keeps the ratio and nothing else. The image styling (object-fit, radius) in these Stories is
|
||||
// the consumer's composition on the child, not part of AspectRatio. Images are inline data URIs (no network). The Card example only
|
||||
// shows how a consumer can compose it; AspectRatio does not depend on Card.
|
||||
|
||||
const svg = (label: string, fill: string) =>
|
||||
"data:image/svg+xml;utf8," +
|
||||
encodeURIComponent(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="480" height="320" viewBox="0 0 480 320"><rect width="480" height="320" fill="${fill}"/><circle cx="360" cy="90" r="40" fill="#bddfd4"/><path d="M0 320 L150 150 L260 260 L340 190 L480 320 Z" fill="#205241"/><text x="24" y="44" font-family="sans-serif" font-size="26" fill="#ffffff">${label}</text></svg>`
|
||||
)
|
||||
|
||||
function Image({ ratio, label }: { ratio: number; label: string }) {
|
||||
return (
|
||||
<div style={{ width: "100%", maxWidth: 320 }}>
|
||||
<AspectRatio ratio={ratio} data-testid={`ratio-${label}`}>
|
||||
<img src={svg(label, "#266651")} alt={`${label} 예시 이미지`} className="size-full rounded-md object-cover" />
|
||||
</AspectRatio>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Placeholder() {
|
||||
return (
|
||||
<div style={{ width: "100%", maxWidth: 320 }}>
|
||||
<AspectRatio ratio={16 / 9} data-testid="ratio-video">
|
||||
<div className="grid size-full place-items-center rounded-md border bg-muted text-sm text-muted-foreground">영상 영역 16:9</div>
|
||||
</AspectRatio>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InCard() {
|
||||
return (
|
||||
<Card style={{ width: "100%", maxWidth: 320 }}>
|
||||
<CardContent>
|
||||
<AspectRatio ratio={4 / 3} data-testid="ratio-card">
|
||||
<img src={svg("Card 4:3", "#3e3523")} alt="카드 안 4:3 이미지" className="size-full rounded-md object-cover" />
|
||||
</AspectRatio>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
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 /> },
|
||||
]
|
||||
|
||||
const meta = { title: "Components/Data & Display/Aspect Ratio", component: AspectRatio, parameters: { layout: "padded" } } satisfies Meta<typeof AspectRatio>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const board = (...ids: string[]) => (
|
||||
<StateBoard min={340} 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.
|
||||
export const All: Story = {
|
||||
name: "Aspect Ratio",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
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>
|
||||
<StorySection title="Usage" description="Card 안의 이미지 비율 조합입니다.">{board("card")}</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const Wide: Story = { tags: ["verification"], name: "16:9", render: () => <Image ratio={16 / 9} label="16:9" /> }
|
||||
export const Square: Story = { tags: ["verification"], name: "1:1", render: () => <Image ratio={1} label="1:1" /> }
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Images are inline data URIs so the Story does not depend on the network. "Broken" uses an address that cannot load so the
|
||||
// Radix fallback shows. Radix renders the image only after it loads.
|
||||
|
||||
const photo =
|
||||
"data:image/svg+xml;utf8," +
|
||||
encodeURIComponent(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80"><rect width="80" height="80" fill="#266651"/><circle cx="40" cy="31" r="14" fill="#bddfd4"/><rect x="14" y="50" width="52" height="40" rx="22" fill="#bddfd4"/></svg>'
|
||||
)
|
||||
|
||||
function Sizes() {
|
||||
return (
|
||||
<div className="flex items-end gap-4">
|
||||
{(["sm", "default", "lg"] as const).map((size) => (
|
||||
<div key={size} className="grid justify-items-center gap-2">
|
||||
<Avatar size={size}>
|
||||
<AvatarImage src={photo} alt="" />
|
||||
<AvatarFallback>한맥</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-xs text-muted-foreground">{size}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Fallbacks() {
|
||||
const items = [
|
||||
{ label: "한글 두 글자", text: "김한" },
|
||||
{ label: "영문 두 글자", text: "HM" },
|
||||
{ label: "한 글자", text: "한" },
|
||||
{ label: "긴 fallback", text: "ABCDEFGHIJ" },
|
||||
]
|
||||
return (
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
{items.map((i) => (
|
||||
<div key={i.label} className="grid justify-items-center gap-2">
|
||||
<Avatar>
|
||||
<AvatarImage src="/broken-avatar-image.png" alt="" />
|
||||
<AvatarFallback>{i.text}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-xs text-muted-foreground">{i.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Group() {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar>
|
||||
<AvatarImage src={photo} alt="김한맥" />
|
||||
<AvatarFallback>김한</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid text-sm">
|
||||
<span className="font-medium">김한맥</span>
|
||||
<span className="text-muted-foreground">노선 설계팀</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const sections = [
|
||||
{ id: "image", label: "Image", note: "이미지가 로드되면 fallback은 나타나지 않습니다", node: <Sizes /> },
|
||||
{ id: "fallback", label: "Fallback (image error)", note: "이미지 로드 실패 시 initials", node: <Fallbacks /> },
|
||||
{ id: "composition", label: "Composition", node: <Group /> },
|
||||
]
|
||||
|
||||
const meta = { title: "Components/Data & Display/Avatar", component: Avatar, parameters: { layout: "padded" } } satisfies Meta<typeof Avatar>
|
||||
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 }))} />
|
||||
)
|
||||
|
||||
// Representative Story: the common Story page; the sections group the same specimens.
|
||||
export const All: Story = {
|
||||
name: "Avatar",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Avatar"
|
||||
description="사용자를 나타내는 원형 이미지입니다. 이미지를 불러오지 못하면 fallback 글자를 표시합니다."
|
||||
/>
|
||||
<StorySection title="Overview">{board("image")}</StorySection>
|
||||
<StorySection title="States">{board("fallback")}</StorySection>
|
||||
<StorySection title="Usage" description="이름·소속과 함께 쓰는 소비 조합입니다.">{board("composition")}</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const Image: Story = { tags: ["verification"], render: () => <Sizes /> }
|
||||
export const FallbackError: Story = { tags: ["verification"], name: "Fallback", render: () => <Fallbacks /> }
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
import { Badge } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
const variants = ["default", "secondary", "destructive", "outline"] as const
|
||||
|
||||
const meta = {
|
||||
title: "Components/Data & Display/Badge",
|
||||
component: Badge,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"현재 HM 규격: 12/18/500, padding 2/8, radius 4, border 1px. variant는 현재 HM Theme에 의미가 있는 default(primary), secondary(neutral), destructive, outline만 둡니다. success·warning·information은 상태 색이 정해지지 않아 만들지 않았습니다. Button variant를 재사용하지 않는 별도 컴포넌트입니다.",
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: { variant: { control: "inline-radio", options: variants } },
|
||||
} satisfies Meta<typeof Badge>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const All: Story = {
|
||||
name: "Badge",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Badge"
|
||||
description="상태나 분류를 짧게 표시하는 라벨입니다. Button variant를 재사용하지 않는 별도 컴포넌트입니다."
|
||||
/>
|
||||
<StorySection title="Overview" description="variant를 생략한 기본 Badge(primary)입니다.">
|
||||
<Badge>Badge</Badge>
|
||||
</StorySection>
|
||||
<StorySection title="Variants" description="default(primary) / secondary(neutral) / destructive / outline.">
|
||||
<StateBoard
|
||||
min={280}
|
||||
states={[
|
||||
{
|
||||
id: "variants",
|
||||
label: "Text",
|
||||
children: (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{variants.map((v) => (
|
||||
<Badge key={v} variant={v}>
|
||||
{v}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "icon",
|
||||
label: "With icon",
|
||||
children: (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{variants.map((v) => (
|
||||
<Badge key={v} variant={v}>
|
||||
<CheckIcon aria-hidden="true" />
|
||||
{v}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Usage" description="긴 텍스트와 링크(asChild)입니다.">
|
||||
<StateBoard
|
||||
min={280}
|
||||
states={[
|
||||
{ id: "long", label: "Long text", children: <div className="max-w-[160px]"><Badge>A very long badge label</Badge></div> },
|
||||
{
|
||||
id: "link",
|
||||
label: "asChild (link)",
|
||||
children: (
|
||||
<Badge asChild variant="outline">
|
||||
<a href="#badge">Link badge</a>
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const Variants: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{variants.map((v) => (
|
||||
<Badge key={v} variant={v}>
|
||||
{v}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}
|
||||
export const Playground: Story = { tags: ["verification"], args: { variant: "default", children: "Badge" } }
|
||||
@@ -0,0 +1,149 @@
|
||||
import { Fragment, type ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbEllipsis,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// The representative "Breadcrumb" Story shows each case on one page (real links with sample hrefs); the same specimens also exist
|
||||
// as Stories tagged `verification` (hidden from the sidebar). BreadcrumbEllipsis is presentational: the Dropdown Menu example is
|
||||
// a consumer composition, not part of Breadcrumb.
|
||||
|
||||
const narrow = (children: ReactNode) => <div className="w-full max-w-xl">{children}</div>
|
||||
|
||||
function Basic() {
|
||||
return (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="#home">홈</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="#users">사용자</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>상세</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
)
|
||||
}
|
||||
|
||||
function Ellipsis() {
|
||||
return (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="#home">홈</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger aria-label="중간 경로 보기">
|
||||
<BreadcrumbEllipsis />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem>문서</DropdownMenuItem>
|
||||
<DropdownMenuItem>설정</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="#project">프로젝트</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>상세</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
)
|
||||
}
|
||||
|
||||
function Long() {
|
||||
const labels = ["홈", "프로젝트", "한맥 프로젝트 관리 시스템", "노선 정보", "시설물 목록", "교량", "상세 정보 및 점검 이력"]
|
||||
return (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{labels.map((label, i) => (
|
||||
<Fragment key={label}>
|
||||
<BreadcrumbItem>
|
||||
{i === labels.length - 1 ? <BreadcrumbPage>{label}</BreadcrumbPage> : <BreadcrumbLink href={`#${i}`}>{label}</BreadcrumbLink>}
|
||||
</BreadcrumbItem>
|
||||
{i < labels.length - 1 ? <BreadcrumbSeparator /> : null}
|
||||
</Fragment>
|
||||
))}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
)
|
||||
}
|
||||
|
||||
// asChild: the link is any element (here a plain <a> standing in for a router Link) and keeps the Breadcrumb look.
|
||||
function CustomLink() {
|
||||
return (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<a href="#router-home">홈 (custom link)</a>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator>/</BreadcrumbSeparator>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>현재 페이지</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
)
|
||||
}
|
||||
|
||||
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 /> },
|
||||
]
|
||||
|
||||
const meta = { title: "Components/Navigation/Breadcrumb", component: Breadcrumb, parameters: { layout: "padded" } } satisfies Meta<typeof Breadcrumb>
|
||||
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) }))} />
|
||||
)
|
||||
|
||||
// Representative Story: the common Story page; the sections group the same specimens.
|
||||
export const All: Story = {
|
||||
name: "Breadcrumb",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Breadcrumb"
|
||||
description="현재 페이지까지의 상위 경로를 보여주는 내비게이션입니다. 마지막 항목은 현재 페이지입니다."
|
||||
/>
|
||||
<StorySection title="Overview">{board("basic")}</StorySection>
|
||||
<StorySection title="Variants" description="중간 경로 생략(Ellipsis)과 사용자 링크 · 구분자입니다.">{board("ellipsis", "custom")}</StorySection>
|
||||
<StorySection title="Usage">{board("long")}</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const Basic3: Story = { tags: ["verification"], name: "Basic", render: () => <Basic /> }
|
||||
export const WithEllipsis: Story = { tags: ["verification"], name: "Ellipsis", render: () => <Ellipsis /> }
|
||||
export const LongPath: Story = { tags: ["verification"], name: "Long Path", render: () => narrow(<Long />) }
|
||||
@@ -0,0 +1,320 @@
|
||||
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 { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Story structure follows ui_viz (implementations/web/storybook/Button.stories.jsx):
|
||||
// one representative "Button" Story shows kinds, sizes and states on a single screen; the same sections also exist
|
||||
// as separate Stories tagged `verification`, which the sidebar hides by default (see .storybook/main.ts).
|
||||
// Playground and Compatibility are HM-only and come after the ui_viz Stories.
|
||||
|
||||
type Role = "primary" | "secondary" | "modify" | "destructive" | "cancel"
|
||||
|
||||
// [role, label]. The gallery shows supported role/treatment combinations; usage sets are documented separately.
|
||||
const roles: [Role, string][] = [
|
||||
["primary", "Green"],
|
||||
["secondary", "Brown"],
|
||||
["modify", "Orange"],
|
||||
["cancel", "Gray"],
|
||||
["destructive", "Red"],
|
||||
]
|
||||
const rolesFor = (appearance: ButtonAppearance) =>
|
||||
roles.filter(([role]) =>
|
||||
appearance === "brand"
|
||||
? ["primary", "secondary", "modify"].includes(role)
|
||||
: appearance === "solid"
|
||||
? role !== "destructive"
|
||||
: ["primary", "secondary", "destructive", "cancel"].includes(role),
|
||||
)
|
||||
|
||||
const meta = {
|
||||
title: "Components/Form/Button",
|
||||
component: Button,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"ui_viz docs/components/button.md D-087: Brand Gradient와 Standard Solid/Outline. 크기 28/34/42, 글자 12/18 · 13/20 · 15/22, Brand 700 · Standard 500. Focus 시각 값은 ui_viz에서 전역 TBD이므로 shadcn 기본 focus 동작을 그대로 사용하며, Tab 키로 확인합니다.",
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: "select",
|
||||
options: roles.map(([role]) => role),
|
||||
description: "역할(Role): primary Green, secondary Brown, modify Orange, destructive Red, cancel Gray. destructive는 outline만, modify는 brand/solid만, cancel은 solid/outline만 지원합니다. docs/components/button.md §6.1 참조.",
|
||||
},
|
||||
appearance: {
|
||||
control: "inline-radio",
|
||||
options: ["brand", "solid", "outline"],
|
||||
description: "Treatment 기본값은 brand. Controls는 Role별 허용 조합을 제한하지 않습니다. destructive/cancel은 허용 appearance를 명시합니다. docs/components/button.md §6.1~6.2 참조.",
|
||||
},
|
||||
size: {
|
||||
control: "inline-radio",
|
||||
options: ["sm", "default", "lg"],
|
||||
description: "sm은 Small 28px, default는 Medium 34px, lg는 Large 42px.",
|
||||
},
|
||||
disabled: { control: "boolean" },
|
||||
children: { control: "text" },
|
||||
asChild: { table: { disable: true } },
|
||||
className: { table: { disable: true } },
|
||||
},
|
||||
} satisfies Meta<typeof Button>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const row = "flex flex-wrap items-center gap-4"
|
||||
const stack = "grid gap-6"
|
||||
const heading = "text-base font-semibold"
|
||||
const note = "text-sm text-muted-foreground"
|
||||
|
||||
function RolesBoard() {
|
||||
return (
|
||||
<div className={stack}>
|
||||
{(
|
||||
[
|
||||
["brand", "Brand"],
|
||||
["solid", "Standard Solid"],
|
||||
["outline", "Standard Outline"],
|
||||
] as const
|
||||
).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>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const sizes = [
|
||||
["sm", "Small"],
|
||||
["default", "Medium"],
|
||||
["lg", "Large"],
|
||||
] as const
|
||||
|
||||
function SizesBoard() {
|
||||
return (
|
||||
<div className={stack}>
|
||||
{(["brand", "solid"] as const).map((appearance) => (
|
||||
<div className={row} key={appearance}>
|
||||
{sizes.map(([size, label]) => (
|
||||
<Button key={size} appearance={appearance} size={size}>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BrandStates() {
|
||||
const [count, setCount] = useState(0)
|
||||
return (
|
||||
<div className={stack}>
|
||||
<div className={row}>
|
||||
<Button>Default</Button>
|
||||
<Button data-sb-hover="">Hover</Button>
|
||||
<Button data-sb-active="">Pressed</Button>
|
||||
<Button onClick={() => setCount((n) => n + 1)}>Interact</Button>
|
||||
<Button disabled onClick={() => setCount((n) => n + 1)}>
|
||||
Disabled
|
||||
</Button>
|
||||
</div>
|
||||
<output aria-live="polite">Activated: {count}</output>
|
||||
<p className={note}>
|
||||
Hover와 Pressed는 Storybook이 고정한 상태입니다. Interact 위에서 실제 Hover·Pressed·포인터 이탈을 확인합니다. Disabled는 실행되지 않습니다.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const standardStates = [
|
||||
["Default", {}],
|
||||
["Hover", { "data-sb-hover": "" }],
|
||||
["Pressed", { "data-sb-active": "" }],
|
||||
["Disabled", { disabled: true }],
|
||||
] as const
|
||||
|
||||
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">
|
||||
<table className="w-full border-collapse [&_td]:p-3 [&_th]:p-3 [&_th]:text-left">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Role</th>
|
||||
{standardStates.map(([state]) => (
|
||||
<th scope="col" key={state}>
|
||||
{state}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rolesFor(appearance).map(([variant, label]) => (
|
||||
<tr key={variant}>
|
||||
<th scope="row">{label}</th>
|
||||
{standardStates.map(([state, props]) => (
|
||||
<td key={state}>
|
||||
<Button variant={variant} appearance={appearance} {...props}>
|
||||
{label}
|
||||
</Button>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function StandardStatesBoard() {
|
||||
return (
|
||||
<div className={stack}>
|
||||
<StandardStateTable appearance="solid" title="Standard Solid" />
|
||||
<StandardStateTable appearance="outline" title="Standard Outline" />
|
||||
<p className={note}>
|
||||
Standard Solid의 Hover·Pressed는 역할 면 위에 Black 20%를 합성합니다(Pressed = Hover). Green·Brown Outline은 Hover에서 역할색 면 + White 라벨, Pressed에서 White 면 + Black 20%입니다. Red Outline은 Hover·Pressed 모두 Red 8% 면이며 Red 라벨·보더를 유지합니다. Disabled는 Default 모양 그대로 opacity 60%이고 상태 면을 적용하지 않습니다. Default 열의 버튼으로 실제 포인터 상태도 확인할 수 있습니다. Brand 상태는 Brand States에서 확인합니다.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LongLabelBoard() {
|
||||
return (
|
||||
<div className="w-[180px]">
|
||||
<Button className="whitespace-normal">Apply changes to all selected project records</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DestructiveBoard() {
|
||||
return (
|
||||
<div className={row}>
|
||||
<Button variant="cancel" appearance="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" appearance="outline">
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Representative Story: the common Story page (Overview → Sizes → Variants → States) with the ui_viz Button "All" boards.
|
||||
// Named after the component, as in ui_viz. It is the only Button entry in the default sidebar, so Storybook shows Button as a single 1-depth item.
|
||||
export const All: Story = {
|
||||
name: "Button",
|
||||
parameters: { layout: "fullscreen" },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Button"
|
||||
description="사용자가 동작을 실행하는 기본 컨트롤입니다. variant는 역할(색), appearance는 Brand / Standard Solid / Standard Outline, size는 높이를 정합니다."
|
||||
/>
|
||||
<StorySection title="Overview" description="props를 생략한 기본 Button: Brand · Primary(Green) · Medium 34px.">
|
||||
<Button>Button</Button>
|
||||
</StorySection>
|
||||
<StorySection title="Sizes" description="Small 28 / Medium 34 / Large 42px · 글자 12/18 · 13/20 · 15/22. 위는 Brand, 아래는 Standard Solid입니다.">
|
||||
<SizesBoard />
|
||||
</StorySection>
|
||||
<StorySection title="Variants" description="appearance별로 허용된 역할 조합입니다.">
|
||||
<RolesBoard />
|
||||
</StorySection>
|
||||
<StorySection title="States" description="Default / Hover / Pressed / Disabled. Hover·Pressed는 Storybook이 고정한 상태입니다.">
|
||||
<StateBoard
|
||||
min={1200}
|
||||
states={[
|
||||
{ id: "brand-states", label: "Brand", children: <BrandStates /> },
|
||||
{ id: "standard-states", label: "Standard Solid · Standard Outline", children: <StandardStatesBoard /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
// Verification Stories: the same sections as separate Stories, hidden from the sidebar (tag `verification`), reachable by URL or the sidebar tag filter.
|
||||
export const Roles: Story = { tags: ["verification"], render: () => <RolesBoard /> }
|
||||
export const Sizes: Story = { tags: ["verification"], render: () => <SizesBoard /> }
|
||||
export const States: Story = { tags: ["verification"], name: "Brand States", render: () => <BrandStates /> }
|
||||
export const StandardStates: Story = { tags: ["verification"], name: "Standard States", render: () => <StandardStatesBoard /> }
|
||||
export const LongLabel: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => <LongLabelBoard />,
|
||||
parameters: {
|
||||
docs: { description: { story: "일반 Label은 한 줄입니다. 긴 Label은 기존 className API의 whitespace-normal로 줄바꿈을 명시하며, 폭이 부족하면 높이가 늘어나고 각 줄은 가운데 정렬됩니다." } },
|
||||
},
|
||||
}
|
||||
|
||||
export const KoreanLabels: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => (
|
||||
<div style={{ display: "grid", gap: 16, width: 88 }}>
|
||||
{["저장", "변경사항 적용", "설정 저장", "파일 다운로드"].map((label) => <div key={label}><Button>{label}</Button></div>)}
|
||||
</div>
|
||||
),
|
||||
}
|
||||
export const IconTextNarrow: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => (
|
||||
<div style={{ display: "grid", gap: 16, width: 88 }}>
|
||||
{(["sm", "default", "lg"] as const).map((size) => <div key={size}><Button size={size}><DownloadIcon />파일 다운로드</Button></div>)}
|
||||
</div>
|
||||
),
|
||||
}
|
||||
export const Destructive: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => <DestructiveBoard />,
|
||||
parameters: {
|
||||
docs: { description: { story: "검증 전용 삭제·취소 세트: Cancel은 Gray Outline, Delete는 Red Outline이며 appearance와 size를 통일합니다. Red Hover/Pressed 면은 Red 8%입니다." } },
|
||||
},
|
||||
}
|
||||
|
||||
// HM only: not part of the ui_viz Button Stories. Tagged verification so the default sidebar keeps only the representative Story.
|
||||
export const Playground: Story = {
|
||||
tags: ["verification"],
|
||||
args: { children: "저장", variant: "primary", appearance: "brand", size: "default", disabled: false },
|
||||
parameters: {
|
||||
docs: { description: { story: "검증 전용 Controls는 역할과 Treatment를 독립적으로 선택하므로 미지원 조합도 선택할 수 있습니다. 대표 Button의 Variants와 docs/components/button.md §6.1의 허용 조합만 사용하세요. Focus는 ui_viz에서 TBD이므로 Tab 키로 shadcn 기본 focus 동작을 확인합니다." } },
|
||||
},
|
||||
}
|
||||
|
||||
export const Compatibility: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => (
|
||||
<div className={stack}>
|
||||
<section>
|
||||
<h2 className={heading}>shadcn compatibility</h2>
|
||||
<p className={`${note} mt-1`}>ui_viz 공식 Button 규격이 아닙니다. shadcn 기본 스타일을 그대로 씁니다.</p>
|
||||
<div className={`${row} mt-3`}>
|
||||
<Button variant="ghost">ghost</Button>
|
||||
<Button variant="link">link</Button>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h2 className={heading}>alias</h2>
|
||||
<p className={`${note} mt-1`}>기존 shadcn 사용 코드를 위한 별칭입니다. default는 primary, outline은 primary + outline과 같습니다.</p>
|
||||
<div className={`${row} mt-3`}>
|
||||
<Button variant="default">default</Button>
|
||||
<Button variant="outline">outline</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useState, type ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import type { DateRange } from "react-day-picker"
|
||||
import { Calendar } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// The representative "Calendar" Story shows each mode with real, clickable calendars; the same specimens also exist as Stories
|
||||
// tagged `verification` (hidden from the sidebar). `today` is pinned to 2026-10-02 so the Story looks the same on any day; this
|
||||
// uses react-day-picker's own `today` prop, not an HM state. Korean is the Calendar default locale.
|
||||
|
||||
const today = new Date(2026, 9, 2)
|
||||
const month = new Date(2026, 9, 1)
|
||||
|
||||
const box = (node: ReactNode) => <div className="rounded-md border bg-card w-fit">{node}</div>
|
||||
|
||||
function Single() {
|
||||
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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DisabledDates() {
|
||||
const [date, setDate] = useState<Date | undefined>()
|
||||
return box(
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={date}
|
||||
onSelect={setDate}
|
||||
defaultMonth={month}
|
||||
today={today}
|
||||
disabled={[{ before: new Date(2026, 9, 5) }, { dayOfWeek: [0] }]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Range() {
|
||||
const [range, setRange] = useState<DateRange | undefined>({ from: new Date(2026, 9, 8), to: new Date(2026, 9, 14) })
|
||||
return box(<Calendar mode="range" selected={range} onSelect={setRange} defaultMonth={month} today={today} />)
|
||||
}
|
||||
|
||||
function TwoMonths() {
|
||||
return box(<Calendar mode="single" numberOfMonths={2} defaultMonth={month} today={today} />)
|
||||
}
|
||||
|
||||
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: "range", label: "Range", node: <Range /> },
|
||||
{ id: "months", label: "Two months", node: <TwoMonths /> },
|
||||
]
|
||||
|
||||
// No `component`: Calendar props are a union of selection modes, so Stories without args would not type-check.
|
||||
const meta = { title: "Components/Search & Date/Calendar", parameters: { layout: "padded" } } satisfies Meta
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const board = (...ids: string[]) => (
|
||||
<StateBoard min={300} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, wide: s.id === "months", children: s.node }))} />
|
||||
)
|
||||
|
||||
// Representative Story: the common Story page; the sections group the same specimens.
|
||||
export const All: Story = {
|
||||
name: "Calendar",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
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>
|
||||
<StorySection title="States" description="선택할 수 없는 날짜(disabled)입니다.">{board("disabled")}</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const SingleSelection: Story = { tags: ["verification"], name: "Single", render: () => <Single /> }
|
||||
export const DisabledSelection: Story = { tags: ["verification"], name: "Disabled Dates", render: () => <DisabledDates /> }
|
||||
export const RangeSelection: Story = { tags: ["verification"], name: "Range", render: () => <Range /> }
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Button } from "@hm-design/ui"
|
||||
import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, type CardSurface } from "@hm-design/ui"
|
||||
import { Input } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Story structure follows Button / Input / Select: one representative "Card" Story shows the ui_viz cases on one page,
|
||||
// the same specimens also exist as separate Stories tagged `verification` (hidden from the sidebar, see .storybook/main.ts).
|
||||
// ui_viz (content-container.md Type B Content Card) defines only the Default state, two surfaces, a Title with an optional
|
||||
// Meta / Action, and a body. Hover / Selected / Clickable, Footer and media are not defined, so they are not shown here
|
||||
// except in the Compatibility Story.
|
||||
|
||||
function Body({ children }: { children?: ReactNode }) {
|
||||
return <p className="text-sm">{children ?? "본문은 카드 안쪽 여백 24px 안에 놓입니다. 내용물의 모양은 각 컴포넌트가 정합니다."}</p>
|
||||
}
|
||||
|
||||
function Specimen({ surface = "surface", action, title = "프로젝트 정보", children }: { surface?: CardSurface; action?: boolean; title?: string; children?: ReactNode }) {
|
||||
return (
|
||||
<Card surface={surface}>
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
{action ? (
|
||||
<CardAction>
|
||||
<Button variant="primary" appearance="solid" size="sm">
|
||||
자세히 보기
|
||||
</Button>
|
||||
</CardAction>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent>{children ?? <Body />}</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function FormSpecimen() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>입력 폼</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-2">
|
||||
<label htmlFor="card-form-name" className="text-sm font-medium text-muted-foreground">
|
||||
프로젝트명
|
||||
</label>
|
||||
<Input id="card-form-name" size="medium" placeholder="프로젝트명을 입력하세요" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Components/Data & Display/Card",
|
||||
component: Card,
|
||||
parameters: { layout: "padded" },
|
||||
argTypes: {
|
||||
surface: { control: "inline-radio", options: ["surface", "content"], description: "ui_viz surface 축: White(기본) / Beige." },
|
||||
},
|
||||
} satisfies Meta<typeof Card>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Representative Story: the common Story page (Overview → Variants → Usage) with every ui_viz case. Named after the component, as in ui_viz.
|
||||
// It is the only Card entry in the default sidebar, so Storybook shows Card as a single 1-depth item.
|
||||
export const All: Story = {
|
||||
name: "Card",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Card"
|
||||
description="제목과 본문을 하나의 면으로 묶는 콘텐츠 컨테이너입니다(ui_viz Content Card). Default 상태만 정의되어 있습니다."
|
||||
/>
|
||||
<StorySection title="Overview" description="surface를 생략한 기본 Card(White)입니다.">
|
||||
<StateBoard min={360} states={[{ id: "surface", label: "Surface · White (기본)", children: <Specimen /> }]} />
|
||||
</StorySection>
|
||||
<StorySection title="Variants" description="surface: content(Beige)와 제목 옆 Action입니다.">
|
||||
<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 /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Usage">
|
||||
<StateBoard min={360} states={[{ id: "form", label: "본문 조합 · Input", children: <FormSpecimen /> }]} />
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
// Verification Stories: the same specimens as separate Stories, hidden from the sidebar (tag `verification`).
|
||||
export const Surfaces: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => (
|
||||
<div className="grid max-w-md gap-6">
|
||||
<Specimen />
|
||||
<Specimen surface="content" />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
export const Action: Story = { tags: ["verification"], render: () => <div className="max-w-md"><Specimen action /></div> }
|
||||
|
||||
// HM only: not part of the ui_viz Card spec. Footer, Description and size="sm" are shadcn defaults that ui_viz does not define.
|
||||
export const Compatibility: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => (
|
||||
<div className="grid max-w-md gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>shadcn 호환</CardTitle>
|
||||
<CardDescription>ui_viz에 없는 Description 입니다. shadcn 기본 스타일을 씁니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Body>Footer도 ui_viz 규격이 없어 shadcn 기본 표면을 씁니다.</Body>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button variant="cancel" appearance="solid" size="sm">
|
||||
닫기
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle>size="sm"</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Body>shadcn 작은 크기(여백 12px)입니다. ui_viz에 없습니다.</Body>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useEffect, useState, type ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { AspectRatio, Card, CardContent, Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious, type CarouselApi } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// The representative Story shows real, draggable carousels; the same specimens also exist as Stories tagged `verification`
|
||||
// (hidden from the sidebar). Slide widths (`basis-*`) are the consumer's `className` on CarouselItem; Card and AspectRatio are
|
||||
// Story composition (Carousel imports neither). `setApi` feeds the "slide n / total" readout.
|
||||
|
||||
const frame = (node: ReactNode) => <div style={{ width: "100%", maxWidth: 420, minWidth: 0 }}>{node}</div>
|
||||
|
||||
const svg = (n: number) =>
|
||||
"data:image/svg+xml;utf8," +
|
||||
encodeURIComponent(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="480" height="270"><rect width="480" height="270" fill="${["#266651", "#3e3523", "#205241", "#604f32", "#123328"][n % 5]}"/><text x="24" y="52" font-family="sans-serif" font-size="34" fill="#ffffff">Slide ${n + 1}</text></svg>`
|
||||
)
|
||||
|
||||
function SlideCard({ n }: { n: number }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="grid h-24 place-items-center text-2xl font-semibold">{n + 1}</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function useReadout() {
|
||||
const [api, setApi] = useState<CarouselApi>()
|
||||
const [current, setCurrent] = useState(0)
|
||||
const [count, setCount] = useState(0)
|
||||
useEffect(() => {
|
||||
if (!api) return
|
||||
setCount(api.scrollSnapList().length)
|
||||
setCurrent(api.selectedScrollSnap() + 1)
|
||||
api.on("select", () => setCurrent(api.selectedScrollSnap() + 1))
|
||||
}, [api])
|
||||
return { setApi, current, count }
|
||||
}
|
||||
|
||||
function Basic() {
|
||||
const { setApi, current, count } = useReadout()
|
||||
return frame(
|
||||
<div className="grid gap-2">
|
||||
<Carousel setApi={setApi} aria-label="기본 carousel">
|
||||
<CarouselContent>
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<CarouselItem key={i}>
|
||||
<SlideCard n={i} />
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
<CarouselPrevious />
|
||||
<CarouselNext />
|
||||
</Carousel>
|
||||
<span className="text-center text-sm text-muted-foreground" data-testid="readout">{`slide ${current} / ${count}`}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Multiple() {
|
||||
return frame(
|
||||
<Carousel opts={{ align: "start" }} aria-label="여러 항목 carousel">
|
||||
<CarouselContent>
|
||||
{Array.from({ length: 8 }, (_, i) => (
|
||||
<CarouselItem key={i} className="basis-1/2">
|
||||
<SlideCard n={i} />
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
<CarouselPrevious />
|
||||
<CarouselNext />
|
||||
</Carousel>
|
||||
)
|
||||
}
|
||||
|
||||
function Loop() {
|
||||
const { setApi, current, count } = useReadout()
|
||||
return frame(
|
||||
<div className="grid gap-2">
|
||||
<Carousel opts={{ loop: true }} setApi={setApi} aria-label="loop carousel">
|
||||
<CarouselContent>
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<CarouselItem key={i}>
|
||||
<SlideCard n={i} />
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
<CarouselPrevious />
|
||||
<CarouselNext />
|
||||
</Carousel>
|
||||
<span className="text-center text-sm text-muted-foreground" data-testid="loop-readout">{`slide ${current} / ${count}`}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Vertical() {
|
||||
return (
|
||||
<div style={{ width: 220, height: 260 }}>
|
||||
<Carousel orientation="vertical" opts={{ align: "start" }} aria-label="세로 carousel" className="h-full">
|
||||
<CarouselContent style={{ height: 260 }}>
|
||||
{Array.from({ length: 4 }, (_, i) => (
|
||||
<CarouselItem key={i} className="basis-1/2">
|
||||
<SlideCard n={i} />
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
<CarouselPrevious />
|
||||
<CarouselNext />
|
||||
</Carousel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Media() {
|
||||
return frame(
|
||||
<Carousel aria-label="미디어 carousel">
|
||||
<CarouselContent>
|
||||
{Array.from({ length: 4 }, (_, i) => (
|
||||
<CarouselItem key={i}>
|
||||
<AspectRatio ratio={16 / 9}>
|
||||
<img src={svg(i)} alt={`슬라이드 ${i + 1}`} className="size-full rounded-md object-cover" />
|
||||
</AspectRatio>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
<CarouselPrevious />
|
||||
<CarouselNext />
|
||||
</Carousel>
|
||||
)
|
||||
}
|
||||
|
||||
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: "vertical", label: "Vertical", node: <Vertical /> },
|
||||
{ id: "media", label: "Media (AspectRatio composition)", node: <Media /> },
|
||||
]
|
||||
|
||||
const meta = { title: "Components/Data & Display/Carousel", component: Carousel, parameters: { layout: "padded" } } satisfies Meta<typeof Carousel>
|
||||
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, children: s.node }))} />
|
||||
)
|
||||
|
||||
// Representative Story: the common Story page; the sections group the same specimens.
|
||||
export const All: Story = {
|
||||
name: "Carousel",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Carousel"
|
||||
description="여러 슬라이드를 끌기·버튼으로 넘겨 보는 영역입니다. 슬라이드 폭(basis-*)은 CarouselItem의 className으로 정합니다."
|
||||
/>
|
||||
<StorySection title="Overview">{board("basic")}</StorySection>
|
||||
<StorySection title="Variants" description="여러 항목 표시, 반복(loop), 세로 방향입니다.">{board("multiple", "loop", "vertical")}</StorySection>
|
||||
<StorySection title="Usage" description="AspectRatio와 조합한 이미지 슬라이드입니다.">{board("media")}</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const BasicCarousel: Story = { tags: ["verification"], name: "Basic", render: () => <Basic /> }
|
||||
export const MultipleItems: Story = { tags: ["verification"], name: "Multiple Items", render: () => <Multiple /> }
|
||||
export const LoopCarousel: Story = { tags: ["verification"], name: "Loop", render: () => <Loop /> }
|
||||
export const VerticalCarousel: Story = { tags: ["verification"], name: "Vertical", render: () => <Vertical /> }
|
||||
@@ -0,0 +1,124 @@
|
||||
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 { STORY_KICKER, 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.
|
||||
|
||||
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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Controlled() {
|
||||
const [checked, setChecked] = useState<boolean | "indeterminate">("indeterminate")
|
||||
return (
|
||||
<Option label={`Select all (${String(checked)})`}>
|
||||
{(id) => <Checkbox id={id} checked={checked} onCheckedChange={setChecked} />}
|
||||
</Option>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Components/Form/Checkbox",
|
||||
component: Checkbox,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"현재 HM 규격: 18px 박스, radius 4, border 1px(stroke), Checked/Indeterminate는 state-active 테두리·면 + on-state-active 아이콘. Radix Checkbox의 data-state, indeterminate, 키보드(Space)와 접근성을 유지합니다. ui-viz에는 Checkbox 규격이 없어 참고만 했습니다.",
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Checkbox>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const All: Story = {
|
||||
name: "Checkbox",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Checkbox"
|
||||
description="항목을 선택하거나 해제하는 컨트롤입니다. 18px 박스이며 Checked·Indeterminate는 state-active 색입니다."
|
||||
/>
|
||||
<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 /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="States">
|
||||
<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: "focus", label: "Focus", children: <Option label="Option">{(id) => <Checkbox id={id} />}</Option> },
|
||||
{
|
||||
id: "disabled",
|
||||
label: "Disabled",
|
||||
children: (
|
||||
<div className="grid gap-3">
|
||||
<Option label="Unchecked">{(id) => <Checkbox id={id} disabled />}</Option>
|
||||
<Option label="Checked">{(id) => <Checkbox id={id} disabled defaultChecked />}</Option>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Validation" description="aria-invalid로 Error 테두리를 표시합니다.">
|
||||
<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>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
// preventScroll: the page opens at its header, not at the States section.
|
||||
canvasElement.querySelector<HTMLButtonElement>('[data-overview-state="focus"] [role="checkbox"]')?.focus({ preventScroll: true })
|
||||
},
|
||||
}
|
||||
|
||||
export const Unchecked: Story = { tags: ["verification"], render: () => <Option label="Option">{(id) => <Checkbox id={id} />}</Option> }
|
||||
export const Checked: Story = { tags: ["verification"], render: () => <Option label="Option">{(id) => <Checkbox id={id} defaultChecked />}</Option> }
|
||||
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> }
|
||||
export const Playground: Story = {
|
||||
tags: ["verification"],
|
||||
args: { disabled: false, defaultChecked: false },
|
||||
render: (args) => <Option label="Option">{(id) => <Checkbox id={id} {...args} />}</Option>,
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useState, type ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
import { Button, Collapsible, CollapsibleContent, CollapsibleTrigger } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// The representative "Collapsible" Story shows one independent disclosure per state with real, clickable triggers. The same
|
||||
// sections also exist as Stories tagged `verification` (hidden from the sidebar). Collapsible is a single disclosure: no divider
|
||||
// system and no animation.
|
||||
|
||||
const longText = "긴 텍스트는 줄바꿈으로 전체를 표시합니다. 공백 없는 긴 문자열도 너비를 밀어내지 않고 줄바꿈합니다. ".repeat(6)
|
||||
|
||||
const narrow = (children: ReactNode) => <div className="w-full max-w-md">{children}</div>
|
||||
|
||||
function Frame({ label, defaultOpen, disabled, children }: { label: string; defaultOpen?: boolean; disabled?: boolean; children: ReactNode }) {
|
||||
return narrow(
|
||||
<Collapsible defaultOpen={defaultOpen} disabled={disabled}>
|
||||
<CollapsibleTrigger>
|
||||
{label}
|
||||
<ChevronDownIcon aria-hidden />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>{children}</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
|
||||
const ClosedSpecimen = () => <Frame label="고급 설정 보기">추가 옵션이 여기에 표시됩니다.</Frame>
|
||||
const OpenSpecimen = () => (
|
||||
<Frame label="고급 설정 보기" defaultOpen>
|
||||
처음부터 열려 있는 영역입니다.
|
||||
</Frame>
|
||||
)
|
||||
const DisabledSpecimen = () => (
|
||||
<Frame label="비활성 영역" disabled>
|
||||
열 수 없습니다.
|
||||
</Frame>
|
||||
)
|
||||
const LongSpecimen = () => (
|
||||
<Frame label="긴 콘텐츠" defaultOpen>
|
||||
<p>{longText}</p>
|
||||
<p className="mt-2">AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA</p>
|
||||
</Frame>
|
||||
)
|
||||
|
||||
// Controlled: the parent owns `open`; an HM Button is the trigger through asChild and keeps its style.
|
||||
function ControlledSpecimen() {
|
||||
const [open, setOpen] = useState(false)
|
||||
return narrow(
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="primary" appearance="outline">
|
||||
{open ? "접기" : "펼치기"}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>{`controlled: open = ${String(open)}`}</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
|
||||
const sections = [
|
||||
{ id: "closed", label: "Closed", node: <ClosedSpecimen /> },
|
||||
{ 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 /> },
|
||||
]
|
||||
|
||||
const meta = { title: "Components/Disclosure/Collapsible", component: Collapsible, parameters: { layout: "padded" } } satisfies Meta<typeof Collapsible>
|
||||
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 }))} />
|
||||
)
|
||||
|
||||
// Representative Story: the common Story page; the sections group the same specimens.
|
||||
export const All: Story = {
|
||||
name: "Collapsible",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Collapsible"
|
||||
description="하나의 영역을 펼치고 접는 단일 disclosure입니다. 구분선 체계와 애니메이션은 없습니다."
|
||||
/>
|
||||
<StorySection title="Overview">{board("closed")}</StorySection>
|
||||
<StorySection title="States">{board("open", "disabled")}</StorySection>
|
||||
<StorySection title="Usage" description="긴 콘텐츠와, Button을 trigger로 쓰는 제어형 조합입니다.">{board("long", "controlled")}</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const Closed: Story = { tags: ["verification"], render: () => <ClosedSpecimen /> }
|
||||
export const Open: Story = { tags: ["verification"], render: () => <OpenSpecimen /> }
|
||||
export const Disabled: Story = { tags: ["verification"], render: () => <DisabledSpecimen /> }
|
||||
export const LongContent: Story = { tags: ["verification"], name: "Long Content", render: () => <LongSpecimen /> }
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useState, type ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Combobox, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, type ComboboxOption } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Combobox is a Popover + Command composition (search + single choice); Select stays for short lists without search.
|
||||
// The representative Story shows real comboboxes; the same specimens also exist as Stories tagged `verification` (hidden from the
|
||||
// sidebar). The Label is Story composition.
|
||||
|
||||
const frameworks: ComboboxOption[] = [
|
||||
{ value: "next", label: "Next.js" },
|
||||
{ value: "sveltekit", label: "SvelteKit" },
|
||||
{ value: "nuxt", label: "Nuxt" },
|
||||
{ value: "remix", label: "Remix", disabled: true },
|
||||
{ value: "astro", label: "Astro" },
|
||||
]
|
||||
|
||||
const longOptions: ComboboxOption[] = [
|
||||
{ value: "a", label: "긴 옵션 이름은 한 줄에서 말줄임으로 잘리고 trigger 폭을 넘지 않습니다. 끝까지 읽을 수 있어야 하면 폭을 넓히세요" },
|
||||
{ value: "b", label: "짧은 옵션" },
|
||||
]
|
||||
|
||||
function Field({ label, id, children }: { label: string; id: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="grid max-w-xs gap-2">
|
||||
<label htmlFor={id} className="justify-self-start text-sm font-medium text-muted-foreground">
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Empty() {
|
||||
return (
|
||||
<Field label="프레임워크" id="cb-empty">
|
||||
<Combobox id="cb-empty" options={frameworks} placeholder="프레임워크 선택" searchPlaceholder="프레임워크 검색..." />
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
function Controlled() {
|
||||
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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Medium() {
|
||||
return (
|
||||
<Field label="Medium" id="cb-medium">
|
||||
<Combobox id="cb-medium" size="medium" options={frameworks} defaultValue="astro" />
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
function LongLabel() {
|
||||
return (
|
||||
<Field label="긴 옵션" id="cb-long">
|
||||
<Combobox id="cb-long" options={longOptions} defaultValue="a" />
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
function Disabled() {
|
||||
return (
|
||||
<Field label="비활성" id="cb-disabled">
|
||||
<Combobox id="cb-disabled" options={frameworks} defaultValue="next" disabled />
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectComparison() {
|
||||
return (
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
{(["medium", "large"] as const).map((size) => (
|
||||
<div key={size} className="grid content-start gap-3" data-comparison-size={size}>
|
||||
<Field label={`Select · ${size}`} id={`compare-select-${size}`}>
|
||||
<Select defaultValue="nuxt">
|
||||
<SelectTrigger id={`compare-select-${size}`} size={size} className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{frameworks.map((option) => <SelectItem key={option.value} value={option.value} disabled={option.disabled}>{option.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label={`Combobox · ${size}`} id={`compare-combobox-${size}`}>
|
||||
<Combobox id={`compare-combobox-${size}`} size={size} options={frameworks} defaultValue="nuxt" />
|
||||
</Field>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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: "long", label: "Long option", node: <LongLabel /> },
|
||||
{ id: "disabled", label: "Disabled", node: <Disabled /> },
|
||||
]
|
||||
|
||||
// No `component`: Combobox has a required `options` prop, so Stories without args would not type-check.
|
||||
const meta = { title: "Components/Search & Date/Combobox", parameters: { layout: "padded" } } satisfies Meta
|
||||
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 Story: the common Story page; the sections group the same specimens.
|
||||
export const All: Story = {
|
||||
name: "Combobox",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Combobox"
|
||||
description="검색해서 하나를 고르는 Popover + Command 조합입니다. 검색이 필요 없는 짧은 목록은 Select를 씁니다."
|
||||
/>
|
||||
<StorySection title="Overview">{board("empty")}</StorySection>
|
||||
<StorySection title="Sizes" description="size를 생략하면 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 /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="States">{board("controlled", "disabled")}</StorySection>
|
||||
<StorySection title="Usage">{board("long")}</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const EmptyCombobox: Story = { tags: ["verification"], name: "Empty", render: () => <Empty /> }
|
||||
export const ControlledCombobox: Story = { tags: ["verification"], name: "Controlled", render: () => <Controlled /> }
|
||||
export const LongOption: Story = { tags: ["verification"], name: "Long Option", render: () => <LongLabel /> }
|
||||
|
||||
export const OpenBottom: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => <div style={{ width: 280, maxWidth: "100%", minHeight: "calc(100dvh - 32px)" }}><Controlled /></div>,
|
||||
}
|
||||
export const OpenTop: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => <div style={{ width: 280, maxWidth: "100%", minHeight: "calc(100dvh - 32px)", display: "grid", alignItems: "end" }}><Controlled /></div>,
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useState, type ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { CalendarIcon, SettingsIcon, UserIcon } from "lucide-react"
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, CommandShortcut } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// The representative "Command" Story shows real, typeable lists; the same specimens also exist as Stories tagged `verification`
|
||||
// (hidden from the sidebar). Filtering is cmdk's. The bordered box is Story composition (Command draws no border of its own).
|
||||
// A selection log shows that Enter / click reach `onSelect`.
|
||||
|
||||
const box = (node: ReactNode) => <div className="w-full max-w-sm rounded-md border">{node}</div>
|
||||
|
||||
function Basic() {
|
||||
const [last, setLast] = useState("-")
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
{box(
|
||||
<Command>
|
||||
<CommandInput placeholder="검색..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>결과가 없습니다.</CommandEmpty>
|
||||
<CommandItem onSelect={setLast}>사과</CommandItem>
|
||||
<CommandItem onSelect={setLast}>바나나</CommandItem>
|
||||
<CommandItem onSelect={setLast} disabled>
|
||||
체리 (품절)
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={setLast}>포도</CommandItem>
|
||||
</CommandList>
|
||||
</Command>
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground" data-testid="last">{`선택: ${last}`}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Grouped() {
|
||||
return box(
|
||||
<Command>
|
||||
<CommandInput placeholder="명령 검색..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>결과가 없습니다.</CommandEmpty>
|
||||
<CommandGroup heading="제안">
|
||||
<CommandItem>
|
||||
<CalendarIcon />
|
||||
일정 보기
|
||||
</CommandItem>
|
||||
<CommandItem>
|
||||
<UserIcon />
|
||||
프로필
|
||||
<CommandShortcut>⌘P</CommandShortcut>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="설정">
|
||||
<CommandItem>
|
||||
<SettingsIcon />
|
||||
환경설정
|
||||
<CommandShortcut>⌘,</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem disabled>결제 (사용 불가)</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
)
|
||||
}
|
||||
|
||||
function Long() {
|
||||
return box(
|
||||
<Command>
|
||||
<CommandInput placeholder="항목 검색..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>결과가 없습니다.</CommandEmpty>
|
||||
{Array.from({ length: 30 }, (_, i) => (
|
||||
<CommandItem key={i}>{`항목 ${i + 1} — 긴 항목 이름도 줄바꿈 없이 한 줄로 이어질 수 있습니다`}</CommandItem>
|
||||
))}
|
||||
</CommandList>
|
||||
</Command>
|
||||
)
|
||||
}
|
||||
|
||||
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 /> },
|
||||
]
|
||||
|
||||
const meta = { title: "Components/Search & Date/Command", component: Command, parameters: { layout: "padded" } } satisfies Meta<typeof Command>
|
||||
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 Story: the common Story page; the sections group the same specimens.
|
||||
export const All: Story = {
|
||||
name: "Command",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Command"
|
||||
description="입력한 검색어로 항목을 걸러 고르는 목록입니다. Combobox와 명령 팔레트의 기반입니다."
|
||||
/>
|
||||
<StorySection title="Overview">{board("basic")}</StorySection>
|
||||
<StorySection title="Variants">{board("grouped")}</StorySection>
|
||||
<StorySection title="Usage">{board("long")}</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const Search: Story = { tags: ["verification"], render: () => <Basic /> }
|
||||
export const GroupedList: Story = { tags: ["verification"], name: "Grouped", render: () => <Grouped /> }
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useState } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuContent,
|
||||
ContextMenuGroup,
|
||||
ContextMenuItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuRadioGroup,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuTrigger,
|
||||
} from "@hm-design/ui"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Context Menu opens from pointer context (right click, the context-menu key / Shift+F10, long press) on its trigger area.
|
||||
// The representative Story is a target area: right click it. Radix owns keyboard navigation, focus return and Escape.
|
||||
|
||||
function Full() {
|
||||
const [showStatus, setShowStatus] = useState(true)
|
||||
const [showPanel, setShowPanel] = useState(false)
|
||||
const [position, setPosition] = useState("bottom")
|
||||
return (
|
||||
<ContextMenu>
|
||||
{/* tabIndex lets keyboard users focus the area and open the menu with Shift+F10 / the context-menu key; Radix Trigger is a plain span. */}
|
||||
<ContextMenuTrigger tabIndex={0} className="flex h-40 w-80 items-center justify-center rounded-md border border-dashed text-sm text-muted-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring">
|
||||
이 영역에서 오른쪽 클릭
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-56">
|
||||
<ContextMenuLabel>파일</ContextMenuLabel>
|
||||
<ContextMenuGroup>
|
||||
<ContextMenuItem>
|
||||
열기 <ContextMenuShortcut>⌘O</ContextMenuShortcut>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem>
|
||||
이름 바꾸기 <ContextMenuShortcut>F2</ContextMenuShortcut>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled>붙여넣기 (사용 불가)</ContextMenuItem>
|
||||
</ContextMenuGroup>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuLabel>보기</ContextMenuLabel>
|
||||
<ContextMenuCheckboxItem checked={showStatus} onCheckedChange={setShowStatus}>
|
||||
상태 표시줄
|
||||
</ContextMenuCheckboxItem>
|
||||
<ContextMenuCheckboxItem checked={showPanel} onCheckedChange={setShowPanel}>
|
||||
패널
|
||||
</ContextMenuCheckboxItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuLabel>위치</ContextMenuLabel>
|
||||
<ContextMenuRadioGroup value={position} onValueChange={setPosition}>
|
||||
<ContextMenuRadioItem value="top">상단</ContextMenuRadioItem>
|
||||
<ContextMenuRadioItem value="bottom">하단</ContextMenuRadioItem>
|
||||
<ContextMenuRadioItem value="right">오른쪽</ContextMenuRadioItem>
|
||||
</ContextMenuRadioGroup>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>더 보기</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent>
|
||||
<ContextMenuItem>이메일로 공유</ContextMenuItem>
|
||||
<ContextMenuItem>링크 복사</ContextMenuItem>
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem variant="destructive">삭제</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = { title: "Components/Overlay/Context Menu", component: ContextMenu, parameters: { layout: "centered" } } satisfies Meta<typeof ContextMenu>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Representative Story: right click the area to open the full menu (item, disabled, separator, shortcut, checkbox, radio group, sub menu, destructive).
|
||||
export const All: Story = {
|
||||
name: "Context Menu",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Context Menu"
|
||||
description="영역에서 오른쪽 클릭, 컨텍스트 메뉴 키 / Shift+F10, 길게 누르기로 여는 메뉴입니다."
|
||||
/>
|
||||
<StorySection title="Overview" description="항목 · Disabled · 단축키 · Checkbox · Radio Group · 하위 메뉴 · 위험 항목을 한 메뉴에서 확인합니다. 점선 영역에서 오른쪽 클릭합니다.">
|
||||
<Full />
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
// Verification Story: opens the menu at the area center on load (synthetic contextmenu event), hidden from the sidebar (tag `verification`).
|
||||
export const Open: Story = {
|
||||
tags: ["verification"],
|
||||
parameters: { layout: "fullscreen" },
|
||||
render: () => (
|
||||
<div className="flex min-h-[560px] justify-center p-8">
|
||||
<Full />
|
||||
</div>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
const area = canvasElement.querySelector<HTMLElement>("[data-slot='context-menu-trigger']")
|
||||
if (!area) return
|
||||
const r = area.getBoundingClientRect()
|
||||
area.dispatchEvent(
|
||||
new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: r.left + r.width / 2, clientY: r.top + r.height / 2, button: 2 }),
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useState, type ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { DatePicker } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// DatePicker is a Popover + Calendar composition packaged as a component (value handling, trigger text, close on select).
|
||||
// The representative Story shows real pickers; the same specimens also exist as Stories tagged `verification` (hidden from the
|
||||
// sidebar). The Label is Story composition (the way a consumer would); `today` is pinned through Calendar's own prop so the
|
||||
// Story looks the same on any day.
|
||||
|
||||
const today = new Date(2026, 9, 2)
|
||||
const calendarProps = { today, startMonth: new Date(2026, 0), endMonth: new Date(2027, 11) }
|
||||
|
||||
function Field({ label, id, children }: { label: string; id: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="grid max-w-xs gap-2">
|
||||
<label htmlFor={id} className="justify-self-start text-sm font-medium text-muted-foreground">
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Empty() {
|
||||
return (
|
||||
<Field label="시작일" id="dp-empty">
|
||||
<DatePicker id="dp-empty" calendarProps={calendarProps} />
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
function Selected() {
|
||||
return (
|
||||
<Field label="시작일" id="dp-selected">
|
||||
<DatePicker id="dp-selected" defaultValue={new Date(2026, 9, 6)} calendarProps={calendarProps} />
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
function FormatExamples() {
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<Field label="기본 형식 · YYYY.MM.DD" id="dp-padded">
|
||||
<DatePicker id="dp-padded" defaultValue={new Date(2026, 0, 9)} calendarProps={calendarProps} />
|
||||
</Field>
|
||||
<Field label="Custom format override" id="dp-custom">
|
||||
<DatePicker id="dp-custom" defaultValue={new Date(2026, 9, 6)} format={(date) => date.toLocaleDateString("ko-KR")} calendarProps={calendarProps} />
|
||||
</Field>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Controlled() {
|
||||
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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Medium() {
|
||||
return (
|
||||
<Field label="Medium" id="dp-medium">
|
||||
<DatePicker id="dp-medium" size="medium" defaultValue={new Date(2026, 9, 15)} calendarProps={calendarProps} />
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
function DisabledDates() {
|
||||
return (
|
||||
<Field label="5일 이전과 일요일은 선택 불가" id="dp-disabled-dates">
|
||||
<DatePicker
|
||||
id="dp-disabled-dates"
|
||||
calendarProps={{ ...calendarProps, disabled: [{ before: new Date(2026, 9, 5) }, { dayOfWeek: [0] }] }}
|
||||
/>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
function Disabled() {
|
||||
return (
|
||||
<Field label="비활성" id="dp-disabled">
|
||||
<DatePicker id="dp-disabled" disabled defaultValue={new Date(2026, 9, 15)} />
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
const sections = [
|
||||
{ id: "empty", label: "Empty (placeholder)", 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: "disabled-dates", label: "Disabled dates", node: <DisabledDates /> },
|
||||
{ id: "disabled", label: "Disabled", node: <Disabled /> },
|
||||
]
|
||||
|
||||
const meta = { title: "Components/Search & Date/Date Picker", component: DatePicker, parameters: { layout: "padded" } } satisfies Meta<typeof DatePicker>
|
||||
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 Story: the common Story page; the sections group the same specimens.
|
||||
export const All: Story = {
|
||||
name: "Date Picker",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Date Picker"
|
||||
description="Trigger를 눌러 달력에서 날짜를 고르는 Popover + Calendar 조합 컴포넌트입니다. 날짜를 고르면 닫힙니다."
|
||||
/>
|
||||
<StorySection title="Overview">{board("empty")}</StorySection>
|
||||
<StorySection title="Sizes" description="size를 생략하면 Large입니다.">{board("medium")}</StorySection>
|
||||
<StorySection title="States">{board("selected", "controlled", "disabled-dates", "disabled")}</StorySection>
|
||||
<StorySection title="Usage" description="기본 표시 형식과 사용자 형식 지정입니다.">{board("format")}</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const EmptyPicker: Story = { tags: ["verification"], name: "Empty", render: () => <Empty /> }
|
||||
export const InvalidFocus: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => (
|
||||
<div className="grid max-w-xs gap-6">
|
||||
<button type="button">Before field</button>
|
||||
<Field label="Invalid Date Picker" id="dp-invalid">
|
||||
<DatePicker id="dp-invalid" aria-invalid defaultValue={new Date(2026, 9, 6)} calendarProps={calendarProps} />
|
||||
</Field>
|
||||
<button type="button">After field</button>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
export const SelectedPicker: Story = { tags: ["verification"], name: "Selected", render: () => <Selected /> }
|
||||
export const ControlledPicker: Story = { tags: ["verification"], name: "Controlled", render: () => <Controlled /> }
|
||||
@@ -0,0 +1,212 @@
|
||||
import type { CSSProperties, ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Button } from "@hm-design/ui"
|
||||
import { Dialog, DialogBody, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@hm-design/ui"
|
||||
import { Input } from "@hm-design/ui"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@hm-design/ui"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Dialog is an overlay (Radix Portal), so it does not fit the StateBoard. The representative Story is a row of triggers that open
|
||||
// the real Dialog for each ui_viz case (dialog.md §9 Reference Viewer examples); the same cases also exist as separate Stories tagged
|
||||
// `verification` that start open. Pseudo-state helpers are not used to fake an open Dialog.
|
||||
|
||||
const longText = "긴 텍스트는 줄바꿈으로 전체를 표시합니다. 공백 없는 긴 문자열도 Dialog 너비를 밀어내지 않고 줄바꿈합니다. ".repeat(8)
|
||||
|
||||
function Frame({
|
||||
trigger,
|
||||
defaultOpen,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
actions,
|
||||
contentStyle,
|
||||
}: {
|
||||
trigger: string
|
||||
defaultOpen?: boolean
|
||||
title: string
|
||||
description?: string
|
||||
children: ReactNode
|
||||
actions?: ReactNode
|
||||
contentStyle?: CSSProperties
|
||||
}) {
|
||||
return (
|
||||
<Dialog defaultOpen={defaultOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="primary" appearance="outline">
|
||||
{trigger}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent style={contentStyle}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
{description ? <DialogDescription>{description}</DialogDescription> : null}
|
||||
</DialogHeader>
|
||||
<DialogBody>{children}</DialogBody>
|
||||
<DialogFooter>{actions}</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
const confirmActions = (
|
||||
<>
|
||||
<DialogClose asChild>
|
||||
<Button variant="cancel" appearance="solid">
|
||||
취소
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<DialogClose asChild>
|
||||
<Button variant="primary" appearance="solid">
|
||||
확인
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</>
|
||||
)
|
||||
|
||||
const deleteActions = (
|
||||
<>
|
||||
<DialogClose asChild>
|
||||
<Button variant="cancel" appearance="outline">
|
||||
취소
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<DialogClose asChild>
|
||||
<Button variant="destructive" appearance="outline">
|
||||
삭제
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</>
|
||||
)
|
||||
|
||||
function Basic({ defaultOpen }: { defaultOpen?: boolean }) {
|
||||
return (
|
||||
<Frame trigger="기본" defaultOpen={defaultOpen} title="작업 확인" description="현재 작업을 계속 진행하시겠습니까?" actions={confirmActions}>
|
||||
<p>짧은 내용의 Dialog는 내용 기준 크기입니다.</p>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
function LongContent({ defaultOpen }: { defaultOpen?: boolean }) {
|
||||
return (
|
||||
<Frame trigger="긴 내용" defaultOpen={defaultOpen} title="긴 내용" description="Body만 세로로 scroll 합니다." actions={confirmActions}>
|
||||
{Array.from({ length: 6 }, (_, i) => (
|
||||
<p key={i} className="mb-4">
|
||||
{longText}
|
||||
</p>
|
||||
))}
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
// ui_viz "세로 스크롤 · 본문만": the max height also leaves out the app common Header 70px / Footer 48px. The Dialog renders in a Portal,
|
||||
// so the app heights are passed on DialogContent as Dialog Tokens, not on a wrapper around the trigger.
|
||||
const appBars = { "--hm-dialog-app-header-height": "70px", "--hm-dialog-app-footer-height": "48px" } as CSSProperties
|
||||
|
||||
function BodyScroll({ defaultOpen }: { defaultOpen?: boolean }) {
|
||||
return (
|
||||
<Frame
|
||||
trigger="본문 스크롤"
|
||||
defaultOpen={defaultOpen}
|
||||
title="본문 스크롤"
|
||||
description="공통 Header 70px / Footer 48px를 뺀 높이 안에서 Body만 세로로 scroll 합니다."
|
||||
actions={confirmActions}
|
||||
contentStyle={appBars}
|
||||
>
|
||||
{Array.from({ length: 60 }, (_, i) => (
|
||||
<p key={i}>검토 기록 {i + 1}. 현장 조사와 부재별 검토 내용을 확인하고 후속 조치를 기록합니다.</p>
|
||||
))}
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
// The wide case composes the HM Table primitive (same as the Table Story), so it carries the Table look inside the Dialog Portal.
|
||||
const wideColumns = Array.from({ length: 10 }, (_, i) => `컬럼 ${i + 1}`)
|
||||
|
||||
function WideContent({ defaultOpen }: { defaultOpen?: boolean }) {
|
||||
return (
|
||||
<Frame trigger="넓은 표" defaultOpen={defaultOpen} title="넓은 내용" description="표처럼 줄이기 어려운 내용은 Body 안에서 가로 scroll 합니다." actions={confirmActions}>
|
||||
<Table style={{ minWidth: 1600 }}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{wideColumns.map((c) => (
|
||||
<TableHead key={c}>{c}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{Array.from({ length: 4 }, (_, r) => (
|
||||
<TableRow key={r}>
|
||||
{wideColumns.map((_, c) => (
|
||||
<TableCell key={c}>{`행 ${r + 1} · 열 ${c + 1}`}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
function DeleteConfirmation({ defaultOpen }: { defaultOpen?: boolean }) {
|
||||
return (
|
||||
<Frame trigger="삭제 확인" defaultOpen={defaultOpen} title="항목 삭제" description="삭제하면 되돌릴 수 없습니다." actions={deleteActions}>
|
||||
<p>선택한 항목을 삭제합니다. 취소와 삭제 실행은 같은 크기의 Outline 세트입니다.</p>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
function FormContent({ defaultOpen }: { defaultOpen?: boolean }) {
|
||||
return (
|
||||
<Frame trigger="폼" defaultOpen={defaultOpen} title="프로젝트 정보" actions={confirmActions}>
|
||||
<div className="grid gap-2">
|
||||
<label htmlFor="dialog-form-name" className="text-sm font-medium text-muted-foreground">
|
||||
프로젝트명
|
||||
</label>
|
||||
<Input id="dialog-form-name" size="medium" placeholder="프로젝트명을 입력하세요" />
|
||||
</div>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = { title: "Components/Overlay/Dialog", component: Dialog, parameters: { layout: "centered" } } satisfies Meta<typeof Dialog>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Representative Story: the common Story page; every case opens from its trigger. Named after the component, as in ui_viz.
|
||||
// It is the only Dialog entry in the default sidebar, so Storybook shows Dialog as a single 1-depth item.
|
||||
export const All: Story = {
|
||||
name: "Dialog",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Dialog"
|
||||
description="현재 화면 위에 내용을 띄워 확인·입력을 받는 모달입니다. 각 버튼을 눌러 실제 Dialog를 엽니다."
|
||||
/>
|
||||
<StorySection title="Overview" description="짧은 내용의 Dialog는 내용 기준 크기입니다.">
|
||||
<Basic />
|
||||
</StorySection>
|
||||
<StorySection title="Variants" description="내용이 길거나 넓으면 Body 안에서만 scroll 합니다. 본문 스크롤은 공통 Header 70px / Footer 48px를 뺀 높이를 씁니다.">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<LongContent />
|
||||
<WideContent />
|
||||
<BodyScroll />
|
||||
</div>
|
||||
</StorySection>
|
||||
<StorySection title="Usage" description="일반 확인은 Solid 세트, 삭제 확인은 취소·삭제 모두 Outline 세트입니다.">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<DeleteConfirmation />
|
||||
<FormContent />
|
||||
</div>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
// Verification Stories: the same cases starting open, hidden from the sidebar (tag `verification`).
|
||||
export const Open: Story = { tags: ["verification"], render: () => <Basic defaultOpen /> }
|
||||
export const LongContentOpen: Story = { tags: ["verification"], name: "Long Content", render: () => <LongContent defaultOpen /> }
|
||||
export const WideContentOpen: Story = { tags: ["verification"], name: "Wide Content", render: () => <WideContent defaultOpen /> }
|
||||
export const BodyScrollOpen: Story = { tags: ["verification"], name: "Body Scroll", render: () => <BodyScroll defaultOpen /> }
|
||||
export const DeleteConfirmationOpen: Story = { tags: ["verification"], name: "Delete Confirmation", render: () => <DeleteConfirmation defaultOpen /> }
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Button, Drawer, DrawerBody, DrawerClose, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle, DrawerTrigger, Input } from "@hm-design/ui"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Drawer is a modal vaul overlay (Portal). The representative Story opens the real Drawer from its triggers; drag the handle or
|
||||
// the panel down to dismiss. The same cases start open in the verification Stories. vaul owns the drag, Radix the focus and ARIA.
|
||||
|
||||
const longText = "긴 내용은 Body 영역만 세로로 scroll 하고 Header와 Footer는 그대로 보입니다. "
|
||||
|
||||
function Frame({
|
||||
trigger,
|
||||
defaultOpen,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
actions,
|
||||
}: {
|
||||
trigger: string
|
||||
defaultOpen?: boolean
|
||||
title: string
|
||||
description?: string
|
||||
children: React.ReactNode
|
||||
actions?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Drawer defaultOpen={defaultOpen}>
|
||||
<DrawerTrigger asChild>
|
||||
<Button variant="primary" appearance="outline">
|
||||
{trigger}
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>{title}</DrawerTitle>
|
||||
{description ? <DrawerDescription>{description}</DrawerDescription> : null}
|
||||
</DrawerHeader>
|
||||
<DrawerBody>{children}</DrawerBody>
|
||||
<DrawerFooter>{actions}</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
const actions = (
|
||||
<>
|
||||
<DrawerClose asChild>
|
||||
<Button variant="primary" appearance="solid">
|
||||
확인
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
<DrawerClose asChild>
|
||||
<Button variant="cancel" appearance="solid">
|
||||
취소
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
</>
|
||||
)
|
||||
|
||||
function Basic({ defaultOpen }: { defaultOpen?: boolean }) {
|
||||
return (
|
||||
<Frame trigger="기본" defaultOpen={defaultOpen} title="작업 선택" description="아래에서 올라오는 패널입니다. 핸들을 아래로 끌어 닫을 수 있습니다." actions={actions}>
|
||||
<p>짧은 내용은 내용 높이만큼만 차지합니다.</p>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
function FormCase({ defaultOpen }: { defaultOpen?: boolean }) {
|
||||
return (
|
||||
<Frame trigger="폼" defaultOpen={defaultOpen} title="프로젝트 정보" actions={actions}>
|
||||
<div className="grid gap-2">
|
||||
<label htmlFor="drawer-form-name" className="text-sm font-medium text-muted-foreground">
|
||||
프로젝트명
|
||||
</label>
|
||||
<Input id="drawer-form-name" size="medium" placeholder="프로젝트명을 입력하세요" />
|
||||
</div>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
function LongCase({ defaultOpen }: { defaultOpen?: boolean }) {
|
||||
return (
|
||||
<Frame trigger="긴 내용" defaultOpen={defaultOpen} title="긴 내용" description="Body만 scroll 합니다." actions={actions}>
|
||||
{Array.from({ length: 30 }, (_, i) => (
|
||||
<p key={i} className="mb-3">
|
||||
{longText}
|
||||
</p>
|
||||
))}
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = { title: "Components/Overlay/Drawer", component: Drawer, parameters: { layout: "centered" } } satisfies Meta<typeof Drawer>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const All: Story = {
|
||||
name: "Drawer",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Drawer"
|
||||
description="화면 아래에서 올라오는 모달 패널입니다. 핸들이나 패널을 아래로 끌어 닫습니다."
|
||||
/>
|
||||
<StorySection title="Overview" description="기본 Drawer입니다. 버튼을 눌러 엽니다.">
|
||||
<Basic />
|
||||
</StorySection>
|
||||
<StorySection title="Usage" description="폼 입력 조합과, 내용이 길 때 Body만 scroll 하는 경우입니다.">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<FormCase />
|
||||
<LongCase />
|
||||
</div>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const Open: Story = { tags: ["verification"], render: () => <Basic defaultOpen /> }
|
||||
export const Form: Story = { tags: ["verification"], render: () => <FormCase defaultOpen /> }
|
||||
export const LongContent: Story = { tags: ["verification"], name: "Long Content", render: () => <LongCase defaultOpen /> }
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useState } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@hm-design/ui"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Dropdown Menu is a Radix overlay (Portal), so the representative Story is a trigger that opens the real menu, the same way as
|
||||
// Dialog. The verification Story starts open. Radix owns keyboard navigation, focus return and Escape.
|
||||
|
||||
function Full({ defaultOpen }: { defaultOpen?: boolean }) {
|
||||
const [showStatus, setShowStatus] = useState(true)
|
||||
const [showPanel, setShowPanel] = useState(false)
|
||||
const [position, setPosition] = useState("bottom")
|
||||
return (
|
||||
<DropdownMenu defaultOpen={defaultOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="primary" appearance="outline">
|
||||
메뉴 열기
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" align="start">
|
||||
<DropdownMenuLabel>내 계정</DropdownMenuLabel>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
프로필 <DropdownMenuShortcut>⇧⌘P</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
설정 <DropdownMenuShortcut>⌘,</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled>결제 (사용 불가)</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>보기</DropdownMenuLabel>
|
||||
<DropdownMenuCheckboxItem checked={showStatus} onCheckedChange={setShowStatus}>
|
||||
상태 표시줄
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem checked={showPanel} onCheckedChange={setShowPanel}>
|
||||
패널
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>위치</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup value={position} onValueChange={setPosition}>
|
||||
<DropdownMenuRadioItem value="top">상단</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="bottom">하단</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="right">오른쪽</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>더 보기</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem>이메일로 공유</DropdownMenuItem>
|
||||
<DropdownMenuItem>링크 복사</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive">로그아웃</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = { title: "Components/Overlay/Dropdown Menu", component: DropdownMenu, parameters: { layout: "centered" } } satisfies Meta<typeof DropdownMenu>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Representative Story: the trigger opens the full menu (item, disabled, separator, checkbox, radio group, sub menu, destructive).
|
||||
export const All: Story = {
|
||||
name: "Dropdown Menu",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Dropdown Menu"
|
||||
description="버튼으로 여는 작업 메뉴입니다. 키보드 이동, Esc 닫기, 포커스 복귀는 Radix가 담당합니다."
|
||||
/>
|
||||
<StorySection title="Overview" description="항목 · Disabled · 구분선 · Checkbox · Radio Group · 하위 메뉴 · 위험 항목을 한 메뉴에서 확인합니다. 버튼을 눌러 엽니다.">
|
||||
<Full />
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
// Verification Story: the same menu starting open, hidden from the sidebar (tag `verification`).
|
||||
export const Open: Story = {
|
||||
tags: ["verification"],
|
||||
parameters: { layout: "fullscreen" },
|
||||
render: () => (
|
||||
<div className="flex min-h-[560px] justify-center p-8">
|
||||
<Full defaultOpen />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@hm-design/ui"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Hover Card is a Radix overlay (Portal) for a richer preview than Tooltip. The representative Story shows real triggers
|
||||
// (hover or focus to open); the verification Story starts one open. Radix owns the open / close delay and the hover bridge.
|
||||
|
||||
const longText = "긴 콘텐츠는 카드 폭에서 줄바꿈되고 카드 높이만 늘어납니다. 공백 없는 긴 문자열도 폭을 밀어내지 않습니다. ".repeat(4)
|
||||
|
||||
function Preview({ defaultOpen }: { defaultOpen?: boolean }) {
|
||||
return (
|
||||
<HoverCard defaultOpen={defaultOpen}>
|
||||
<HoverCardTrigger asChild>
|
||||
<a href="#preview" className="text-sm font-medium text-primary underline underline-offset-4">
|
||||
한맥 프로젝트
|
||||
</a>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent>
|
||||
<div className="grid gap-1">
|
||||
<p className="font-bold">한맥 프로젝트</p>
|
||||
<p>노선과 시설물 정보를 관리하는 프로젝트입니다.</p>
|
||||
<p className="pt-1 text-xs text-muted-foreground">담당 김한맥 · 최근 수정 2026-10-02</p>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)
|
||||
}
|
||||
|
||||
function LongContent() {
|
||||
return (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<a href="#long" className="text-sm font-medium text-primary underline underline-offset-4">
|
||||
긴 콘텐츠
|
||||
</a>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent>
|
||||
<div className="grid gap-1">
|
||||
<p className="font-bold">긴 콘텐츠</p>
|
||||
<p>{longText}</p>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = { title: "Components/Overlay/Hover Card", component: HoverCard, parameters: { layout: "centered" } } satisfies Meta<typeof HoverCard>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const All: Story = {
|
||||
name: "Hover Card",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Hover Card"
|
||||
description="링크에 포인터를 올리거나 포커스하면 Tooltip보다 풍부한 미리보기를 띄우는 overlay입니다."
|
||||
/>
|
||||
<StorySection title="Overview" description="링크 위에 포인터를 올려 미리보기를 엽니다.">
|
||||
<Preview />
|
||||
</StorySection>
|
||||
<StorySection title="Usage" description="긴 콘텐츠는 카드 폭에서 줄바꿈되고 높이만 늘어납니다.">
|
||||
<LongContent />
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const Open: Story = {
|
||||
tags: ["verification"],
|
||||
parameters: { layout: "fullscreen" },
|
||||
render: () => (
|
||||
<div className="flex min-h-[260px] justify-center p-8">
|
||||
<Preview defaultOpen />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { useId, type ComponentProps, type ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Input, type InputSize } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Story structure follows ui_viz (implementations/web/storybook/Input.stories.jsx and InputSizes.stories.jsx):
|
||||
// one representative "Input" Story shows the sizes and every state on one page; the same specimens also exist as
|
||||
// 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.
|
||||
|
||||
function Specimen({
|
||||
label = "Project name",
|
||||
message,
|
||||
error,
|
||||
required,
|
||||
id,
|
||||
...input
|
||||
}: ComponentProps<typeof Input> & { label?: string; message?: string; error?: boolean }) {
|
||||
const uid = useId()
|
||||
const fieldId = id ?? `input-${uid}`
|
||||
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}
|
||||
{required ? (
|
||||
<span aria-hidden="true" className="text-destructive">
|
||||
{" *"}
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
<Input
|
||||
id={fieldId}
|
||||
required={required}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={message ? messageId : undefined}
|
||||
{...input}
|
||||
/>
|
||||
{message ? (
|
||||
<p id={messageId} className={error ? "text-[11px] leading-[18px] text-destructive" : "text-xs leading-[18px] text-muted-foreground"}>
|
||||
{message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const narrow = (children: ReactNode) => <div className="max-w-sm">{children}</div>
|
||||
const placeholder = "Enter a project name"
|
||||
|
||||
// ui_viz docs/components/input.md §9, §24: display metadata only, never sets control geometry.
|
||||
const sizes: { size: InputSize; name: string; height: number; padding: number; typography: string; usage: string }[] = [
|
||||
{ size: "small", name: "Small", height: 28, padding: 10, typography: "12 / 18 / 400", usage: "Small control" },
|
||||
{ size: "medium", name: "Medium", height: 34, padding: 12, typography: "14 / 20 / 400", usage: "Business forms / Search" },
|
||||
{ size: "large", name: "Large", height: 42, padding: 12, typography: "16 / 24 / 400", usage: "Component default" },
|
||||
]
|
||||
|
||||
function SizeCard({ spec }: { spec: (typeof sizes)[number] }) {
|
||||
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>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function SizeComparison() {
|
||||
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} />
|
||||
))}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
높이는 입력 컨트롤의 외곽 높이입니다. 값·Placeholder 글자는 size를 따르고 Label 14/20/500은 공통입니다. 업무 폼·조회 조합은 Medium을 쓰며, size를 생략하면 Large입니다.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const readonlyNote =
|
||||
"Readonly는 입력 상자 없이 확정된 텍스트만 표시합니다. size와 무관하게 Large 기준 typography와 42px 정렬 높이를 유지하며, Disabled처럼 흐리게 표시하지 않습니다."
|
||||
|
||||
const meta = {
|
||||
title: "Components/Form/Input",
|
||||
component: Input,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"ui_viz docs/components/input.md: Small 28 / Medium 34 / Large 42px, size를 생략하면 Large. Focus와 Error는 1.5px 테두리로 구분하며 shadow·glow를 쓰지 않습니다. Hover는 없습니다. Label·Helper·Error 문구는 Input이 아니라 소비 조합입니다. Disabled는 흰 면을 유지하고 opacity 0.6으로 흐립니다(2026-10-02 사용자 결정, Input·Select 한정).",
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
size: {
|
||||
control: "inline-radio",
|
||||
options: ["small", "medium", "large"],
|
||||
description: "무지정 기본값 Large. 업무 폼·조회 소비 역할은 Medium.",
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Input>
|
||||
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.
|
||||
// Named after the component, as in ui_viz.
|
||||
export const All: Story = {
|
||||
name: "Input",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Input"
|
||||
description="한 줄 텍스트를 입력하는 컨트롤입니다. Label·Helper·Error 문구는 Input이 아니라 소비 조합입니다."
|
||||
/>
|
||||
<StorySection title="Overview" description="size를 생략한 기본 Input: Large 42px.">
|
||||
<StateBoard states={[{ id: "default", label: "Default · size 생략(Large)", children: <Specimen placeholder={placeholder} /> }]} />
|
||||
</StorySection>
|
||||
<StorySection title="Sizes" description="Small 28 / Medium 34 / Large 42px. 업무 폼·조회 조합은 Medium을 씁니다.">
|
||||
<SizeComparison />
|
||||
</StorySection>
|
||||
<StorySection title="States" description="Focus와 Error는 1.5px 테두리로 구분하며 Hover는 없습니다. Disabled는 흰 면을 유지하고 흐리게 표시합니다.">
|
||||
<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" /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Validation" description="aria-invalid로 Error 테두리를 표시하고, 오류 문구는 소비 조합으로 연결합니다.">
|
||||
<StateBoard
|
||||
states={[
|
||||
{ id: "error", label: "Error", children: <Specimen size="medium" error defaultValue="Invalid value" /> },
|
||||
{ id: "error-message", label: "Error Message", children: <Specimen size="medium" placeholder={placeholder} error message="프로젝트명을 입력하세요." /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
// preventScroll: the page opens at its header, not at the States section.
|
||||
canvasElement.querySelector<HTMLInputElement>('[data-overview-state="focus"] input')?.focus({ preventScroll: true })
|
||||
},
|
||||
}
|
||||
|
||||
// Verification Stories: the same specimens as separate Stories, hidden from the sidebar (tag `verification`), reachable by URL or the sidebar tag filter.
|
||||
export const Default: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => narrow(<Specimen placeholder={placeholder} />),
|
||||
parameters: { docs: { description: { story: "size를 생략한 실제 기본값(Large 42px)입니다. 업무 폼·조회 역할의 Medium 34px과 구분합니다." } } },
|
||||
}
|
||||
export const Focus: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => narrow(<Specimen size="medium" placeholder={placeholder} />),
|
||||
play: async ({ canvasElement }) => canvasElement.querySelector("input")?.focus(),
|
||||
parameters: { docs: { description: { story: "Focus는 실제로 focus를 받은 컨트롤에서만 보입니다. play 함수가 focus를 줍니다. Tab 또는 클릭으로도 확인합니다." } } },
|
||||
}
|
||||
export const Error: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => narrow(<Specimen size="medium" error defaultValue="Invalid value" />),
|
||||
}
|
||||
export const InvalidFocus: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => narrow(<Specimen size="medium" error defaultValue="Invalid value" />),
|
||||
play: async ({ canvasElement }) => canvasElement.querySelector("input")?.focus(),
|
||||
}
|
||||
export const ErrorMessage: Story = {
|
||||
tags: ["verification"],
|
||||
name: "Error Message",
|
||||
render: () => narrow(<Specimen size="medium" placeholder={placeholder} error message="프로젝트명을 입력하세요." />),
|
||||
}
|
||||
export const Required: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => narrow(<Specimen size="medium" placeholder={placeholder} required />),
|
||||
}
|
||||
export const Disabled: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => narrow(<Specimen disabled defaultValue="Unavailable project" />),
|
||||
}
|
||||
export const Readonly: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => narrow(<Specimen readOnly value="Confirmed project" />),
|
||||
parameters: { docs: { description: { story: readonlyNote } } },
|
||||
}
|
||||
export const Sizes: Story = { tags: ["verification"], name: "Compare Sizes", render: () => <SizeComparison /> }
|
||||
export const Small: Story = { tags: ["verification"], render: () => narrow(<SizeCard spec={sizes[0]} />) }
|
||||
export const Medium: Story = { tags: ["verification"], render: () => narrow(<SizeCard spec={sizes[1]} />) }
|
||||
export const Large: Story = { tags: ["verification"], render: () => narrow(<SizeCard spec={sizes[2]} />) }
|
||||
|
||||
// HM only: not part of the ui_viz Input Stories. Tagged verification so the default sidebar keeps only the representative Story.
|
||||
export const Playground: Story = {
|
||||
tags: ["verification"],
|
||||
args: { size: "medium", type: "text", placeholder, disabled: false, readOnly: false },
|
||||
argTypes: {
|
||||
type: { control: "select", options: ["text", "email", "password", "search", "number", "tel", "url"] },
|
||||
placeholder: { control: "text" },
|
||||
disabled: { control: "boolean" },
|
||||
readOnly: { control: "boolean" },
|
||||
"aria-invalid": { control: "boolean" },
|
||||
className: { table: { disable: true } },
|
||||
},
|
||||
render: (args) => narrow(<Specimen {...args} />),
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Input, Label } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, 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).
|
||||
|
||||
const meta = {
|
||||
title: "Components/Form/Label",
|
||||
component: Label,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Field Label 14/20/500, 색 color.fg.secondary (ui_viz input.md §9, §10). Radix Label의 htmlFor·클릭 시 컨트롤 focus 동작을 유지합니다. Required 별표·문구는 Label이 아닌 소비 조합입니다.",
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Label>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const All: Story = {
|
||||
name: "Label",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Label"
|
||||
description="입력 컨트롤의 이름입니다. Field Label 14/20/500을 쓰며, htmlFor로 연결하면 라벨 클릭 시 컨트롤로 focus가 이동합니다."
|
||||
/>
|
||||
<StorySection title="Overview">
|
||||
<StateBoard
|
||||
states={[
|
||||
{
|
||||
id: "default",
|
||||
label: "Default · htmlFor",
|
||||
noteAfter: true,
|
||||
note: "라벨을 클릭하면 연결된 입력으로 focus가 이동합니다.",
|
||||
children: (
|
||||
<div className="grid max-w-sm gap-2">
|
||||
<Label htmlFor="label-default">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 (peer-disabled, shadcn 기본)",
|
||||
noteAfter: true,
|
||||
note: "Disabled 연동은 ui_viz Label에 정의가 없어 shadcn 기본(peer-disabled)을 유지합니다.",
|
||||
children: (
|
||||
<div className="grid max-w-sm gap-2">
|
||||
<Input id="label-disabled" size="medium" disabled defaultValue="Unavailable" className="peer order-2" />
|
||||
<Label htmlFor="label-disabled" className="order-1">
|
||||
Project name
|
||||
</Label>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Usage" description="Required 별표는 Label이 아닌 소비 조합입니다. 긴 라벨은 줄바꿈됩니다.">
|
||||
<StateBoard
|
||||
states={[
|
||||
{
|
||||
id: "required",
|
||||
label: "Required (소비 조합)",
|
||||
children: (
|
||||
<div className="grid max-w-sm gap-2">
|
||||
<Label htmlFor="label-required">
|
||||
Project name
|
||||
<span aria-hidden="true" className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input id="label-required" size="medium" required />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "long",
|
||||
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>
|
||||
<Input id="label-long" size="medium" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useState } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import {
|
||||
Menubar,
|
||||
MenubarCheckboxItem,
|
||||
MenubarContent,
|
||||
MenubarGroup,
|
||||
MenubarItem,
|
||||
MenubarLabel,
|
||||
MenubarMenu,
|
||||
MenubarRadioGroup,
|
||||
MenubarRadioItem,
|
||||
MenubarSeparator,
|
||||
MenubarShortcut,
|
||||
MenubarSub,
|
||||
MenubarSubContent,
|
||||
MenubarSubTrigger,
|
||||
MenubarTrigger,
|
||||
} from "@hm-design/ui"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Menubar is a desktop menu bar (Left / Right between top menus). The representative Story is the real bar and opens the real
|
||||
// menus; Radix owns the keyboard model and focus. Hidden verification Stories isolate trigger focus and open + focus.
|
||||
|
||||
function Specimen() {
|
||||
const [grid, setGrid] = useState(true)
|
||||
const [ruler, setRuler] = useState(false)
|
||||
const [zoom, setZoom] = useState("100")
|
||||
return (
|
||||
<Menubar>
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger>파일</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem>
|
||||
새로 만들기 <MenubarShortcut>⌘N</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarItem>
|
||||
열기 <MenubarShortcut>⌘O</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarItem disabled>저장 (사용 불가)</MenubarItem>
|
||||
<MenubarSeparator />
|
||||
<MenubarSub>
|
||||
<MenubarSubTrigger>내보내기</MenubarSubTrigger>
|
||||
<MenubarSubContent>
|
||||
<MenubarItem>PDF</MenubarItem>
|
||||
<MenubarItem>이미지</MenubarItem>
|
||||
</MenubarSubContent>
|
||||
</MenubarSub>
|
||||
<MenubarSeparator />
|
||||
<MenubarItem variant="destructive">종료</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger>편집</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem>
|
||||
실행 취소 <MenubarShortcut>⌘Z</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarItem>
|
||||
다시 실행 <MenubarShortcut>⇧⌘Z</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger>보기</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarGroup>
|
||||
<MenubarLabel>표시</MenubarLabel>
|
||||
<MenubarCheckboxItem checked={grid} onCheckedChange={setGrid}>
|
||||
격자
|
||||
</MenubarCheckboxItem>
|
||||
<MenubarCheckboxItem checked={ruler} onCheckedChange={setRuler}>
|
||||
눈금자
|
||||
</MenubarCheckboxItem>
|
||||
</MenubarGroup>
|
||||
<MenubarSeparator />
|
||||
<MenubarLabel>확대</MenubarLabel>
|
||||
<MenubarRadioGroup value={zoom} onValueChange={setZoom}>
|
||||
<MenubarRadioItem value="75">75%</MenubarRadioItem>
|
||||
<MenubarRadioItem value="100">100%</MenubarRadioItem>
|
||||
<MenubarRadioItem value="125">125%</MenubarRadioItem>
|
||||
</MenubarRadioGroup>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
</Menubar>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = { title: "Components/Navigation/Menubar", component: Menubar, parameters: { layout: "padded" } } satisfies Meta<typeof Menubar>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Representative Story: File (item, shortcut, disabled, separator, sub menu, destructive), Edit, View (label, checkbox, radio).
|
||||
export const All: Story = {
|
||||
name: "Menubar",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Menubar"
|
||||
description="데스크톱 앱 형태의 상단 메뉴 막대입니다. 메뉴가 열린 상태에서 좌우 방향키로 이웃 메뉴로 이동합니다."
|
||||
/>
|
||||
<StorySection title="Overview" description="파일(단축키 · Disabled · 하위 메뉴 · 위험 항목), 편집, 보기(Label · Checkbox · Radio)입니다. 메뉴를 눌러 엽니다.">
|
||||
<div style={{ minHeight: 384 }}>
|
||||
<Specimen />
|
||||
</div>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
function FocusSpecimen({ open = false }: { open?: boolean }) {
|
||||
return (
|
||||
<div style={{ minHeight: 160 }}>
|
||||
<Menubar defaultValue={open ? "file" : undefined}>
|
||||
<MenubarMenu value="file">
|
||||
<MenubarTrigger>파일</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem>새로 만들기</MenubarItem>
|
||||
<MenubarItem disabled>저장 (사용 불가)</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
<MenubarMenu value="disabled">
|
||||
<MenubarTrigger disabled>사용 불가</MenubarTrigger>
|
||||
</MenubarMenu>
|
||||
</Menubar>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const TriggerFocus: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => <FocusSpecimen />,
|
||||
play: async ({ canvasElement }) => {
|
||||
canvasElement.querySelector<HTMLElement>('[data-slot="menubar-trigger"]')?.focus()
|
||||
},
|
||||
}
|
||||
|
||||
export const OpenFocus: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => <FocusSpecimen open />,
|
||||
play: async ({ canvasElement }) => {
|
||||
// Verification only: actual trigger focus while its real menu is open.
|
||||
// Production keyboard opening still moves focus into the menu (Radix default).
|
||||
canvasElement.querySelector<HTMLElement>('[data-slot="menubar-trigger"]')?.focus()
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import type { ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import {
|
||||
NavigationMenu,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuIndicator,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuList,
|
||||
NavigationMenuTrigger,
|
||||
} from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Navigation Menu is for destinations (links), not commands. The representative Story opens the real menus with hover, click or
|
||||
// keyboard; the same specimens also exist as Stories tagged `verification` (hidden from the sidebar). Title + description children
|
||||
// inside a content link are marked with `data-description` for the secondary text.
|
||||
|
||||
function Destination({ href, title, description, active }: { href: string; title: string; description?: string; active?: boolean }) {
|
||||
return (
|
||||
<NavigationMenuLink href={href} active={active}>
|
||||
<span>{title}</span>
|
||||
{description ? <span data-description="">{description}</span> : null}
|
||||
</NavigationMenuLink>
|
||||
)
|
||||
}
|
||||
|
||||
function Specimen({ viewport = true }: { viewport?: boolean }) {
|
||||
return (
|
||||
<NavigationMenu viewport={viewport}>
|
||||
<NavigationMenuList>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuTrigger>제품</NavigationMenuTrigger>
|
||||
<NavigationMenuContent>
|
||||
<ul className="sm:w-[min(360px,calc(100cqw-18px))]" style={{ display: "grid", gap: 4 }}>
|
||||
<li>
|
||||
<Destination href="#analytics" title="분석" description="노선과 시설물 데이터를 한눈에 확인합니다." />
|
||||
</li>
|
||||
<li>
|
||||
<Destination href="#maps" title="지도" description="공간 정보를 지도 위에서 관리합니다." />
|
||||
</li>
|
||||
<li>
|
||||
<Destination href="#reports" title="리포트" description="점검 결과를 보고서로 만듭니다." />
|
||||
</li>
|
||||
</ul>
|
||||
</NavigationMenuContent>
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuTrigger>자료</NavigationMenuTrigger>
|
||||
<NavigationMenuContent>
|
||||
<ul className="sm:w-[min(300px,calc(100cqw-18px))]" style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 4 }}>
|
||||
<li>
|
||||
<Destination href="#docs" title="문서" />
|
||||
</li>
|
||||
<li>
|
||||
<Destination href="#guides" title="가이드" />
|
||||
</li>
|
||||
<li>
|
||||
<Destination href="#api" title="API" />
|
||||
</li>
|
||||
<li>
|
||||
<Destination href="#blog" title="블로그" />
|
||||
</li>
|
||||
</ul>
|
||||
</NavigationMenuContent>
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuLink href="#company" active>
|
||||
회사
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuLink href="#contact">문의</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuTrigger disabled>준비 중</NavigationMenuTrigger>
|
||||
</NavigationMenuItem>
|
||||
</NavigationMenuList>
|
||||
{viewport ? <NavigationMenuIndicator /> : null}
|
||||
</NavigationMenu>
|
||||
)
|
||||
}
|
||||
|
||||
// Size specimens to their review area (including the panel's 8px padding + 1px border on each side).
|
||||
// Mobile uses the content's natural minimum width instead of a fixed desktop specimen width.
|
||||
const frame = (node: ReactNode) => (
|
||||
<div
|
||||
style={{ minHeight: 256, width: "100%", containerType: "inline-size" }}
|
||||
className="max-md:[&_[data-viewport=false]_[data-slot=navigation-menu-content]]:absolute"
|
||||
>
|
||||
{node}
|
||||
</div>
|
||||
)
|
||||
|
||||
const meta = { title: "Components/Navigation/Navigation Menu", component: NavigationMenu, parameters: { layout: "padded" } } satisfies Meta<typeof NavigationMenu>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const All: Story = {
|
||||
name: "Navigation Menu",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Navigation Menu"
|
||||
description="목적지(링크)로 이동하는 메뉴입니다. 명령 실행에는 쓰지 않습니다. hover, 클릭 또는 키보드로 엽니다."
|
||||
/>
|
||||
<StorySection title="Overview">
|
||||
<StateBoard
|
||||
min={520}
|
||||
states={[
|
||||
{ id: "viewport", label: "Viewport (default)", note: "제품 / 자료는 content, 회사는 현재 위치(active), 문의는 일반 링크, 준비 중은 disabled trigger", children: frame(<Specimen />) },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Variants">
|
||||
<StateBoard
|
||||
min={520}
|
||||
states={[
|
||||
{ id: "no-viewport", label: "viewport={false}", note: "각 content가 자기 trigger 아래에 열립니다", children: frame(<Specimen viewport={false} />) },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const Default: Story = { tags: ["verification"], render: () => frame(<Specimen />) }
|
||||
export const NoViewport: Story = { tags: ["verification"], name: "No Viewport", render: () => frame(<Specimen viewport={false} />) }
|
||||
|
||||
// Three enabled triggers with different widths exercise first / middle / last offsets in both positioning modes.
|
||||
export const IndicatorAlignment: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => (
|
||||
<StateBoard min={520} states={[true, false].map((viewport) => ({
|
||||
id: viewport ? "viewport" : "no-viewport",
|
||||
label: `Indicator alignment · viewport=${viewport}`,
|
||||
children: frame(
|
||||
<NavigationMenu viewport={viewport}>
|
||||
<NavigationMenuList>
|
||||
{["제품", "기술 자료", "지원 센터"].map((label) => (
|
||||
<NavigationMenuItem key={label}>
|
||||
<NavigationMenuTrigger>{label}</NavigationMenuTrigger>
|
||||
<NavigationMenuContent><Destination href="#verification" title={`${label} 안내`} /></NavigationMenuContent>
|
||||
</NavigationMenuItem>
|
||||
))}
|
||||
</NavigationMenuList>
|
||||
{viewport ? <NavigationMenuIndicator /> : null}
|
||||
</NavigationMenu>
|
||||
),
|
||||
}))} />
|
||||
),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useState, type ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Pagination is presentational: the consumer owns the page and the hrefs. The Story keeps the page in local state and uses
|
||||
// `#page-N` hrefs with preventDefault. The representative Story shows each case on one page; the same specimens also exist as
|
||||
// Stories tagged `verification` (hidden from the sidebar).
|
||||
|
||||
// Page list with ellipses: 1 … (p-1 p p+1) … total.
|
||||
function pageRange(page: number, total: number): (number | "start" | "end")[] {
|
||||
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1)
|
||||
const out: (number | "start" | "end")[] = [1]
|
||||
const from = Math.max(2, Math.min(page - 1, total - 4))
|
||||
const to = Math.min(total - 1, Math.max(page + 1, 5))
|
||||
if (from > 2) out.push("start")
|
||||
for (let p = from; p <= to; p++) out.push(p)
|
||||
if (to < total - 1) out.push("end")
|
||||
out.push(total)
|
||||
return out
|
||||
}
|
||||
|
||||
function Specimen({ total, initial }: { total: number; initial: number }) {
|
||||
const [page, setPage] = useState(initial)
|
||||
const go = (p: number) => (event: React.MouseEvent) => {
|
||||
event.preventDefault()
|
||||
setPage(p)
|
||||
}
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious href={`#page-${page - 1}`} aria-disabled={page === 1} onClick={go(page - 1)} />
|
||||
</PaginationItem>
|
||||
{pageRange(page, total).map((p) =>
|
||||
typeof p === "number" ? (
|
||||
<PaginationItem key={p}>
|
||||
<PaginationLink href={`#page-${p}`} isActive={p === page} onClick={go(p)}>
|
||||
{p}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
) : (
|
||||
<PaginationItem key={p}>
|
||||
<PaginationEllipsis />
|
||||
</PaginationItem>
|
||||
)
|
||||
)}
|
||||
<PaginationItem>
|
||||
<PaginationNext href={`#page-${page + 1}`} aria-disabled={page === total} onClick={go(page + 1)} />
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
<p className="text-center text-sm text-muted-foreground" data-testid="page-state">{`현재 ${page} / ${total}`}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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} /> },
|
||||
]
|
||||
|
||||
const meta = { title: "Components/Navigation/Pagination", component: Pagination, parameters: { layout: "padded" } } satisfies Meta<typeof Pagination>
|
||||
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) }))} />
|
||||
)
|
||||
|
||||
// Representative Story: the common Story page; the sections group the same specimens.
|
||||
export const All: Story = {
|
||||
name: "Pagination",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Pagination"
|
||||
description="여러 페이지로 나뉜 목록을 이동하는 내비게이션입니다. 현재 페이지와 href는 소비 코드가 소유합니다."
|
||||
/>
|
||||
<StorySection title="Overview">{board("middle")}</StorySection>
|
||||
<StorySection title="States" description="첫 페이지와 마지막 페이지에서는 이전·다음이 aria-disabled입니다.">{board("first", "last")}</StorySection>
|
||||
<StorySection title="Usage" description="페이지가 많으면 생략 부호로 줄입니다.">{board("ellipsis")}</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const FirstPage: Story = { tags: ["verification"], name: "First Page", render: () => wrap(<Specimen total={5} initial={1} />) }
|
||||
export const LastPage: Story = { tags: ["verification"], name: "Last Page", render: () => wrap(<Specimen total={5} initial={5} />) }
|
||||
export const LongRange: Story = { tags: ["verification"], name: "Long Range", render: () => wrap(<Specimen total={20} initial={10} />) }
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Button, Input, Popover, PopoverClose, PopoverContent, PopoverTrigger } from "@hm-design/ui"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Popover is a non-modal Radix overlay (Portal). The representative Story opens the real panels from their triggers;
|
||||
// the verification Story starts one open. Radix owns outside click / Escape close and focus return.
|
||||
|
||||
function Info({ defaultOpen }: { defaultOpen?: boolean }) {
|
||||
return (
|
||||
<Popover defaultOpen={defaultOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="primary" appearance="outline">
|
||||
정보 보기
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent aria-label="프로젝트 안내">
|
||||
<div className="grid gap-1">
|
||||
<p className="font-bold">프로젝트 안내</p>
|
||||
<p>일정과 담당자 정보를 현재 화면 위에서 확인합니다. 바깥을 누르거나 Esc를 누르면 닫힙니다.</p>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
function FormPanel() {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="primary" appearance="outline">
|
||||
이름 변경
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" aria-label="프로젝트 이름 변경">
|
||||
<div className="grid gap-3">
|
||||
<label htmlFor="popover-name" className="text-sm font-medium text-muted-foreground">
|
||||
프로젝트명
|
||||
</label>
|
||||
<Input id="popover-name" size="medium" placeholder="프로젝트명을 입력하세요" />
|
||||
<div className="flex justify-end gap-2">
|
||||
<PopoverClose asChild>
|
||||
<Button variant="cancel" appearance="solid" size="sm">
|
||||
취소
|
||||
</Button>
|
||||
</PopoverClose>
|
||||
<PopoverClose asChild>
|
||||
<Button variant="primary" appearance="solid" size="sm">
|
||||
저장
|
||||
</Button>
|
||||
</PopoverClose>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
function Aligned() {
|
||||
return (
|
||||
<div className="flex w-full max-w-md justify-between gap-3">
|
||||
{(["start", "center", "end"] as const).map((align) => (
|
||||
<Popover key={align}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="primary" appearance="outline" size="sm">
|
||||
{`align ${align}`}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align={align} side="bottom" aria-label={`정렬 예제: ${align}`}>
|
||||
<p>{`align="${align}"`}</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = { title: "Components/Overlay/Popover", component: Popover, parameters: { layout: "centered" } } satisfies Meta<typeof Popover>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const All: Story = {
|
||||
name: "Popover",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Popover"
|
||||
description="Trigger 옆에 상호작용 가능한 패널을 띄우는 비모달 overlay입니다. 바깥 클릭이나 Esc로 닫힙니다."
|
||||
/>
|
||||
<StorySection title="Overview" description="버튼을 눌러 안내 패널을 엽니다.">
|
||||
<Info />
|
||||
</StorySection>
|
||||
<StorySection title="Variants" description="align: start / center / end.">
|
||||
<Aligned />
|
||||
</StorySection>
|
||||
<StorySection title="Usage" description="패널 안의 입력 폼 조합입니다. 취소·저장은 PopoverClose로 닫습니다.">
|
||||
<FormPanel />
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const Open: Story = {
|
||||
tags: ["verification"],
|
||||
parameters: { layout: "fullscreen" },
|
||||
render: () => (
|
||||
<div className="flex min-h-[320px] justify-center p-8">
|
||||
<Info defaultOpen />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useState } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Button, Progress } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// 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() {
|
||||
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>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="cancel" appearance="solid" size="sm" onClick={() => setValue((v) => Math.max(0, v - 25))}>
|
||||
-25
|
||||
</Button>
|
||||
<Button variant="primary" appearance="solid" size="sm" onClick={() => setValue((v) => Math.min(100, v + 25))}>
|
||||
+25
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CustomMaximum() {
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<Progress value={0} max={200} aria-label="0 of 200" />
|
||||
<Progress value={50} max={200} aria-label="50 of 200" />
|
||||
<Progress value={200} max={200} aria-label="200 of 200" />
|
||||
<Progress max={200} aria-label="Indeterminate" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 /> },
|
||||
]
|
||||
|
||||
const meta = { title: "Components/Data & Display/Progress", component: Progress, parameters: { layout: "padded" } } satisfies Meta<typeof Progress>
|
||||
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 }))} />
|
||||
)
|
||||
|
||||
// Representative Story: the common Story page; the sections group the same specimens.
|
||||
export const All: Story = {
|
||||
name: "Progress",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Progress"
|
||||
description="작업 진행률을 막대로 표시합니다. value(0..max)를 따라 채워집니다."
|
||||
/>
|
||||
<StorySection title="Overview">{board("middle")}</StorySection>
|
||||
<StorySection title="States" description="value 0과 100입니다.">{board("zero", "full")}</StorySection>
|
||||
<StorySection title="Usage" description="max를 바꾼 범위와, value를 바꾸는 실제 조작입니다.">{board("custom-maximum", "interactive")}</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const Zero: Story = { tags: ["verification"], render: () => <Progress value={0} aria-label="0%" /> }
|
||||
export const Middle: Story = { tags: ["verification"], render: () => <Progress value={50} aria-label="50%" /> }
|
||||
export const Full: Story = { tags: ["verification"], render: () => <Progress value={100} aria-label="100%" /> }
|
||||
|
||||
export const NonDefaultMax: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => <CustomMaximum />,
|
||||
play: async ({ canvasElement }) => {
|
||||
const bars = canvasElement.querySelectorAll('[role="progressbar"]')
|
||||
const expected = ["translateX(-100%)", "translateX(-75%)", "translateX(0%)", "translateX(-100%)"]
|
||||
bars.forEach((bar, index) => {
|
||||
const indicator = bar.querySelector<HTMLElement>('[data-slot="progress-indicator"]')
|
||||
if (bar.getAttribute("aria-valuemax") !== "200" || indicator?.style.transform !== expected[index]) {
|
||||
throw new Error(`Progress max=200: ARIA/fill mismatch in specimen ${index}`)
|
||||
}
|
||||
})
|
||||
if (bars.length !== 4 || bars[1]?.getAttribute("aria-valuenow") !== "50" || bars[3]?.hasAttribute("aria-valuenow")) {
|
||||
throw new Error("Progress max=200: incorrect determinate/indeterminate ARIA state")
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Label, RadioGroup, RadioGroupItem } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Current HM Radio Group. ui-viz has no Radio spec, so it was reference only.
|
||||
|
||||
function Group({
|
||||
prefix,
|
||||
defaultValue = "a",
|
||||
disabled,
|
||||
disabledItem,
|
||||
invalid,
|
||||
orientation,
|
||||
}: {
|
||||
prefix: string
|
||||
defaultValue?: string
|
||||
disabled?: boolean
|
||||
disabledItem?: string
|
||||
invalid?: boolean
|
||||
orientation?: "horizontal" | "vertical"
|
||||
}) {
|
||||
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} />
|
||||
<Label htmlFor={`${prefix}-${v}`}>Option {v.toUpperCase()}</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Components/Form/Radio Group",
|
||||
component: RadioGroup,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"현재 HM 규격: 18px 원, border 1px(stroke), Checked/Selected는 state-active 테두리 + 8px state-active dot, group gap 12(horizontal 16). Radix RadioGroup의 roving focus, 방향키 선택, ARIA를 유지합니다. ui-viz에는 Radio 규격이 없어 참고만 했습니다.",
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof RadioGroup>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const All: Story = {
|
||||
name: "Radio Group",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Radio Group"
|
||||
description="여러 선택지 중 하나를 고르는 컨트롤입니다. 그룹 안에서는 방향키로 이동·선택합니다(Radix roving focus)."
|
||||
/>
|
||||
<StorySection title="Overview">
|
||||
<StateBoard min={240} states={[{ id: "vertical", label: "Vertical (default) · Checked / Unchecked", children: <Group prefix="v" defaultValue="b" /> }]} />
|
||||
</StorySection>
|
||||
<StorySection title="Variants">
|
||||
<StateBoard min={240} states={[{ id: "horizontal", label: "Horizontal", children: <Group prefix="h" orientation="horizontal" /> }]} />
|
||||
</StorySection>
|
||||
<StorySection title="States">
|
||||
<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: "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 테두리를 표시합니다.">
|
||||
<StateBoard min={240} states={[{ id: "error", label: "Error", children: <Group prefix="e" invalid /> }]} />
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
// preventScroll: the page opens at its header, not at the States section.
|
||||
canvasElement.querySelector<HTMLButtonElement>('[data-overview-state="focus"] [role="radio"][data-state="checked"]')?.focus({ preventScroll: true })
|
||||
},
|
||||
}
|
||||
|
||||
export const Vertical: Story = { tags: ["verification"], render: () => <Group prefix="sv" /> }
|
||||
export const Horizontal: Story = { tags: ["verification"], render: () => <Group prefix="sh" orientation="horizontal" /> }
|
||||
export const Disabled: Story = { tags: ["verification"], render: () => <Group prefix="sd" disabled /> }
|
||||
export const Error: Story = { tags: ["verification"], render: () => <Group prefix="se" invalid /> }
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// The representative Story shows real, draggable layouts; the same specimens also exist as Stories tagged `verification` (hidden
|
||||
// from the sidebar). react-resizable-panels v4 sizes: a string without a unit ("30") or with "%" is a percentage, a number is
|
||||
// pixels. The frames (size, border) and panel content are Story layout. These are not Dashboard layouts.
|
||||
|
||||
const frame = (node: ReactNode, height = 200) => (
|
||||
<div className="rounded-md border" style={{ width: "100%", maxWidth: 560, height }}>
|
||||
{node}
|
||||
</div>
|
||||
)
|
||||
|
||||
const Pane = ({ children }: { children: ReactNode }) => <div className="grid size-full place-items-center p-3 text-sm">{children}</div>
|
||||
|
||||
function Horizontal() {
|
||||
return frame(
|
||||
<ResizablePanelGroup orientation="horizontal" aria-label="가로 분할" id="h">
|
||||
<ResizablePanel defaultSize="30" minSize="15" id="h-list">
|
||||
<Pane>목록 (min 15%)</Pane>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle aria-label="목록 폭 조절" />
|
||||
<ResizablePanel defaultSize="70" minSize="20" id="h-detail">
|
||||
<Pane>상세</Pane>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
)
|
||||
}
|
||||
|
||||
function Vertical() {
|
||||
return frame(
|
||||
<ResizablePanelGroup orientation="vertical" aria-label="세로 분할" id="v">
|
||||
<ResizablePanel defaultSize="40" minSize="20" id="v-top">
|
||||
<Pane>위</Pane>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle aria-label="높이 조절" />
|
||||
<ResizablePanel defaultSize="60" minSize="20" id="v-bottom">
|
||||
<Pane>아래</Pane>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>,
|
||||
240
|
||||
)
|
||||
}
|
||||
|
||||
function WithHandle() {
|
||||
return frame(
|
||||
<ResizablePanelGroup orientation="horizontal" aria-label="handle 분할" id="g">
|
||||
<ResizablePanel defaultSize="50" minSize="20" id="g-a">
|
||||
<Pane>A</Pane>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle aria-label="A와 B 사이 조절" />
|
||||
<ResizablePanel defaultSize="50" minSize="20" id="g-b">
|
||||
<Pane>B</Pane>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
)
|
||||
}
|
||||
|
||||
function MinMax() {
|
||||
return frame(
|
||||
<ResizablePanelGroup orientation="horizontal" aria-label="min max 분할" id="m">
|
||||
<ResizablePanel defaultSize="40" minSize="25" maxSize="60" id="m-a">
|
||||
<Pane>25% – 60%</Pane>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle aria-label="min max 조절" />
|
||||
<ResizablePanel defaultSize="60" minSize="20" id="m-b">
|
||||
<Pane>나머지</Pane>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
)
|
||||
}
|
||||
|
||||
function Collapsible() {
|
||||
return frame(
|
||||
<ResizablePanelGroup orientation="horizontal" aria-label="접힘 분할" id="c">
|
||||
<ResizablePanel defaultSize="30" minSize="15" collapsible collapsedSize="0" id="c-side">
|
||||
<Pane>사이드 (끌면 접힘)</Pane>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle aria-label="사이드 폭 조절" />
|
||||
<ResizablePanel defaultSize="70" minSize="30" id="c-main">
|
||||
<Pane>본문</Pane>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
)
|
||||
}
|
||||
|
||||
const sections = [
|
||||
{ id: "horizontal", label: "Horizontal (list min 15%)", 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: "collapsible", label: "Collapsible panel", node: <Collapsible /> },
|
||||
]
|
||||
|
||||
const meta = { title: "Components/Disclosure/Resizable", component: ResizablePanelGroup, parameters: { layout: "padded" } } satisfies Meta<typeof ResizablePanelGroup>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const board = (...ids: string[]) => (
|
||||
<StateBoard min={580} 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.
|
||||
export const All: Story = {
|
||||
name: "Resizable",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Resizable"
|
||||
description="경계선을 끌어 인접한 패널의 크기를 조절하는 레이아웃입니다."
|
||||
/>
|
||||
<StorySection title="Overview">{board("horizontal")}</StorySection>
|
||||
<StorySection title="Variants" description="세로 방향과 grip 표시입니다.">{board("vertical", "handle")}</StorySection>
|
||||
<StorySection title="Usage" description="최소·최대 크기와 접을 수 있는 패널입니다.">{board("minmax", "collapsible")}</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const HorizontalLayout: Story = { tags: ["verification"], name: "Horizontal", render: () => <Horizontal /> }
|
||||
export const VerticalLayout: Story = { tags: ["verification"], name: "Vertical", render: () => <Vertical /> }
|
||||
export const MinMaxLayout: Story = { tags: ["verification"], name: "Min Max", render: () => <MinMax /> }
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { ScrollArea, ScrollBar } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Scroll Area is Radix scrolling with a styled scrollbar (type "hover": the bar shows while hovering or scrolling). Sizes below
|
||||
// are Story layout. The representative Story shows vertical, horizontal, both axes and a table-like content.
|
||||
|
||||
const items = Array.from({ length: 30 }, (_, i) => `항목 ${i + 1}`)
|
||||
|
||||
function Vertical() {
|
||||
return (
|
||||
<ScrollArea style={{ height: 200, width: 280 }} className="rounded-md border">
|
||||
<div className="p-3 text-sm">
|
||||
{items.map((t) => (
|
||||
<div key={t} className="py-1.5">
|
||||
{t}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
|
||||
function Horizontal() {
|
||||
return (
|
||||
<ScrollArea style={{ width: 280 }} className="rounded-md border whitespace-nowrap">
|
||||
<div className="flex w-max gap-3 p-3">
|
||||
{Array.from({ length: 12 }, (_, i) => (
|
||||
<div key={i} className="grid h-16 w-24 place-items-center rounded-md bg-muted text-sm">{`카드 ${i + 1}`}</div>
|
||||
))}
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
|
||||
function Both() {
|
||||
return (
|
||||
<ScrollArea style={{ height: 200, width: 280 }} className="rounded-md border">
|
||||
<div className="p-3 text-sm" style={{ width: 600 }}>
|
||||
{items.map((t) => (
|
||||
<div key={t} className="py-1.5">{`${t} — 가로로도 길어지는 내용입니다. 가로 scroll로 끝까지 볼 수 있습니다.`}</div>
|
||||
))}
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
|
||||
function TableLike() {
|
||||
return (
|
||||
<ScrollArea style={{ height: 180, width: 300 }} className="rounded-md border">
|
||||
<div className="grid text-sm" style={{ width: 520 }}>
|
||||
{Array.from({ length: 12 }, (_, r) => (
|
||||
<div key={r} className="grid grid-cols-4 border-b p-2">
|
||||
{["번호", "이름", "상태", "금액"].map((c) => (
|
||||
<span key={c}>{`${c} ${r + 1}`}</span>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
|
||||
const sections: { id: string; label: string; node: ReactNode }[] = [
|
||||
{ id: "vertical", label: "Vertical", node: <Vertical /> },
|
||||
{ id: "horizontal", label: "Horizontal", node: <Horizontal /> },
|
||||
{ id: "both", label: "Both axes", node: <Both /> },
|
||||
{ id: "table-like", label: "Table-like content", node: <TableLike /> },
|
||||
]
|
||||
|
||||
const meta = { title: "Components/Data & Display/Scroll Area", component: ScrollArea, parameters: { layout: "padded" } } satisfies Meta<typeof ScrollArea>
|
||||
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 Story: the common Story page; the sections group the same specimens.
|
||||
export const All: Story = {
|
||||
name: "Scroll Area",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Scroll Area"
|
||||
description="정해진 크기 안에서 내용을 스크롤하는 영역입니다. 스크롤바는 포인터를 올리거나 스크롤할 때 나타납니다."
|
||||
/>
|
||||
<StorySection title="Overview">{board("vertical")}</StorySection>
|
||||
<StorySection title="Variants" description="가로와 양방향 스크롤입니다.">{board("horizontal", "both")}</StorySection>
|
||||
<StorySection title="Usage">{board("table-like")}</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const VerticalScroll: Story = { tags: ["verification"], name: "Vertical", render: () => <Vertical /> }
|
||||
export const HorizontalScroll: Story = { tags: ["verification"], name: "Horizontal", render: () => <Horizontal /> }
|
||||
@@ -0,0 +1,312 @@
|
||||
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 { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Story structure follows ui_viz (implementations/web/storybook/Select.stories.jsx and SelectSizes.stories.jsx):
|
||||
// one representative "Select" Story shows the default, selected, disabled and open states on one page; the same specimens
|
||||
// also exist as separate Stories tagged `verification`, which the sidebar hides by default (see .storybook/main.ts).
|
||||
//
|
||||
// The Label is not part of the Select component. SelectSpecimen composes it with a plain element and shadcn Token
|
||||
// utilities, the way a consumer would: Label 14/20/500 in the secondary text color, field gap 8.
|
||||
|
||||
const statusOptions = [
|
||||
{ value: "all", label: "All statuses" },
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "planned", label: "Planned" },
|
||||
{ value: "completed", label: "Completed" },
|
||||
]
|
||||
|
||||
const longOptions = [
|
||||
{ value: "short", label: "Short option" },
|
||||
{ value: "long", label: "A very long selected value that must truncate without moving the chevron" },
|
||||
]
|
||||
|
||||
const scrollOptions = Array.from({ length: 32 }, (_, index) => ({
|
||||
value: `option-${index + 1}`,
|
||||
label: `Option ${index + 1}`,
|
||||
}))
|
||||
|
||||
type SpecimenProps = {
|
||||
listSize?: SelectSize
|
||||
label?: string
|
||||
placeholder?: string
|
||||
size?: SelectSize
|
||||
defaultValue?: string
|
||||
defaultOpen?: boolean
|
||||
disabled?: boolean
|
||||
"aria-invalid"?: boolean
|
||||
options?: ReadonlyArray<{ value: string; label: string }>
|
||||
}
|
||||
|
||||
function SelectSpecimen({ listSize, label = "Select", placeholder = "Choose a status", size, defaultValue, defaultOpen, disabled, "aria-invalid": ariaInvalid, options = statusOptions }: SpecimenProps) {
|
||||
const id = useId()
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<label htmlFor={id} className={`justify-self-start text-sm font-medium text-muted-foreground${disabled ? " opacity-60" : ""}`}>
|
||||
{label}
|
||||
</label>
|
||||
<Select defaultValue={defaultValue} defaultOpen={defaultOpen} disabled={disabled}>
|
||||
<SelectTrigger id={id} size={size} aria-invalid={ariaInvalid} className="w-full">
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent {...(listSize ? { "data-size": listSize } : {})}>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const narrow = (children: ReactNode) => <div className="max-w-sm">{children}</div>
|
||||
|
||||
// ui_viz docs/components/select.md §1.1, §9: display metadata only, never sets control geometry.
|
||||
const sizes = [
|
||||
{ size: "medium" as const, name: "Medium", outer: "34px / 32px", typography: "14 / 20 / 400", option: "42px minimum · 14 / 20 / 400", usage: "Business forms / Search" },
|
||||
{ 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] }) {
|
||||
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>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function SizeComparison() {
|
||||
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} />
|
||||
))}
|
||||
</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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DisabledStates() {
|
||||
return (
|
||||
<div className="grid max-w-[480px] gap-6">
|
||||
<section className="grid gap-2">
|
||||
<h3 className="text-base font-semibold">Selected Value</h3>
|
||||
<SelectSpecimen size="medium" label="Status" defaultValue="active" disabled />
|
||||
</section>
|
||||
<section className="grid gap-2">
|
||||
<h3 className="text-base font-semibold">Placeholder</h3>
|
||||
<SelectSpecimen size="medium" label="Status" placeholder="Choose a status" disabled />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Components/Form/Select",
|
||||
component: SelectSpecimen,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"ui_viz docs/components/select.md: Medium 외곽 34px(Trigger 32), Large 외곽 44px(Trigger 42, 기본값). Trigger는 Medium 14/20/400, Large 16/24/400이고 화살표는 18×18입니다. 목록은 Trigger 폭에 연결되는 Overlay이며 Option은 Trigger의 size를 따릅니다(최소 42px, Medium 14/20/400, Large 16/24/400). Option 상태 면(.45)·글자색과 열린 구분선, 그림자는 ui_viz에서 Temporary/EXPERIMENTAL이라 shadcn 기본값을 유지합니다. Error는 ui_viz에 정의가 없습니다.",
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
size: { control: "inline-radio", options: ["medium", "large"], description: "무지정 기본값 Large. 업무 폼·조회 소비 역할은 Medium." },
|
||||
disabled: { control: "boolean" },
|
||||
defaultOpen: { control: "boolean" },
|
||||
defaultValue: { control: "select", options: [undefined, ...statusOptions.map((option) => option.value)] },
|
||||
placeholder: { control: "text" },
|
||||
label: { control: "text" },
|
||||
},
|
||||
} satisfies Meta<typeof SelectSpecimen>
|
||||
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.
|
||||
export const All: Story = {
|
||||
name: "Select",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
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="size를 생략한 기본 Select(Large 44px)와 열린 목록입니다. 목록은 Escape 또는 바깥 클릭으로 닫히고, Trigger를 클릭하면 다시 열립니다.">
|
||||
<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="Medium 34 / Large 44px. Input과 독립된 두 크기입니다.">
|
||||
<SizeComparison />
|
||||
</StorySection>
|
||||
<StorySection title="States" description="값이 선택된 상태와 Disabled입니다.">
|
||||
<StateBoard
|
||||
fixed
|
||||
states={[
|
||||
{ id: "selected", label: "Selected", children: <SelectSpecimen size="medium" defaultValue="active" /> },
|
||||
{ id: "disabled", label: "Disabled", children: <DisabledStates /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Invalid / Focus" description="aria-invalid는 Error 테두리로 표시합니다. Tab으로 Trigger에 포커스해 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" /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
// Verification Stories: the same specimens as separate Stories, hidden from the sidebar (tag `verification`), reachable by URL or the sidebar tag filter.
|
||||
export const Default: Story = { tags: ["verification"], render: () => narrow(<SelectSpecimen />) }
|
||||
export const InvalidFocus: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => (
|
||||
<div className="grid max-w-sm gap-6">
|
||||
<button type="button">Before field</button>
|
||||
<SelectSpecimen size="medium" aria-invalid label="Invalid Select" defaultValue="active" />
|
||||
<SelectSpecimen size="medium" aria-invalid disabled label="Disabled Invalid Select" />
|
||||
<button type="button">After field</button>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
export const Open: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => narrow(<div className="min-h-64"><SelectSpecimen size="medium" defaultOpen /></div>),
|
||||
}
|
||||
export const Selected: Story = { tags: ["verification"], render: () => narrow(<SelectSpecimen size="medium" defaultValue="active" />) }
|
||||
export const Disabled: Story = { tags: ["verification"], render: () => <DisabledStates /> }
|
||||
export const Sizes: Story = { tags: ["verification"], name: "Compare Sizes", render: () => <SizeComparison /> }
|
||||
export const Medium: Story = { tags: ["verification"], render: () => narrow(<SizeCard spec={sizes[0]} />) }
|
||||
export const Large: Story = { tags: ["verification"], render: () => narrow(<SizeCard spec={sizes[1]} />) }
|
||||
|
||||
// HM resilience checks only: ui_viz had no public long-content or scroll Story, so these are not parity evidence.
|
||||
export const LongContent: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => (
|
||||
<div style={{ display: "grid", gap: 24, maxWidth: 280 }}>
|
||||
<SelectSpecimen
|
||||
size="medium"
|
||||
label="Long placeholder"
|
||||
placeholder="A very long placeholder that must truncate without moving the chevron"
|
||||
options={longOptions}
|
||||
/>
|
||||
<SelectSpecimen size="medium" label="Long selected value" defaultValue="long" options={longOptions} />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
// Open list with an option longer than the list: checks Viewport → Item → ItemText sizing (Trigger 14px vs Option 16px for Medium).
|
||||
const longOpenOptions = [
|
||||
{ value: "short", label: "Short option" },
|
||||
{ value: "long", label: "A very long option label that is wider than the open list and must truncate inside it" },
|
||||
]
|
||||
export const OpenLargeShort: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => narrow(<div className="min-h-64"><SelectSpecimen size="large" defaultOpen /></div>),
|
||||
}
|
||||
export const OpenMediumLong: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => <div className="min-h-64" style={{ maxWidth: 280 }}><SelectSpecimen size="medium" defaultOpen defaultValue="long" options={longOpenOptions} /></div>,
|
||||
}
|
||||
export const OpenLargeLong: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => <div className="min-h-64" style={{ maxWidth: 280 }}><SelectSpecimen size="large" defaultOpen defaultValue="long" options={longOpenOptions} /></div>,
|
||||
}
|
||||
|
||||
// Place the real Trigger near the viewport bottom so Radix collision detection flips it.
|
||||
const atBottom = (children: ReactNode) => (
|
||||
<div style={{ minHeight: "calc(100dvh - 32px)", display: "grid", alignItems: "end", width: 280, maxWidth: "100%" }}>{children}</div>
|
||||
)
|
||||
export const OpenTop: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => atBottom(<SelectSpecimen size="large" defaultOpen />),
|
||||
}
|
||||
export const OpenTopLong: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => atBottom(<SelectSpecimen size="medium" defaultOpen defaultValue="long" options={longOpenOptions} />),
|
||||
}
|
||||
|
||||
// Comparison only: pins the list profile independently of the Trigger to check whether a Medium Trigger + Large list
|
||||
// breaks the open list. Production derives the list size from the Trigger; this Story overrides the data-size attribute
|
||||
// that SelectContent already forwards, so no Select API exists for it. Both columns share options, selected value and width.
|
||||
function ListSizeColumn({ title, listSize, width }: { title: string; listSize: SelectSize; width: number }) {
|
||||
return (
|
||||
<section className="grid content-start gap-2" style={{ width }} data-list-size={listSize}>
|
||||
<h3 className="m-0 text-sm font-semibold">{title}</h3>
|
||||
<div className="min-h-56">
|
||||
<SelectSpecimen size="medium" listSize={listSize} defaultOpen defaultValue="long" options={longOpenOptions} label={"Trigger medium · " + width + "px"} />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
export const ListSizeComparison: Story = {
|
||||
tags: ["verification"],
|
||||
name: "Medium Trigger / List Size Comparison",
|
||||
parameters: { controls: { disable: true } },
|
||||
render: () => (
|
||||
<div className="grid gap-8 [grid-template-columns:repeat(auto-fit,minmax(280px,max-content))]">
|
||||
<ListSizeColumn title="A · Medium Trigger + Large List" listSize="large" width={280} />
|
||||
<ListSizeColumn title="B · Medium Trigger + Medium List" listSize="medium" width={280} />
|
||||
<ListSizeColumn title="A · Medium Trigger + Large List" listSize="large" width={384} />
|
||||
<ListSizeColumn title="B · Medium Trigger + Medium List" listSize="medium" width={384} />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const Scroll: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => (
|
||||
<div style={{ minHeight: 360, maxWidth: 280 }}>
|
||||
<SelectSpecimen size="medium" label="Scrollable options" defaultOpen options={scrollOptions} />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
// HM only: not part of the ui_viz Select Stories. Tagged verification so the default sidebar keeps only the representative Story.
|
||||
export const Playground: Story = {
|
||||
tags: ["verification"],
|
||||
args: { label: "Select", placeholder: "Choose a status", size: "medium", disabled: false, defaultOpen: false },
|
||||
render: (args) => narrow(<SelectSpecimen key={String(args.defaultOpen)} {...args} />),
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Separator } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
const meta = {
|
||||
title: "Components/Data & Display/Separator",
|
||||
component: Separator,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"현재 HM 규격: 1px 선, 색은 pale warm neutral(Primitive 참조). Radix Separator의 orientation과 decorative(기본 true) API를 유지합니다. decorative=false이면 role=separator가 노출됩니다.",
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: { orientation: { control: "inline-radio", options: ["horizontal", "vertical"] }, decorative: { control: "boolean" } },
|
||||
} satisfies Meta<typeof Separator>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const All: Story = {
|
||||
name: "Separator",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Separator"
|
||||
description="콘텐츠 사이를 나누는 선입니다. 기본은 장식용(decorative)이며 접근성 트리에 노출되지 않습니다."
|
||||
/>
|
||||
<StorySection title="Overview">
|
||||
<StateBoard
|
||||
min={280}
|
||||
states={[
|
||||
{
|
||||
id: "horizontal",
|
||||
label: "Horizontal",
|
||||
children: (
|
||||
<div className="grid gap-3 text-sm">
|
||||
<span>Above</span>
|
||||
<Separator />
|
||||
<span>Below</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Variants">
|
||||
<StateBoard
|
||||
min={280}
|
||||
states={[
|
||||
{
|
||||
id: "vertical",
|
||||
label: "Vertical (flex row)",
|
||||
children: (
|
||||
<div className="flex h-6 items-center gap-3 text-sm">
|
||||
<span>Left</span>
|
||||
<Separator orientation="vertical" />
|
||||
<span>Middle</span>
|
||||
<Separator orientation="vertical" />
|
||||
<span>Right</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "semantic",
|
||||
label: "Semantic (decorative=false)",
|
||||
noteAfter: true,
|
||||
note: "decorative=false는 role=separator와 aria-orientation을 노출합니다.",
|
||||
children: (
|
||||
<div className="grid gap-3 text-sm">
|
||||
<span>Section A</span>
|
||||
<Separator decorative={false} />
|
||||
<span>Section B</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const Horizontal: Story = { tags: ["verification"], render: () => <div className="max-w-xs"><Separator /></div> }
|
||||
export const Vertical: Story = { tags: ["verification"], render: () => <div className="flex h-8 items-center"><Separator orientation="vertical" /></div> }
|
||||
@@ -0,0 +1,130 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Button, Input, Sheet, SheetBody, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from "@hm-design/ui"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Sheet is a modal Radix overlay (Portal), so the representative Story is a row of triggers that open the real Sheet for each
|
||||
// side and content case. The same cases start open in the verification Stories. Radix owns focus trap, Escape and focus return.
|
||||
|
||||
type Side = "top" | "right" | "bottom" | "left"
|
||||
|
||||
const longText = "긴 내용은 Body 영역만 세로로 scroll 하고 Header와 Footer는 그대로 보입니다. "
|
||||
|
||||
function Frame({
|
||||
trigger,
|
||||
side,
|
||||
defaultOpen,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
trigger: string
|
||||
side?: Side
|
||||
defaultOpen?: boolean
|
||||
title: string
|
||||
description?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Sheet defaultOpen={defaultOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="primary" appearance="outline">
|
||||
{trigger}
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side={side}>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
{description ? <SheetDescription>{description}</SheetDescription> : null}
|
||||
</SheetHeader>
|
||||
<SheetBody>{children}</SheetBody>
|
||||
<SheetFooter>
|
||||
<SheetClose asChild>
|
||||
<Button variant="cancel" appearance="solid">
|
||||
취소
|
||||
</Button>
|
||||
</SheetClose>
|
||||
<SheetClose asChild>
|
||||
<Button variant="primary" appearance="solid">
|
||||
저장
|
||||
</Button>
|
||||
</SheetClose>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
function SideCase({ side, defaultOpen }: { side: Side; defaultOpen?: boolean }) {
|
||||
return (
|
||||
<Frame trigger={side} side={side} defaultOpen={defaultOpen} title="설정" description="화면 가장자리에서 열리는 보조 패널입니다.">
|
||||
<p>{`side="${side}"`}</p>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
function FormCase({ defaultOpen }: { defaultOpen?: boolean }) {
|
||||
return (
|
||||
<Frame trigger="폼" defaultOpen={defaultOpen} title="프로젝트 정보">
|
||||
<div className="grid gap-2">
|
||||
<label htmlFor="sheet-form-name" className="text-sm font-medium text-muted-foreground">
|
||||
프로젝트명
|
||||
</label>
|
||||
<Input id="sheet-form-name" size="medium" placeholder="프로젝트명을 입력하세요" />
|
||||
</div>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
function LongCase({ defaultOpen }: { defaultOpen?: boolean }) {
|
||||
return (
|
||||
<Frame trigger="긴 내용" defaultOpen={defaultOpen} title="긴 내용" description="Body만 scroll 합니다.">
|
||||
{Array.from({ length: 30 }, (_, i) => (
|
||||
<p key={i} className="mb-3">
|
||||
{longText}
|
||||
</p>
|
||||
))}
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = { title: "Components/Overlay/Sheet", component: Sheet, parameters: { layout: "centered" } } satisfies Meta<typeof Sheet>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const All: Story = {
|
||||
name: "Sheet",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Sheet"
|
||||
description="화면 가장자리에서 열리는 보조 모달 패널입니다. 각 버튼을 눌러 실제 Sheet를 엽니다."
|
||||
/>
|
||||
<StorySection title="Overview" description="side를 생략한 기본 방향(right)입니다.">
|
||||
<SideCase side="right" />
|
||||
</StorySection>
|
||||
<StorySection title="Variants" description="side: left / top / bottom.">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<SideCase side="left" />
|
||||
<SideCase side="top" />
|
||||
<SideCase side="bottom" />
|
||||
</div>
|
||||
</StorySection>
|
||||
<StorySection title="Usage" description="폼 입력 조합과, 내용이 길 때 Body만 scroll 하는 경우입니다.">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<FormCase />
|
||||
<LongCase />
|
||||
</div>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
// Verification Stories: the same cases starting open, hidden from the sidebar (tag `verification`).
|
||||
export const Right: Story = { tags: ["verification"], render: () => <SideCase side="right" defaultOpen /> }
|
||||
export const Left: Story = { tags: ["verification"], render: () => <SideCase side="left" defaultOpen /> }
|
||||
export const Top: Story = { tags: ["verification"], render: () => <SideCase side="top" defaultOpen /> }
|
||||
export const Bottom: Story = { tags: ["verification"], render: () => <SideCase side="bottom" defaultOpen /> }
|
||||
export const Form: Story = { tags: ["verification"], render: () => <FormCase defaultOpen /> }
|
||||
export const LongContent: Story = { tags: ["verification"], name: "Long Content", render: () => <LongCase defaultOpen /> }
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Card, CardContent, CardHeader, Skeleton } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Skeleton is a visual placeholder; size and shape come from `className` (layout classes here are Story composition).
|
||||
// There is no data fetching. The representative Story shows text lines, an avatar, and a card / list loading composition.
|
||||
|
||||
function TextLines() {
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarRow() {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-10 rounded-full" />
|
||||
<div className="grid flex-1 gap-2">
|
||||
<Skeleton className="h-4 w-1/3" />
|
||||
<Skeleton className="h-3 w-2/3" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CardLoading() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-1/2" />
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3">
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<AvatarRow />
|
||||
<AvatarRow />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const sections = [
|
||||
{ id: "text", label: "Text", node: <TextLines /> },
|
||||
{ id: "avatar", label: "Avatar + text", node: <AvatarRow /> },
|
||||
{ id: "card", label: "Card / list loading", node: <CardLoading /> },
|
||||
]
|
||||
|
||||
const meta = { title: "Components/Data & Display/Skeleton", component: Skeleton, parameters: { layout: "padded" } } satisfies Meta<typeof Skeleton>
|
||||
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 Story: the common Story page; the sections group the same specimens.
|
||||
export const All: Story = {
|
||||
name: "Skeleton",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Skeleton"
|
||||
description="콘텐츠를 불러오는 동안 자리를 표시하는 placeholder입니다. 크기와 모양은 className으로 정합니다."
|
||||
/>
|
||||
<StorySection title="Overview">{board("text")}</StorySection>
|
||||
<StorySection title="Usage" description="Avatar 행과 카드·목록 로딩 조합입니다.">{board("avatar", "card")}</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const Text: Story = { tags: ["verification"], render: () => <TextLines /> }
|
||||
export const CardList: Story = { tags: ["verification"], name: "Card List", render: () => <CardLoading /> }
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useState, type ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Slider } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// The representative Story shows each Slider mode with real, draggable controls; the same specimens also exist as Stories tagged
|
||||
// `verification` (hidden from the sidebar). The value label is the Story's composition (Slider has no built-in label).
|
||||
// Names (`aria-label`) are provided by the consumer.
|
||||
|
||||
function Labeled({ label, children, valueText }: { label: string; children: ReactNode; valueText: string }) {
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="font-medium">{label}</span>
|
||||
<span className="text-muted-foreground" data-testid="value">{valueText}</span>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Single() {
|
||||
const [value, setValue] = useState([40])
|
||||
return (
|
||||
<Labeled label="볼륨" valueText={`${value[0]}`}>
|
||||
<Slider aria-label="볼륨" value={value} onValueChange={setValue} max={100} step={1} />
|
||||
</Labeled>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
function Step() {
|
||||
const [value, setValue] = useState([50])
|
||||
return (
|
||||
<Labeled label="단계 (step 25)" valueText={`${value[0]}`}>
|
||||
<Slider aria-label="단계" value={value} onValueChange={setValue} max={100} step={25} />
|
||||
</Labeled>
|
||||
)
|
||||
}
|
||||
|
||||
function Disabled() {
|
||||
return <Slider aria-label="비활성" defaultValue={[60]} disabled />
|
||||
}
|
||||
|
||||
function Vertical() {
|
||||
return (
|
||||
<div style={{ height: 180 }} className="flex justify-center">
|
||||
<Slider aria-label="세로" defaultValue={[30]} orientation="vertical" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const sections = [
|
||||
{ id: "single", label: "Single", node: <Single /> },
|
||||
{ id: "range", label: "Range (two thumbs)", node: <Range /> },
|
||||
{ id: "step", label: "Step", node: <Step /> },
|
||||
{ id: "disabled", label: "Disabled", node: <Disabled /> },
|
||||
{ id: "vertical", label: "Vertical", node: <Vertical /> },
|
||||
]
|
||||
|
||||
const meta = { title: "Components/Form/Slider", component: Slider, parameters: { layout: "padded" } } satisfies Meta<typeof Slider>
|
||||
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 Story: the common Story page; the sections group the same specimens.
|
||||
export const All: Story = {
|
||||
name: "Slider",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Slider"
|
||||
description="막대 위의 thumb을 끌어 범위 안의 값을 고르는 컨트롤입니다. 화살표 키로도 값을 바꿉니다."
|
||||
/>
|
||||
<StorySection title="Overview">{board("single")}</StorySection>
|
||||
<StorySection title="Variants" description="두 thumb의 범위, step, 세로 방향입니다.">{board("range", "step", "vertical")}</StorySection>
|
||||
<StorySection title="States">{board("disabled")}</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const SingleValue: Story = { tags: ["verification"], name: "Single", render: () => <Single /> }
|
||||
export const RangeValue: Story = { tags: ["verification"], name: "Range", render: () => <Range /> }
|
||||
export const DisabledSlider: Story = { tags: ["verification"], name: "Disabled", render: () => <Disabled /> }
|
||||
|
||||
export const Focus: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => (
|
||||
<div className="grid gap-8">
|
||||
<Single />
|
||||
<Labeled label="비활성" valueText="60"><Disabled /></Labeled>
|
||||
<output data-testid="slider-focus-result" hidden>Ready</output>
|
||||
</div>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
const thumb = canvasElement.querySelector<HTMLElement>('[data-slot="slider-thumb"]')!
|
||||
const disabled = canvasElement.querySelector<HTMLElement>('[data-disabled] [data-slot="slider-thumb"]')!
|
||||
const result = canvasElement.querySelector<HTMLOutputElement>('[data-testid="slider-focus-result"]')!
|
||||
const states: Record<string, string> = {}
|
||||
const read = (name: string, element = thumb) => { states[name] = getComputedStyle(element).boxShadow }
|
||||
const assert = (condition: boolean, message: string) => { if (!condition) throw Error(message) }
|
||||
thumb.blur()
|
||||
read("Default")
|
||||
// Existing Storybook pseudo-state mirroring preserves production selector order/specificity.
|
||||
thumb.setAttribute("data-sb-hover", "")
|
||||
read("Hover")
|
||||
thumb.setAttribute("data-sb-active", "")
|
||||
read("Drag")
|
||||
thumb.removeAttribute("data-sb-hover")
|
||||
thumb.removeAttribute("data-sb-active")
|
||||
thumb.focus()
|
||||
assert(thumb.matches(":focus-visible"), "Keyboard focus must be visible")
|
||||
read("Focus")
|
||||
thumb.setAttribute("data-sb-hover", "")
|
||||
read("Focus + Hover")
|
||||
thumb.setAttribute("data-sb-active", "")
|
||||
read("Focus + Drag")
|
||||
disabled.setAttribute("data-sb-hover", "")
|
||||
disabled.setAttribute("data-sb-active", "")
|
||||
disabled.focus()
|
||||
read("Disabled", disabled)
|
||||
assert(document.activeElement !== disabled, "Disabled thumb cannot receive focus")
|
||||
assert(states.Default === "none" && states.Disabled === "none", "Default/Disabled have no halo")
|
||||
assert(states.Hover.includes("4px") && states.Drag === states.Hover.replace("4px", "6px"), "Interaction keeps 4/6px and its color")
|
||||
assert(states.Focus.includes("4px") && states["Focus + Hover"] === states.Focus, "Hover cannot mask Focus")
|
||||
assert(states["Focus + Drag"] === states.Focus.replace("4px", "6px"), "Drag keeps 6px without masking Focus color")
|
||||
for (const element of [thumb, disabled]) {
|
||||
element.removeAttribute("data-sb-hover")
|
||||
element.removeAttribute("data-sb-active")
|
||||
}
|
||||
thumb.focus()
|
||||
result.textContent = JSON.stringify(states)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Button, toast, Toaster } from "@hm-design/ui"
|
||||
import { StateBoard } from "./story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "./story-page"
|
||||
|
||||
// Toast is created with the real Sonner API (`toast`, re-exported by @hm-design/ui); nothing is a static specimen.
|
||||
// One <Toaster /> is mounted in .storybook/preview.tsx for every Story (an app mounts it once at its root).
|
||||
|
||||
const wait = (ms: number, fail?: boolean) =>
|
||||
new Promise<string>((resolve, reject) => setTimeout(() => (fail ? reject(new Error("fail")) : resolve("완료")), ms))
|
||||
|
||||
function Trigger({ onClick, children }: { onClick: () => void; children: ReactNode }) {
|
||||
return (
|
||||
<Button variant="primary" appearance="outline" onClick={onClick}>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
// `ids` picks the specimens for one section of the common Story page.
|
||||
function Board({ ids }: { ids: string[] }) {
|
||||
return (
|
||||
<StateBoard
|
||||
min={220}
|
||||
states={[
|
||||
{
|
||||
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>
|
||||
<Trigger onClick={() => toast.success("저장했습니다", { description: "변경 사항이 저장되었습니다." })}>Success</Trigger>
|
||||
<Trigger onClick={() => toast.error("저장하지 못했습니다", { description: "잠시 후 다시 시도하세요." })}>Error</Trigger>
|
||||
<Trigger onClick={() => toast.warning("저장 공간이 부족합니다", { description: "불필요한 파일을 정리하세요." })}>Warning</Trigger>
|
||||
<Trigger onClick={() => toast.info("새 버전이 있습니다")}>Info</Trigger>
|
||||
<Trigger onClick={() => toast.loading("처리 중입니다", { duration: 4000 })}>Loading</Trigger>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "action",
|
||||
label: "Action / Close",
|
||||
children: (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Trigger onClick={() => toast("파일을 삭제했습니다", { action: { label: "실행 취소", onClick: () => toast.success("복원했습니다") } })}>
|
||||
Action
|
||||
</Trigger>
|
||||
<Trigger
|
||||
onClick={() =>
|
||||
toast("변경 사항을 적용할까요?", {
|
||||
description: "취소하면 이전 상태가 유지됩니다.",
|
||||
duration: Infinity,
|
||||
action: { label: "적용", onClick: () => undefined },
|
||||
cancel: { label: "취소", onClick: () => undefined },
|
||||
})
|
||||
}
|
||||
>
|
||||
Action + Cancel
|
||||
</Trigger>
|
||||
<Trigger onClick={() => toast("닫기 버튼이 있는 알림", { closeButton: true, duration: Infinity })}>Close button</Trigger>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
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: "저장하지 못했습니다" })}>
|
||||
Promise → Success
|
||||
</Trigger>
|
||||
<Trigger onClick={() => toast.promise(wait(1500, true), { loading: "저장 중...", success: "저장했습니다", error: "저장하지 못했습니다" })}>
|
||||
Promise → Error
|
||||
</Trigger>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "stack",
|
||||
label: "Stack / Long",
|
||||
children: (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Trigger
|
||||
onClick={() => {
|
||||
toast("첫 번째 알림")
|
||||
toast.success("두 번째 알림")
|
||||
toast.error("세 번째 알림")
|
||||
}}
|
||||
>
|
||||
Stack ×3
|
||||
</Trigger>
|
||||
<Trigger
|
||||
onClick={() =>
|
||||
toast.info("긴 메시지 확인용 제목입니다. 좁은 화면에서도 줄바꿈되어야 합니다.", {
|
||||
description: "설명도 길게 작성하면 여러 줄로 나뉘고 토스트 폭을 넘치지 않아야 합니다. 이 문장은 줄바꿈을 확인하기 위한 예시입니다.",
|
||||
action: { label: "자세히 보기", onClick: () => undefined },
|
||||
closeButton: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
Long message
|
||||
</Trigger>
|
||||
<Trigger onClick={() => toast.dismiss()}>Dismiss all</Trigger>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
].filter((s) => ids.includes(s.id))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = { title: "Components/Feedback/Sonner", component: Toaster, parameters: { layout: "padded" } } satisfies Meta<typeof Toaster>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Representative Story: buttons create real toasts through Sonner (types, action / cancel, close, promise, stack, long message).
|
||||
export const All: Story = {
|
||||
name: "Sonner",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Sonner"
|
||||
description="작업 결과를 잠시 띄우는 Toast 알림입니다. 실제 Sonner API(toast)로 만들며, Toaster는 앱 루트에 한 번 둡니다."
|
||||
/>
|
||||
<StorySection title="Variants" description="버튼을 눌러 각 유형의 Toast를 띄웁니다.">
|
||||
<Board ids={["types"]} />
|
||||
</StorySection>
|
||||
<StorySection title="Usage" description="Action / Cancel / Close, Promise, 여러 개 쌓기와 긴 메시지입니다.">
|
||||
<Board ids={["action", "promise", "stack"]} />
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useContext, type CSSProperties, type ReactNode } from "react"
|
||||
import { StorySectionContext } from "./story-page"
|
||||
|
||||
// Representative-Story layout used by component review pages (ui_viz StateBoard): labeled sections on one page.
|
||||
// It only arranges examples; it never styles the component being reviewed.
|
||||
// `note` is a Story note, not Product UI. It sits under the title by default; `noteAfter` places it below the specimen
|
||||
// (Title → actual UI → Story note) for specimens whose Label/Control must stay directly under the title.
|
||||
export type BoardState = { id: string; label: string; note?: string; noteAfter?: boolean; wide?: boolean; children: ReactNode }
|
||||
|
||||
// `fixed` uses columns of exactly `min` px instead of stretching them (1fr). Specimens whose popup is anchored to a Radix
|
||||
// trigger need whole-pixel columns: a fractional column (e.g. 358.4px) puts the trigger edge between device pixels while the
|
||||
// popper rounds the popup to whole pixels, so the two outlines end up 1px apart.
|
||||
export function StateBoard({ states, min = 320, fixed = false }: { states: BoardState[]; min?: number; fixed?: boolean }) {
|
||||
// Inside a StorySection the labels sit one heading level below the section title.
|
||||
const Label = useContext(StorySectionContext) ? "h3" : "h2"
|
||||
return (
|
||||
<div
|
||||
className={`grid items-start gap-x-6 gap-y-10 ${fixed ? "[grid-template-columns:repeat(auto-fill,min(100%,var(--sb-state-min,320px)))]" : "[grid-template-columns:repeat(auto-fill,minmax(min(100%,var(--sb-state-min,320px)),1fr))]"}`}
|
||||
style={{ "--sb-state-min": `${min}px` } as CSSProperties}
|
||||
>
|
||||
{states.map(({ id, label, note, noteAfter, wide, children }) => (
|
||||
<section
|
||||
className={`grid min-w-0 content-start gap-3${wide ? " col-span-full" : ""}`}
|
||||
key={id}
|
||||
data-overview-state={id}
|
||||
aria-label={label}
|
||||
>
|
||||
<Label className="border-b border-black/10 pb-2 text-sm font-medium text-muted-foreground">{label}</Label>
|
||||
{note && !noteAfter ? <p className="text-sm text-muted-foreground">{note}</p> : null}
|
||||
{children}
|
||||
{note && noteAfter ? <p className="text-sm text-muted-foreground">{note}</p> : null}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/* Storybook presentation only. Common page frame for representative Stories; all selectors stay inside .sb-page.
|
||||
Inner (max 1280, gutter 16–40) and title (kicker 12 / title 28·36·700 / description, underlined) reuse Foundation / Color.
|
||||
Section reuses the Center Graphic / 교량 split: heading + description, a divider, then the specimen. */
|
||||
.sb-page {
|
||||
--sb-page-max-width: 1280px;
|
||||
--sb-page-gutter: clamp(16px, 3vw, 40px);
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding-block: var(--sb-page-gutter);
|
||||
font-family: var(--hm-font-family-base);
|
||||
color: var(--hm-sem-fg-primary);
|
||||
}
|
||||
.sb-page > .sb-page-inner {
|
||||
box-sizing: border-box;
|
||||
/* Keep the established content width: the 1280px envelope includes both gutters. */
|
||||
width: calc(min(100%, var(--sb-page-max-width)) - 2 * var(--sb-page-gutter));
|
||||
min-width: 0;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.sb-page .sb-page-header {
|
||||
padding-bottom: 24px;
|
||||
border-bottom: 1px solid var(--hm-sem-stroke-input-default);
|
||||
font-size: var(--hm-label-font-size);
|
||||
line-height: var(--hm-label-line-height);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.sb-page .sb-page-header .sb-page-kicker {
|
||||
margin: 0 0 12px;
|
||||
color: var(--hm-sem-fg-secondary);
|
||||
font-size: var(--hm-button-font-size-sm);
|
||||
font-weight: var(--hm-font-weight-bold);
|
||||
}
|
||||
.sb-page .sb-page-header .sb-page-title {
|
||||
margin: 8px 0 12px;
|
||||
font-size: var(--hm-page-header-title-font-size);
|
||||
line-height: var(--hm-page-header-title-line-height);
|
||||
font-weight: var(--hm-page-header-title-font-weight);
|
||||
}
|
||||
.sb-page .sb-page-header .sb-page-description { margin: 0 0 12px; font-weight: var(--hm-input-font-weight); }
|
||||
|
||||
.sb-page .sb-page-section { margin-top: 32px; scroll-margin-top: 16px; }
|
||||
.sb-page .sb-page-section-header {
|
||||
padding-bottom: 12px;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid var(--hm-sem-stroke-input-default);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.sb-page .sb-page-section-header .sb-page-section-title {
|
||||
margin: 0;
|
||||
font-size: var(--hm-card-title-font-size);
|
||||
line-height: var(--hm-card-title-line-height);
|
||||
font-weight: var(--hm-card-title-font-weight);
|
||||
}
|
||||
.sb-page .sb-page-section-header .sb-page-section-description {
|
||||
margin: 4px 0 0;
|
||||
color: var(--hm-sem-fg-secondary);
|
||||
font-size: var(--hm-label-font-size);
|
||||
line-height: var(--hm-label-line-height);
|
||||
font-weight: var(--hm-input-font-weight);
|
||||
}
|
||||
.sb-page .sb-page-section-body { min-width: 0; }
|
||||
@@ -0,0 +1,56 @@
|
||||
import { createContext, type ReactNode } from "react"
|
||||
import "./story-page.css"
|
||||
|
||||
// Storybook presentation only (not part of @hm-design/ui): the common frame of a representative Story page.
|
||||
// Page → Header (title, description) → Sections (title, description, specimen). It never styles the component being reviewed.
|
||||
// Inner and title follow Foundation / Color; the section header → content split follows Center Graphic / 교량.
|
||||
// A Story using it sets `parameters.layout: "fullscreen"` so the page owns its inner padding.
|
||||
|
||||
// Lets StateBoard render its specimen labels one level below the section heading.
|
||||
export const StorySectionContext = createContext(false)
|
||||
|
||||
export function StoryPage({ className, children }: { className?: string; children: ReactNode }) {
|
||||
return <div className={className ? `sb-page ${className}` : "sb-page"}><div className="sb-page-inner">{children}</div></div>
|
||||
}
|
||||
|
||||
export function StoryPageHeader({ kicker, title, description }: { kicker: string; title: string; description?: ReactNode }) {
|
||||
return (
|
||||
<header className="sb-page-header">
|
||||
<p className="sb-page-kicker">{kicker}</p>
|
||||
<h1 className="sb-page-title">{title}</h1>
|
||||
{description ? <p className="sb-page-description">{description}</p> : null}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
// Headings and specimens share StoryPage's single Inner. Wide specimens own their local scroll frame.
|
||||
export function StorySection({
|
||||
title,
|
||||
description,
|
||||
id,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
description?: ReactNode
|
||||
id?: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<section className="sb-page-section" id={id} aria-label={title}>
|
||||
<header className="sb-page-section-header">
|
||||
<h2 className="sb-page-section-title">{title}</h2>
|
||||
{description ? <p className="sb-page-section-description">{description}</p> : null}
|
||||
</header>
|
||||
<StorySectionContext.Provider value>
|
||||
<div className="sb-page-section-body">{children}</div>
|
||||
</StorySectionContext.Provider>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export const STORY_KICKER = {
|
||||
foundation: "HM DESIGN SYSTEM / FOUNDATION",
|
||||
components: "HM DESIGN SYSTEM / COMPONENTS",
|
||||
patterns: "HM DESIGN SYSTEM / PATTERNS",
|
||||
center: "HM DESIGN SYSTEM / CENTER GRAPHIC",
|
||||
} as const
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useId, useState, 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 { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"현재 HM 규격: 트랙 36×20, 썸 16, On은 state-active, Off는 stroke 색. ui-viz에는 Switch 규격이 없어 참고만 했습니다. Radix Switch의 role=switch, 키보드, 접근성과 shadcn 기본 transition을 유지합니다.",
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Switch>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const All: Story = {
|
||||
name: "Switch",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Switch"
|
||||
description="설정을 즉시 켜고 끄는 컨트롤입니다. 트랙 36×20, 썸 16이며 On은 state-active 색입니다."
|
||||
/>
|
||||
<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>
|
||||
<StorySection title="States">
|
||||
<StateBoard
|
||||
min={240}
|
||||
states={[
|
||||
{ id: "hover", label: "Hover", noteAfter: true, note: "Black 20% overlay. 포인터를 올려 확인합니다.", children: <Option label="Option">{(id) => <Switch id={id} />}</Option> },
|
||||
{ id: "focus", label: "Focus", children: <Option label="Option">{(id) => <Switch id={id} />}</Option> },
|
||||
{
|
||||
id: "disabled",
|
||||
label: "Disabled",
|
||||
children: (
|
||||
<div className="grid gap-3">
|
||||
<Option label="Off">{(id) => <Switch id={id} disabled />}</Option>
|
||||
<Option label="On">{(id) => <Switch id={id} disabled defaultChecked />}</Option>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
// preventScroll: the page opens at its header, not at the States section.
|
||||
canvasElement.querySelector<HTMLButtonElement>('[data-overview-state="focus"] [role="switch"]')?.focus({ preventScroll: true })
|
||||
},
|
||||
}
|
||||
|
||||
export const Off: Story = { tags: ["verification"], render: () => <Option label="Option">{(id) => <Switch id={id} />}</Option> }
|
||||
export const On: Story = { tags: ["verification"], render: () => <Option label="Option">{(id) => <Switch id={id} defaultChecked />}</Option> }
|
||||
export const Disabled: Story = { tags: ["verification"], render: () => <Option label="Option">{(id) => <Switch id={id} disabled defaultChecked />}</Option> }
|
||||
@@ -0,0 +1,170 @@
|
||||
import { useState, type ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Badge, Checkbox, Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Table is a static display primitive, so the representative Story is a board of specimens. Checkbox and Badge appear only as
|
||||
// cell content (Story composition); Table has no dependency on them and no selection, sorting or paging behavior.
|
||||
// The Story keeps the selected rows in local state and passes `data-state="selected"` itself.
|
||||
|
||||
const projects = [
|
||||
{ id: "P-001", name: "한맥 노선 정비", status: "진행", amount: 1280000 },
|
||||
{ id: "P-002", name: "시설물 점검 리포트", status: "완료", amount: 450000 },
|
||||
{ id: "P-003", name: "교량 안전 진단", status: "대기", amount: 9800000 },
|
||||
]
|
||||
const won = (n: number) => n.toLocaleString("ko-KR")
|
||||
|
||||
function BasicTable() {
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>번호</TableHead>
|
||||
<TableHead>프로젝트</TableHead>
|
||||
<TableHead>상태</TableHead>
|
||||
<TableHead className="text-right">금액</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{projects.map((p) => (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell>{p.id}</TableCell>
|
||||
<TableCell>{p.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge>{p.status}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{won(p.amount)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
<TableFooter>
|
||||
<TableRow>
|
||||
<TableCell colSpan={3}>합계</TableCell>
|
||||
<TableCell className="text-right">{won(projects.reduce((s, p) => s + p.amount, 0))}</TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
<TableCaption>프로젝트별 계약 금액(원)</TableCaption>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectableTable() {
|
||||
const [selected, setSelected] = useState<string[]>(["P-002"])
|
||||
const toggle = (id: string) => setSelected((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id]))
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10">
|
||||
<Checkbox
|
||||
aria-label="전체 선택"
|
||||
checked={selected.length === projects.length ? true : selected.length ? "indeterminate" : false}
|
||||
onCheckedChange={(v) => setSelected(v ? projects.map((p) => p.id) : [])}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>프로젝트</TableHead>
|
||||
<TableHead className="text-right">금액</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{projects.map((p) => (
|
||||
<TableRow key={p.id} data-state={selected.includes(p.id) ? "selected" : undefined}>
|
||||
<TableCell>
|
||||
<Checkbox aria-label={`${p.name} 선택`} checked={selected.includes(p.id)} onCheckedChange={() => toggle(p.id)} />
|
||||
</TableCell>
|
||||
<TableCell>{p.name}</TableCell>
|
||||
<TableCell className="text-right">{won(p.amount)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
function LongTextTable() {
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>제목</TableHead>
|
||||
<TableHead>비고</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>긴 텍스트는 셀 안에서 줄바꿈되고 행 높이만 늘어납니다. 공백 없는 문자열도 열 폭을 밀어내지 않습니다.</TableCell>
|
||||
<TableCell>AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>짧은 행</TableCell>
|
||||
<TableCell>-</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
function WideTable() {
|
||||
const cols = Array.from({ length: 10 }, (_, i) => `컬럼 ${i + 1}`)
|
||||
return (
|
||||
<Table style={{ minWidth: 1100 }}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{cols.map((c) => (
|
||||
<TableHead key={c}>{c}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{Array.from({ length: 3 }, (_, r) => (
|
||||
<TableRow key={r}>
|
||||
{cols.map((c) => (
|
||||
<TableCell key={c}>{`값 ${r + 1}-${c.slice(3)}`}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
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: "long", label: "Long text", node: <LongTextTable /> },
|
||||
{ id: "wide", label: "Horizontal overflow", note: "container가 가로로 scroll 하고 페이지는 밀리지 않습니다", node: <WideTable /> },
|
||||
]
|
||||
|
||||
const meta = { title: "Components/Data & Display/Table", component: Table, parameters: { layout: "padded" } } satisfies Meta<typeof Table>
|
||||
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) }))} />
|
||||
)
|
||||
|
||||
// Representative Story: the common Story page; the sections group the same specimens.
|
||||
export const All: Story = {
|
||||
name: "Table",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Table"
|
||||
description="행과 열로 데이터를 보여주는 정적 표시 primitive입니다. 정렬·선택·페이지 로직은 Data Table Pattern이 담당합니다."
|
||||
/>
|
||||
<StorySection title="Overview">{board("basic")}</StorySection>
|
||||
<StorySection title="States">{board("selected")}</StorySection>
|
||||
<StorySection title="Usage" description="긴 텍스트와 가로 넘침입니다.">{board("long", "wide")}</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const Basic: Story = { tags: ["verification"], render: () => box(<BasicTable />) }
|
||||
export const Selected: Story = { tags: ["verification"], render: () => box(<SelectableTable />) }
|
||||
export const LongText: Story = { tags: ["verification"], name: "Long Text", render: () => box(<LongTextTable />) }
|
||||
export const Overflow: Story = { tags: ["verification"], name: "Horizontal Overflow", render: () => box(<WideTable />) }
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import type { ComponentProps } from "react"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Representative "Tabs" Story shows default Primary, explicit API comparisons and Secondary; Legacy stays verification-only.
|
||||
// Selected connects to the content surface, so every specimen puts the Tabs on a plain bordered panel supplied by the consumer
|
||||
// (TabsContent here). The panel is layout, not part of Tabs.
|
||||
|
||||
type Variant = "primary" | "secondary"
|
||||
type ListProps = Pick<ComponentProps<typeof TabsList>, "fill" | "panelSurface">
|
||||
|
||||
// Primary = Fill + panelSurface="content", Secondary = Fill + panelSurface="surface" (2026-10-06 사용자 결정).
|
||||
const variantProps = {
|
||||
primary: { listProps: {}, panel: "border bg-background" },
|
||||
secondary: { listProps: { fill: true, panelSurface: "surface" }, panel: "border bg-card" },
|
||||
} as const
|
||||
|
||||
function Specimen({
|
||||
variant = "primary",
|
||||
defaultValue = "menu2",
|
||||
disabledSecond,
|
||||
count = 3,
|
||||
labels,
|
||||
listProps,
|
||||
}: {
|
||||
variant?: Variant
|
||||
defaultValue?: string
|
||||
disabledSecond?: boolean
|
||||
count?: number
|
||||
labels?: string[]
|
||||
listProps?: ListProps
|
||||
}) {
|
||||
const { listProps: defaults, panel } = variantProps[variant]
|
||||
const items = labels?.map((label, i) => ({ value: `menu${i + 1}`, label }))
|
||||
?? Array.from({ length: count }, (_, i) => ({ value: `menu${i + 1}`, label: `메뉴 ${i + 1}` }))
|
||||
return (
|
||||
<Tabs defaultValue={defaultValue}>
|
||||
<TabsList aria-label="업무 필터" {...(listProps ?? defaults)}>
|
||||
{items.map(({ value, label }, i) => (
|
||||
<TabsTrigger key={value} value={value} disabled={disabledSecond && i === 1}>
|
||||
{label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{items.map(({ value }, i) => (
|
||||
<TabsContent key={value} value={value} className={`rounded-b p-4 text-sm ${panel}`}>
|
||||
메뉴 {i + 1} 콘텐츠
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
|
||||
const longLabels = ["기본 정보", "보상 협의 및 계약 진행 현황", "토지 및 지장물 조사 내역"]
|
||||
|
||||
function LongLabelSpecimens() {
|
||||
return (
|
||||
<div className="grid min-w-0 max-w-full gap-5">
|
||||
<div className="min-w-0">
|
||||
<p className="mb-2 text-xs text-muted-foreground">짧은 label</p>
|
||||
<div className="w-full min-w-0 max-w-sm"><Specimen defaultValue="menu1" /></div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="mb-2 text-xs text-muted-foreground">긴 label 1개</p>
|
||||
<div className="w-full min-w-0 max-w-lg"><Specimen labels={["기본 정보", longLabels[1], "조사 내역"]} /></div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="mb-2 text-xs text-muted-foreground">긴 label 여러 개</p>
|
||||
<div className="w-full min-w-0 max-w-2xl"><Specimen labels={longLabels} /></div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="mb-2 text-xs text-muted-foreground">좁은 container · nowrap · overflow controls</p>
|
||||
<div className="w-full min-w-0 max-w-[260px]"><Specimen labels={longLabels} /></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Components/Navigation/Tabs",
|
||||
component: Tabs,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"기본 TabsList는 Primary(fill=true, panelSurface=\"content\")입니다. 대표 Story는 기본 API와 명시적 Primary, Secondary(fill + panelSurface=\"surface\"), Primary Overflow를 비교합니다. Legacy non-fill은 fill={false}로 유지하며 숨김 Compatibility에서 확인합니다. 높이 34, 좌우 16, 상단 radius 4, 16/24/700과 8px 곡선 연결은 기존 값 그대로입니다. Selected는 패널 배경과 이어지고 Hover는 비선택에만 Black 20%입니다. 넘칠 때만 이전/다음 이동 버튼이 나타납니다. 곡선 반경 8px과 비선택 합성색의 과거 REVIEW / TEMPORARY 이력은 유지합니다.",
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Tabs>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const All: Story = {
|
||||
name: "Tabs",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Tabs"
|
||||
description="같은 영역의 콘텐츠를 전환하는 탭입니다. 선택된 탭은 하단으로 콘텐츠 면과 이어지며, 넘칠 때만 이전/다음 이동 버튼이 나타납니다."
|
||||
/>
|
||||
<StorySection title="Overview">
|
||||
<StateBoard
|
||||
states={[
|
||||
{ id: "primary", label: "Primary · 기본 TabsList", noteAfter: true, note: "fill / panelSurface 생략 → Primary. 선택 면이 위 White → 아래 content panel색 gradient로 콘텐츠와 이어집니다. 비선택 탭에 hover하면 Black 20% overlay가 적용됩니다.", 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" }} /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Variants">
|
||||
<StateBoard
|
||||
states={[
|
||||
{ id: "secondary", label: "Secondary · Fill + panelSurface surface", noteAfter: true, note: "서브형. Surface(White) 위에서 사용하며 선택 면이 White로 이어집니다.", children: <Specimen variant="secondary" /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="States">
|
||||
<StateBoard
|
||||
states={[
|
||||
{ id: "overflow", label: "Overflow · Primary + 이동 버튼", noteAfter: true, note: "Primary Tabs에 탭이 많거나 가용 폭이 좁으면 이전/다음 이동 버튼이 나타납니다. 버튼은 선택을 바꾸지 않고 목록만 한 항목씩 이동합니다. 260px 폭은 Story wrapper의 검증 폭이며 Tabs 자체 폭이 아닙니다.", children: <div className="max-w-[260px]"><Specimen count={8} /></div> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
// 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> }
|
||||
export const SelectedLast: Story = { tags: ["verification"], name: "Selected Last", render: () => <div className="max-w-md"><Specimen defaultValue="menu3" /></div> }
|
||||
export const Focus: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => <div className="max-w-md"><Specimen /></div>,
|
||||
play: async ({ canvasElement }) => canvasElement.querySelector<HTMLButtonElement>('[role="tab"][data-state="active"]')?.focus(),
|
||||
}
|
||||
export const Disabled: Story = { tags: ["verification"], render: () => <div className="max-w-md"><Specimen disabledSecond /></div> }
|
||||
export const Overflow: Story = { tags: ["verification"], render: () => <div className="max-w-[260px]"><Specimen count={8} /></div> }
|
||||
export const SecondaryOverflow: Story = { tags: ["verification"], name: "Secondary Overflow", render: () => <div className="max-w-[260px]"><Specimen variant="secondary" count={8} /></div> }
|
||||
export const LongLabel: Story = { tags: ["verification"], name: "Long Label", render: () => <LongLabelSpecimens /> }
|
||||
|
||||
export const Compatibility: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader kicker="VERIFICATION ONLY" title="Tabs Compatibility" description="Legacy non-fill은 OLD_TEST_ONLY / REMOVE_CANDIDATE 이력을 보존하며 명시적으로 사용할 수 있습니다. 사용자 별도 승인 없이 삭제하지 않습니다." />
|
||||
<StorySection title="Legacy non-fill">
|
||||
<StateBoard states={[
|
||||
{ id: "legacy", label: "TabsList fill={false}", noteAfter: true, note: "panelSurface 기본 content는 non-fill 시각에 영향을 주지 않습니다. 패널은 기존 White surface를 유지합니다.", children: <Specimen variant="secondary" listProps={{ fill: false }} /> },
|
||||
{ id: "legacy-surface", label: "TabsList fill={false} panelSurface surface", noteAfter: true, note: "이전 기본값을 모두 명시한 경우에도 같은 Legacy 디자인입니다.", children: <Specimen variant="secondary" listProps={{ fill: false, panelSurface: "surface" }} /> },
|
||||
]} />
|
||||
</StorySection>
|
||||
<StorySection title="Explicit panelSurface">
|
||||
<StateBoard states={[
|
||||
{ id: "surface-only", label: "TabsList panelSurface surface", noteAfter: true, note: "fill 생략 → true이므로 Secondary가 됩니다.", children: <Specimen variant="secondary" listProps={{ panelSurface: "surface" }} /> },
|
||||
]} />
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
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 { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Story structure follows Input: one representative "Textarea" Story shows every state on one page; the same specimens
|
||||
// also exist as separate Stories tagged `verification`, hidden from the sidebar by default (see .storybook/main.ts).
|
||||
// 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.
|
||||
|
||||
function Specimen({
|
||||
label = "Description",
|
||||
message,
|
||||
error,
|
||||
required,
|
||||
id,
|
||||
...textarea
|
||||
}: ComponentProps<typeof Textarea> & { label?: string; message?: string; error?: boolean }) {
|
||||
const uid = useId()
|
||||
const fieldId = id ?? `textarea-${uid}`
|
||||
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}
|
||||
{required ? (
|
||||
<span aria-hidden="true" className="text-destructive">
|
||||
{" *"}
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
<Textarea
|
||||
id={fieldId}
|
||||
required={required}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={message ? messageId : undefined}
|
||||
{...textarea}
|
||||
/>
|
||||
{message ? (
|
||||
<p id={messageId} className={error ? "text-[11px] leading-[18px] text-destructive" : "text-xs leading-[18px] text-muted-foreground"}>
|
||||
{message}
|
||||
</p>
|
||||
) : null}
|
||||
</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."
|
||||
|
||||
const hoverNote = "Hover는 Default와 같습니다(사용자 결정 TEXTAREA-05). 포인터를 올려도 면, 테두리, 글자색이 바뀌지 않습니다."
|
||||
const readonlyNote =
|
||||
"Readonly는 Default의 면, 테두리, typography를 유지하고 흐리게 표시하지 않습니다. 값 변경은 막고 선택·복사는 허용하며 resize 표시가 없습니다."
|
||||
|
||||
const meta = {
|
||||
title: "Components/Form/Textarea",
|
||||
component: Textarea,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"ui_viz docs/components/textarea.md: 최소 높이 112px, 안쪽 여백 8/12px(Textarea 한정 owner 승인, SCOPED_DESIGN_APPROVAL), 값 16/24/400, resize는 기본 vertical. Focus와 Error는 1.5px 테두리로 구분하며 shadow·glow를 쓰지 않습니다. Hover는 Default와 같습니다. Label·Helper·Error 문구는 Textarea가 아니라 소비 조합입니다. size 체계는 없습니다.",
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
resize: {
|
||||
control: "inline-radio",
|
||||
options: ["vertical", "none", "both"],
|
||||
description: "기본 vertical. Readonly에서는 항상 none.",
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Textarea>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Representative Story: every state on one page. Focus is placed by the play function. Named after the component, as in ui_viz.
|
||||
export const All: Story = {
|
||||
name: "Textarea",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Textarea"
|
||||
description="여러 줄 텍스트를 입력하는 컨트롤입니다. 최소 높이 112px, resize 기본 vertical이며 size 체계는 없습니다. Label·Helper·Error 문구는 소비 조합입니다."
|
||||
/>
|
||||
<StorySection title="Overview">
|
||||
<StateBoard states={[{ id: "default", label: "Default", children: <Specimen placeholder={placeholder} /> }]} />
|
||||
</StorySection>
|
||||
<StorySection title="States" description="Focus는 1.5px 테두리로 구분하며 shadow·glow를 쓰지 않습니다. Hover는 Default와 같습니다.">
|
||||
<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: "disabled", label: "Disabled", children: <Specimen disabled defaultValue="Unavailable description" /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Validation" description="aria-invalid로 Error 테두리를 표시하고, 오류 문구는 소비 조합으로 연결합니다.">
|
||||
<StateBoard
|
||||
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" description="줄바꿈 유지와 resize 해제입니다.">
|
||||
<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 }) => {
|
||||
// preventScroll: the page opens at its header, not at the States section.
|
||||
canvasElement.querySelector<HTMLTextAreaElement>('[data-overview-state="focus"] textarea')?.focus({ preventScroll: true })
|
||||
},
|
||||
}
|
||||
|
||||
// Verification Stories: the same specimens as separate Stories, hidden from the sidebar (tag `verification`), reachable by URL or the sidebar tag filter.
|
||||
export const Default: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => narrow(<Specimen placeholder={placeholder} />),
|
||||
}
|
||||
export const Focus: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => narrow(<Specimen placeholder={placeholder} />),
|
||||
play: async ({ canvasElement }) => canvasElement.querySelector("textarea")?.focus(),
|
||||
parameters: { docs: { description: { story: "Focus는 실제로 focus를 받은 컨트롤에서만 보입니다. play 함수가 focus를 줍니다. Tab 또는 클릭으로도 확인합니다." } } },
|
||||
}
|
||||
export const Error: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => narrow(<Specimen error defaultValue="Invalid value" />),
|
||||
}
|
||||
export const InvalidFocus: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => narrow(<Specimen error defaultValue="Invalid value" />),
|
||||
play: async ({ canvasElement }) => canvasElement.querySelector("textarea")?.focus(),
|
||||
}
|
||||
export const ErrorMessage: Story = {
|
||||
tags: ["verification"],
|
||||
name: "Error Message",
|
||||
render: () => narrow(<Specimen error message="설명을 입력하세요." />),
|
||||
}
|
||||
export const Required: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => narrow(<Specimen required />),
|
||||
}
|
||||
export const Readonly: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => narrow(<Specimen readOnly defaultValue="Confirmed description" />),
|
||||
parameters: { docs: { description: { story: readonlyNote } } },
|
||||
}
|
||||
export const Disabled: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => narrow(<Specimen disabled defaultValue="Unavailable description" />),
|
||||
}
|
||||
export const LongContent: Story = {
|
||||
tags: ["verification"],
|
||||
name: "Long Content",
|
||||
render: () =>
|
||||
narrow(
|
||||
<Specimen
|
||||
defaultValue={Array.from({ length: 12 }, (_, i) => `Line ${i + 1}: a long value keeps its line order and scrolls inside the control.`).join("\n")}
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
// HM only: not part of the ui_viz Textarea Stories. Tagged verification so the default sidebar keeps only the representative Story.
|
||||
export const Playground: Story = {
|
||||
tags: ["verification"],
|
||||
args: { resize: "vertical", placeholder, disabled: false, readOnly: false },
|
||||
argTypes: {
|
||||
placeholder: { control: "text" },
|
||||
disabled: { control: "boolean" },
|
||||
readOnly: { control: "boolean" },
|
||||
"aria-invalid": { control: "boolean" },
|
||||
className: { table: { disable: true } },
|
||||
},
|
||||
render: (args) => narrow(<Specimen {...args} />),
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useState } 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 { STORY_KICKER, 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() {
|
||||
const [value, setValue] = useState("left")
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<ToggleGroup type="single" variant="outline" value={value} onValueChange={(v) => v && setValue(v)} aria-label="정렬">
|
||||
<ToggleGroupItem value="left">왼쪽</ToggleGroupItem>
|
||||
<ToggleGroupItem value="center">가운데</ToggleGroupItem>
|
||||
<ToggleGroupItem value="right">오른쪽</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<span className="text-sm text-muted-foreground" data-testid="single">{`value = ${value}`}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Multiple() {
|
||||
const [value, setValue] = useState<string[]>(["bold"])
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<ToggleGroup type="multiple" variant="outline" value={value} onValueChange={setValue} aria-label="서식">
|
||||
<ToggleGroupItem value="bold" aria-label="굵게">
|
||||
<BoldIcon />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="italic" aria-label="기울임">
|
||||
<ItalicIcon />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="underline" aria-label="밑줄">
|
||||
<UnderlineIcon />
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<span className="text-sm text-muted-foreground" data-testid="multiple">{`value = [${value.join(", ")}]`}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Disabled() {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
function Vertical() {
|
||||
return (
|
||||
<ToggleGroup type="single" variant="outline" orientation="vertical" defaultValue="left" aria-label="세로 정렬">
|
||||
<ToggleGroupItem value="left" aria-label="왼쪽">
|
||||
<AlignLeftIcon />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="center" aria-label="가운데">
|
||||
<AlignCenterIcon />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="right" aria-label="오른쪽">
|
||||
<AlignRightIcon />
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
)
|
||||
}
|
||||
|
||||
function Spaced() {
|
||||
return (
|
||||
<ToggleGroup type="multiple" variant="outline" spacing={2} aria-label="간격 있는 그룹">
|
||||
<ToggleGroupItem value="a">첫째</ToggleGroupItem>
|
||||
<ToggleGroupItem value="b">둘째</ToggleGroupItem>
|
||||
<ToggleGroupItem value="c">셋째</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
)
|
||||
}
|
||||
|
||||
function Many() {
|
||||
return (
|
||||
<ToggleGroup type="multiple" variant="outline" spacing={1} className="flex-wrap" aria-label="많은 항목">
|
||||
{Array.from({ length: 10 }, (_, i) => (
|
||||
<ToggleGroupItem key={i} value={`${i}`}>{`옵션 ${i + 1}`}</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
)
|
||||
}
|
||||
|
||||
function Sizes() {
|
||||
return (
|
||||
<div className="grid gap-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>
|
||||
<ToggleGroupItem value="mixed"><BoldIcon />굵게</ToggleGroupItem>
|
||||
<ToggleGroupItem value="text">{size}</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 }))} />
|
||||
)
|
||||
|
||||
// Representative Story: the common Story page; the sections group the same specimens.
|
||||
export const All: Story = {
|
||||
name: "Toggle Group",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Toggle Group"
|
||||
description="눌림 상태를 갖는 항목 묶음(툴바형)입니다. 내용을 전환하는 용도는 Tabs를 씁니다."
|
||||
/>
|
||||
<StorySection title="Overview">{board("single")}</StorySection>
|
||||
<StorySection title="Sizes" description="Icon-only / Icon + Text / Text-only.">
|
||||
<Sizes />
|
||||
</StorySection>
|
||||
<StorySection title="Variants" description="다중 선택, 세로 방향, 항목 간격(spacing)입니다.">{board("multiple", "vertical", "spaced")}</StorySection>
|
||||
<StorySection title="States">{board("disabled")}</StorySection>
|
||||
<StorySection title="Usage">{board("many")}</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
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 /> }
|
||||
|
||||
export const ContentNodes: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => (
|
||||
<ToggleGroup type="multiple" variant="outline" aria-label="Content nodes">
|
||||
<ToggleGroupItem value="array" aria-label="Array icon">{[<BoldIcon key="icon" />]}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="fragment" aria-label="Fragment icon"><><BoldIcon /></></ToggleGroupItem>
|
||||
<ToggleGroupItem value="mixed"><><BoldIcon />굵게</></ToggleGroupItem>
|
||||
<ToggleGroupItem value="span"><BoldIcon /><span>굵게</span></ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useState } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { BoldIcon, ItalicIcon, StarIcon } from "lucide-react"
|
||||
import { Toggle } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { STORY_KICKER, 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
|
||||
// specimens also exist as Stories tagged `verification` (hidden from the sidebar). Icon-only toggles carry an `aria-label`.
|
||||
|
||||
function OffOn() {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Toggle>Off</Toggle>
|
||||
<Toggle defaultPressed>On</Toggle>
|
||||
<Toggle variant="outline">Outline off</Toggle>
|
||||
<Toggle variant="outline" defaultPressed>
|
||||
Outline on
|
||||
</Toggle>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WithIcon() {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Toggle aria-label="굵게">
|
||||
<BoldIcon />
|
||||
</Toggle>
|
||||
<Toggle aria-label="기울임" defaultPressed>
|
||||
<ItalicIcon />
|
||||
</Toggle>
|
||||
<Toggle variant="outline">
|
||||
<StarIcon />
|
||||
즐겨찾기
|
||||
</Toggle>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Sizes() {
|
||||
return (
|
||||
<div className="grid gap-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>
|
||||
<Toggle size={size} variant="outline"><StarIcon />즐겨찾기</Toggle>
|
||||
<Toggle size={size} variant="outline">{size}</Toggle>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Disabled() {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Toggle disabled>Disabled off</Toggle>
|
||||
<Toggle disabled defaultPressed>
|
||||
Disabled on
|
||||
</Toggle>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
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 Story: the common Story page; the sections group the same specimens.
|
||||
export const All: Story = {
|
||||
name: "Toggle",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Toggle"
|
||||
description="눌림(On) 상태를 유지하는 단일 버튼입니다."
|
||||
/>
|
||||
<StorySection title="Overview">{board("offon")}</StorySection>
|
||||
<StorySection title="Sizes">
|
||||
<Sizes />
|
||||
</StorySection>
|
||||
<StorySection title="Variants" description="아이콘만, 아이콘 + 텍스트입니다.">{board("icon")}</StorySection>
|
||||
<StorySection title="States">{board("disabled", "controlled")}</StorySection>
|
||||
<StorySection title="Usage" description="className 확장입니다.">{board("extended")}</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
export const States: Story = { tags: ["verification"], render: () => <OffOn /> }
|
||||
export const IconOnly: Story = { tags: ["verification"], name: "Icon Only", render: () => <WithIcon /> }
|
||||
export const DisabledToggle: Story = { tags: ["verification"], name: "Disabled", render: () => <Disabled /> }
|
||||
|
||||
export const ContentNodes: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Toggle aria-label="Array icon">{[<StarIcon key="icon" />]}</Toggle>
|
||||
<Toggle aria-label="Fragment icon"><><StarIcon /></></Toggle>
|
||||
<Toggle><><StarIcon />즐겨찾기</></Toggle>
|
||||
<Toggle><StarIcon /><span>즐겨찾기</span></Toggle>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@hm-design/ui"
|
||||
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Tooltip is a Radix overlay (Portal) with short, non-interactive text. The representative Story shows real triggers
|
||||
// (hover or focus to open); the verification Story starts one open. Interactive content belongs in Popover / Hover Card.
|
||||
|
||||
const longText = "긴 설명은 최대 폭에서 줄바꿈됩니다. 이 정도 길이가 Tooltip의 한계이며, 더 긴 내용은 Popover나 Hover Card를 사용합니다."
|
||||
|
||||
function Basic({ defaultOpen }: { defaultOpen?: boolean }) {
|
||||
return (
|
||||
<Tooltip defaultOpen={defaultOpen}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="primary" appearance="outline">
|
||||
기본
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>저장하지 않은 변경 사항이 있습니다</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function Placements() {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{(["top", "right", "bottom", "left"] as const).map((side) => (
|
||||
<Tooltip key={side}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="primary" appearance="outline" size="sm">
|
||||
{side}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={side}>{`side="${side}"`}</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LongText() {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="primary" appearance="outline">
|
||||
긴 텍스트
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{longText}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function Disabled() {
|
||||
// A disabled button gets no pointer events, so the trigger is a wrapper element.
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span tabIndex={0} className="inline-block">
|
||||
<Button variant="primary" appearance="solid" disabled>
|
||||
비활성
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>권한이 없어 사용할 수 없습니다</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = { title: "Components/Overlay/Tooltip", component: Tooltip, parameters: { layout: "centered" } } satisfies Meta<typeof Tooltip>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const All: Story = {
|
||||
name: "Tooltip",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<TooltipProvider>
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
kicker={STORY_KICKER.components}
|
||||
title="Tooltip"
|
||||
description="짧고 상호작용 없는 설명을 띄우는 overlay입니다. Trigger에 포인터를 올리거나 포커스하면 열립니다."
|
||||
/>
|
||||
<StorySection title="Overview" description="기본 Tooltip입니다.">
|
||||
<Basic />
|
||||
</StorySection>
|
||||
<StorySection title="Variants" description="side: top / right / bottom / left.">
|
||||
<Placements />
|
||||
</StorySection>
|
||||
<StorySection title="Usage" description="긴 문구는 줄바꿈됩니다. Disabled 버튼은 포인터 이벤트가 없으므로 감싼 요소를 Trigger로 씁니다.">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<LongText />
|
||||
<Disabled />
|
||||
</div>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
</TooltipProvider>
|
||||
),
|
||||
}
|
||||
|
||||
export const Open: Story = {
|
||||
tags: ["verification"],
|
||||
parameters: { layout: "fullscreen" },
|
||||
render: () => (
|
||||
<div className="flex min-h-[200px] items-end justify-center p-8">
|
||||
<Basic defaultOpen />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
Reference in new issue
Block a user