Skip to content
4 changes: 3 additions & 1 deletion packages/design-system/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ export { default as Card } from './card/Card';
export { default as Chip } from './chip/Chip';
export { default as Input } from './input/Input';
export { default as Level } from './level/Level';
export { Progress } from './progress/Progress';
export { Switch } from './switch/Switch';
export { Textarea } from './textarea/Textarea';
export { Progress } from './progress/Progress';
export { default as AutoDismissToast } from './toast/hooks/uesFadeOut';
export { default as Toast } from './toast/Toast';
115 changes: 115 additions & 0 deletions packages/design-system/src/components/toast/Toast.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Storybook 타입 import 경로 오류 가능성(@storybook/react 권장)

타입은 일반적으로 @storybook/react에서 가져옵니다. @storybook/react-vite는 프레임워크 설정 패키지로 TS 타입을 내보내지 않는 구성이 많아 빌드가 실패할 수 있습니다. 아래처럼 수정해 주세요.

-import type { Meta, StoryObj } from '@storybook/react-vite';
+import type { Meta, StoryObj } from '@storybook/react';
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import type { Meta, StoryObj } from '@storybook/react-vite';
import type { Meta, StoryObj } from '@storybook/react';
🤖 Prompt for AI Agents
In packages/design-system/src/components/toast/Toast.stories.tsx around line 1,
the Storybook type import uses '@storybook/react-vite' which may not export TS
types and can break builds; change the import to pull Meta and StoryObj from
'@storybook/react' instead so types come from the official React Storybook
package.

import React from 'react';
import { within, userEvent, expect, waitFor } from '@storybook/test';
import AutoDismissToast from './hooks/uesFadeOut';
import Toast from './Toast';

type AutoDismissStoryArgs = {
text: string;
duration: number;
fadeMs: number;
className?: string;
};

const meta = {
title: 'Components/Toast',
tags: ['autodocs'],
parameters: {
layout: 'centered',
docs: {
description: {
component:
'`AutoDismissToast`는 표시/페이드/언마운트만 담당하고, **콘텐츠는 children**으로 받습니다. ' +
'스토리 컨트롤의 `text`는 children `<Toast>`로 전달합니다.',
},
},
},
argTypes: {
text: { control: 'text', description: 'children <Toast>에 전달할 문구' },
duration: { control: 'number', description: '표시 시간(ms), 기본 3000' },
fadeMs: { control: 'number', description: '페이드아웃(ms), 기본 200' },
className: { table: { disable: true } },
},
args: {
text: '저장에 실패했어요.\n다시 시도해주세요.',
duration: 3000,
fadeMs: 200,
},
} satisfies Meta<AutoDismissStoryArgs>;

export default meta;
type Story = StoryObj<AutoDismissStoryArgs>;

const ManualTriggerExample: React.FC<AutoDismissStoryArgs> = ({
text,
duration,
fadeMs,
}) => {
const [show, setShow] = React.useState(false);

return (
<div className="p-10" style={{ minHeight: '25vh' }}>
<button
type="button"
onClick={() => setShow(true)}
style={{
background: 'gray',
color: 'white',
padding: '8px 16px',
borderRadius: 4,
}}
>
토스트 띄우기
</button>

{show && (
<div className="fixed bottom-6 left-6 z-[9999]">
<AutoDismissToast
duration={duration}
fadeMs={fadeMs}
onClose={() => setShow(false)}
>
<Toast text={text} />
</AutoDismissToast>
</div>
)}
</div>
);
};

export const ManualTrigger: Story = {
name: '버튼으로 띄우기 (AutoDismiss)',
parameters: { layout: 'fullscreen', docs: { story: { height: 520 } } },
render: (args) => <ManualTriggerExample {...args} />,
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);

// 버튼 클릭 → 토스트 등장
const btn = await canvas.findByRole('button', { name: '토스트 띄우기' });
await userEvent.click(btn);
await expect(await canvas.findByRole('alert')).toBeInTheDocument();

// duration + fadeMs 후 사라짐 검증
const timeout = (args.duration ?? 3000) + (args.fadeMs ?? 200) + 400;
await waitFor(() => expect(canvas.queryByRole('alert')).toBeNull(), {
timeout,
});
},
};
export const Static_Basic: Story = {
name: '정적 UI — 기본',
parameters: { layout: 'centered' },
render: (args) => <Toast text={args.text} />,
};

export const Static_LongText: Story = {
name: '정적 UI — 긴 문구',
parameters: { layout: 'centered' },
render: () => (
<Toast
text={
'네트워크 상태가 불안정합니다.\n잠시 후 다시 시도해주세요. 문제가 계속되면 관리자에게 문의해 주세요.'
}
/>
),
};
21 changes: 21 additions & 0 deletions packages/design-system/src/components/toast/Toast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { cn } from '@/lib/utils';

export interface ToastProps {
text: string;
}

export default function Toast({ text }: ToastProps) {
return (
<div
role="alert"
aria-live="polite"
Comment on lines +10 to +11
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

aria-live 적용까지 꼼꼼하시네요

className={cn(
'bg-gray800 text-white-bg',
'rounded-[0.8rem] px-[1.6rem] py-[1.2rem]',
'common-shadow'
)}
>
<p className="caption2-sb whitespace-pre-line">{text}</p>
</div>
);
}
49 changes: 49 additions & 0 deletions packages/design-system/src/components/toast/hooks/uesFadeOut.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { useEffect, useState } from 'react';
import { cn } from '@/lib/utils';

export interface AutoDismissToastProps {
children: React.ReactNode;
duration?: number;
Comment on lines +4 to +6
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

React 타입 네임스페이스 직접 참조로 인한 TS 오류 가능성 — ReactNode를 명시 import 권장

children: React.ReactNode를 사용하면서 React 타입 네임스페이스를 가져오지 않았습니다. TS 설정에 따라 “Cannot find namespace 'React'”로 빌드가 실패할 수 있습니다. 타입만 가져오도록 수정해 주세요.

적용 diff:

-import { useEffect, useState } from 'react';
+import { useEffect, useState } from 'react';
+import type { ReactNode } from 'react';
@@
 export interface AutoDismissToastProps {
-  children: React.ReactNode;
+  children: ReactNode;
   duration?: number;
   fadeMs?: number;
   onClose?: () => void;
   className?: string;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export interface AutoDismissToastProps {
children: React.ReactNode;
duration?: number;
import { useEffect, useState } from 'react';
import type { ReactNode } from 'react';
export interface AutoDismissToastProps {
children: ReactNode;
duration?: number;
fadeMs?: number;
onClose?: () => void;
className?: string;
}
🤖 Prompt for AI Agents
In packages/design-system/src/components/toast/hooks/uesFadeOut.tsx around lines
4 to 6, the code uses React.ReactNode without importing React, which can cause
"Cannot find namespace 'React'". Fix by adding a type-only import and using the
imported type: import type { ReactNode } from 'react'; then change the prop to
children: ReactNode; to avoid referencing the React namespace.

fadeMs?: number;
onClose?: () => void;
className?: string;
}

export default function AutoDismissToast({
children,
duration = 3000,
fadeMs = 200,
onClose,
className,
}: AutoDismissToastProps) {
const [visible, setVisible] = useState(true);
const [fading, setFading] = useState(false);

useEffect(() => {
const t1 = setTimeout(() => setFading(true), duration);
const t2 = setTimeout(() => {
setVisible(false);
onClose?.();
}, duration + fadeMs);
return () => {
clearTimeout(t1);
clearTimeout(t2);
};
}, [duration, fadeMs, onClose]);

if (!visible) return null;

return (
<div className={cn(className)}>
<div
className={cn(
'transition-opacity ease-out',
fading ? 'opacity-0' : 'opacity-100'
)}
style={{ transitionDuration: `${fadeMs}ms` }}
>
{children}
</div>
</div>
);
}
Loading