141 lines
6.2 KiB
TypeScript
141 lines
6.2 KiB
TypeScript
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>
|
||
),
|
||
}
|