Skip to content

Commit fc0a3b3

Browse files
authored
Merge pull request #28 from DDD-Community/feat/design-system
FEAT: 공통 컴포넌트 개발 (버튼, 체크박스, 스켈레톤)
2 parents 0e54386 + 51e7cb4 commit fc0a3b3

File tree

7 files changed

+267
-34
lines changed

7 files changed

+267
-34
lines changed

src/app/test-toast/page.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use client';
22

33
import { useToast } from '@/shared/components/toast';
4+
import Button from '@/shared/components/Button';
45

56
export default function TestToastPage() {
67
const { showToast } = useToast();
@@ -22,7 +23,7 @@ export default function TestToastPage() {
2223
};
2324

2425
return (
25-
<div className="p-8 space-y-4">
26+
<div className="p-8 space-y-4 bg-gray-200">
2627
<h1 className="text-2xl font-bold">토스트 테스트 페이지</h1>
2728

2829
<div className="space-y-2">
@@ -50,6 +51,7 @@ export default function TestToastPage() {
5051
>
5152
정보 토스트 테스트
5253
</button>
54+
<Button size={'xl'} text={'버튼'} isPending={false} />
5355
</div>
5456
</div>
5557
);

src/shared/components/Button.tsx

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,55 @@
1-
const Button = () => {
2-
return <div>button Component</div>;
1+
import { ButtonHTMLAttributes } from 'react';
2+
3+
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
4+
size: 'sm' | 'ml' | 'lg' | 'xl' | 'full';
5+
text: string;
6+
isPending?: boolean;
7+
className?: string;
8+
}
9+
10+
const Button = ({ size, text, onClick, disabled = false, isPending = false, className = '' }: ButtonProps) => {
11+
return (
12+
<button
13+
className={`rounded-lg
14+
${
15+
size === 'sm'
16+
? 'py-[8px] px-[14px] w-[106px] label-1-bold'
17+
: size === 'ml'
18+
? 'py-[10px] px-[16px] w-[110px] label-1-bold'
19+
: size === 'lg'
20+
? 'py-[10px] px-[18px] w-[123px] body-1-bold'
21+
: size === 'xl'
22+
? 'py-[12px] px-[20px] w-[127px] body-1-bold'
23+
: 'py-3 px-4 w-full body-1-bold'
24+
}
25+
shadow-xs
26+
${disabled ? 'bg-interaction-disable text-label-disable cursor-not-allowed' : 'cursor-pointer bg-primary-normal hover:bg-primary-strong focus:outline-4 focus:outline-solid focus:outline-line-normal'}
27+
${className}`}
28+
onClick={e => onClick && onClick(e)}
29+
disabled={disabled}
30+
>
31+
{!disabled && isPending ? (
32+
<svg
33+
aria-hidden="true"
34+
className="inline w-5 h-5 text-gray-200 animate-spin dark:text-gray-600 fill-gray-600 dark:fill-gray-300"
35+
viewBox="0 0 100 101"
36+
fill="none"
37+
xmlns="http://www.w3.org/2000/svg"
38+
>
39+
<path
40+
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
41+
fill="currentColor"
42+
/>
43+
<path
44+
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
45+
fill="currentFill"
46+
/>
47+
</svg>
48+
) : (
49+
<span className="whitespace-nowrap">{text}</span>
50+
)}
51+
</button>
52+
);
353
};
454

555
export default Button;

src/shared/components/Skeleton.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import FlexBox from './FlexBox';
2+
3+
interface SkeletonUIProps {
4+
// 추후에 더 추가 예정
5+
size: 'sm' | 'lg';
6+
direction: 'col' | 'row';
7+
count: number;
8+
className?: string;
9+
}
10+
const Skeleton = ({ size, direction = 'row', count = 1, className }: SkeletonUIProps) => {
11+
return (
12+
<FlexBox direction={direction} className="gap-2">
13+
{Array.from({ length: count }).map(() => (
14+
<div
15+
className={`rounded-lg bg-gray-400 h-[44px] animate-pulse ${size === 'sm' ? 'w-[120px]' : 'w-[335px] md:w-[420px]'} ${className}`}
16+
/>
17+
))}
18+
</FlexBox>
19+
);
20+
};
21+
22+
export default Skeleton;

src/shared/components/toast.tsx

Lines changed: 36 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ interface Toast {
99
message: string;
1010
type: 'success' | 'error' | 'warning' | 'info';
1111
duration?: number;
12+
isExisting: boolean;
1213
}
1314

1415
interface ToastContextType {
@@ -21,9 +22,9 @@ const ToastContext = createContext<ToastContextType | undefined>(undefined);
2122
export function ToastProvider({ children }: { children: ReactNode }) {
2223
const [toasts, setToasts] = useState<Toast[]>([]);
2324

24-
const showToast = (message: string, type: Toast['type'] = 'info', duration = 3000) => {
25+
const showToast = (message: string, type: Toast['type'] = 'warning', duration = 2500) => {
2526
const id = Math.random().toString(36).substr(2, 9);
26-
const newToast: Toast = { id, message, type, duration };
27+
const newToast: Toast = { id, message, type, duration, isExisting: false };
2728

2829
setToasts(prev => [...prev, newToast]);
2930

@@ -35,9 +36,14 @@ export function ToastProvider({ children }: { children: ReactNode }) {
3536
};
3637

3738
const hideToast = (id: string) => {
38-
setToasts(prev => prev.filter(toast => toast.id !== id));
39-
};
39+
// 먼저 isExiting라는 플래그 변수를 true로 설정하여 exit 애니메이션 시작
40+
setToasts(prev => prev.map(toast => (toast.id === id ? { ...toast, isExisting: true } : toast)));
4041

42+
// 애니메이션 완료 후 실제로 토스트 제거 (300ms는 애니메이션 duration)
43+
setTimeout(() => {
44+
setToasts(prev => prev.filter(toast => toast.id !== id));
45+
}, 300);
46+
};
4147
return (
4248
<ToastContext.Provider value={{ showToast, hideToast }}>
4349
{children}
@@ -51,42 +57,38 @@ export function ToastProvider({ children }: { children: ReactNode }) {
5157
}
5258

5359
function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
54-
const getToastStyles = (type: Toast['type']) => {
55-
switch (type) {
56-
case 'success':
57-
return 'bg-green-500 text-white border-green-600';
58-
case 'error':
59-
return 'bg-red-500 text-white border-red-600';
60-
case 'warning':
61-
return 'bg-yellow-500 text-white border-yellow-600';
62-
case 'info':
63-
default:
64-
return 'bg-blue-500 text-white border-blue-600';
65-
}
66-
};
67-
6860
const getIcon = (type: Toast['type']) => {
6961
switch (type) {
7062
case 'success':
7163
return (
72-
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
73-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
64+
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
65+
<path
66+
fillRule="evenodd"
67+
clipRule="evenodd"
68+
d="M1.92499 10.9999C1.92499 5.98794 5.98799 1.92493 11 1.92493C16.0119 1.92493 20.0749 5.98794 20.0749 10.9999C20.0749 16.0119 16.0119 20.0749 11 20.0749C5.98799 20.0749 1.92499 16.0119 1.92499 10.9999ZM15.2594 9.05278C15.5763 8.72539 15.5678 8.2031 15.2404 7.88621C14.913 7.56932 14.3907 7.57784 14.0738 7.90523L9.78756 12.3336L7.9269 10.406C7.61046 10.0782 7.08818 10.069 6.76036 10.3854C6.43254 10.7019 6.42331 11.2242 6.73975 11.552L9.19318 14.0936C9.34851 14.2546 9.56253 14.3455 9.78619 14.3457C10.0099 14.3458 10.224 14.2552 10.3795 14.0944L15.2594 9.05278Z"
69+
fill="#1ED45A"
70+
/>
7471
</svg>
7572
);
7673
case 'error':
7774
return (
78-
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
79-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
75+
<svg width="22" height="22" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
76+
<path
77+
fillRule="evenodd"
78+
clipRule="evenodd"
79+
d="M0.924881 10C0.924881 4.98807 4.98788 0.925049 9.99985 0.925049C15.0118 0.925049 19.0748 4.98807 19.0748 10C19.0748 15.012 15.0118 19.075 9.99985 19.075C4.98788 19.075 0.924881 15.012 0.924881 10ZM9.99993 5.50829C10.4556 5.50829 10.8249 5.87766 10.8249 6.33329V10.4583C10.8249 10.9139 10.4556 11.2833 9.99993 11.2833C9.5443 11.2833 9.17493 10.9139 9.17493 10.4583V6.33329C9.17493 5.87766 9.5443 5.50829 9.99993 5.50829ZM10.9165 13.6666C10.9165 14.1729 10.5061 14.5833 9.99982 14.5833C9.49356 14.5833 9.08316 14.1729 9.08316 13.6666C9.08316 13.1604 9.49356 12.75 9.99982 12.75C10.5061 12.75 10.9165 13.1604 10.9165 13.6666Z"
80+
fill="#FF6363"
81+
/>
8082
</svg>
8183
);
8284
case 'warning':
8385
return (
84-
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
86+
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
8587
<path
86-
strokeLinecap="round"
87-
strokeLinejoin="round"
88-
strokeWidth={2}
89-
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
88+
fillRule="evenodd"
89+
clipRule="evenodd"
90+
d="M9.91854 2.84201C10.6068 2.53557 11.3928 2.53557 12.081 2.84201C12.5607 3.05559 12.9071 3.4471 13.208 3.87311C13.5064 4.29544 13.8347 4.86408 14.2341 5.55592L18.9191 13.6705C19.3185 14.3624 19.6468 14.931 19.8634 15.4006C20.0818 15.8742 20.2477 16.3699 20.1928 16.8921C20.1141 17.6414 19.7211 18.3221 19.1116 18.7649C18.6868 19.0736 18.1745 19.1777 17.6551 19.2254C17.1402 19.2726 16.4836 19.2726 15.6847 19.2726H6.31484C5.51597 19.2726 4.85937 19.2726 4.34442 19.2254C3.82501 19.1777 3.31279 19.0736 2.88796 18.7649C2.27846 18.3221 1.88548 17.6414 1.80673 16.8921C1.75184 16.3699 1.91773 15.8742 2.13617 15.4006C2.35273 14.931 2.68104 14.3624 3.08049 13.6705L7.76543 5.55593C8.16485 4.86408 8.49314 4.29544 8.79152 3.8731C9.0925 3.4471 9.43882 3.05559 9.91854 2.84201ZM10.9999 7.42468C11.4556 7.42468 11.8249 7.79405 11.8249 8.24968V11.9163C11.8249 12.372 11.4556 12.7413 10.9999 12.7413C10.5443 12.7413 10.1749 12.372 10.1749 11.9163V8.24968C10.1749 7.79405 10.5443 7.42468 10.9999 7.42468ZM11.9165 15.1247C11.9165 15.6309 11.5061 16.0413 10.9998 16.0413C10.4936 16.0413 10.0831 15.6309 10.0831 15.1247C10.0831 14.6184 10.4936 14.208 10.9998 14.208C11.5061 14.208 11.9165 14.6184 11.9165 15.1247Z"
91+
fill="#FFA938"
9092
/>
9193
</svg>
9294
);
@@ -108,12 +110,15 @@ function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
108110
return (
109111
<div
110112
className={cn(
111-
'flex items-center justify-between p-4 rounded-lg shadow-lg border min-w-[300px] max-w-[400px] animate-in slide-in-from-right-full duration-300',
112-
getToastStyles(toast.type)
113+
'flex items-center justify-between p-4 rounded-lg shadow-lg w-[335px] md:w-[420px] bg-gray-500',
114+
toast.isExisting
115+
? 'animate-out slide-out-to-right-full duration-300'
116+
: 'animate-in slide-in-from-right-full duration-300'
113117
)}
114118
>
115-
<div className="flex items-center space-x-3">
116-
<span className="text-sm font-medium">{toast.message}</span>
119+
<div className="flex items-center space-x-3 text-label-normal">
120+
{getIcon(toast.type)}
121+
<span className="text-body-2-normal">{toast.message}</span>
117122
</div>
118123
<button onClick={onClose} className="ml-4 text-white/80 hover:text-white transition-colors">
119124
<XIcon className="w-4 h-4" />
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import type { Meta, StoryObj } from '@storybook/react';
2+
import { useToast, ToastProvider } from '@/shared/components/toast';
3+
import Button from '@/shared/components/Button';
4+
5+
const meta = {
6+
title: 'FeedBack/Toast',
7+
tags: ['autodocs'],
8+
parameters: {
9+
layout: 'centered',
10+
actions: { disable: true },
11+
},
12+
decorators: [
13+
Story => (
14+
<ToastProvider>
15+
<Story />
16+
</ToastProvider>
17+
),
18+
],
19+
} satisfies Meta;
20+
21+
export default meta;
22+
type Story = StoryObj;
23+
24+
export const Playground: Story = {
25+
globals: {
26+
backgrounds: 'dark',
27+
},
28+
render() {
29+
const { showToast } = useToast();
30+
const handleTestSuccess = () => {
31+
showToast('성공 메시지입니다!', 'success');
32+
};
33+
34+
const handleTestError = () => {
35+
showToast('에러 메시지입니다!', 'error');
36+
};
37+
38+
const handleTestWarning = () => {
39+
showToast('경고 메시지입니다!', 'warning');
40+
};
41+
42+
const handleTestInfo = () => {
43+
showToast('정보 메시지입니다!', 'info');
44+
};
45+
46+
return (
47+
<div className="p-8 space-y-4 rounded-xl">
48+
<div className="space-y-3 w-48">
49+
<Button size="full" text="성공 토스트 테스트" onClick={handleTestSuccess} />
50+
51+
<Button size="full" text="실패 토스트 테스트" onClick={handleTestError} />
52+
53+
<Button size="full" text="경고 토스트 테스트" onClick={handleTestWarning} />
54+
55+
<Button size="full" text="정보 토스트 테스트" onClick={handleTestInfo} />
56+
</div>
57+
</div>
58+
);
59+
},
60+
};
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import type { Meta, StoryObj } from '@storybook/react';
2+
import Skeleton from '@/shared/components/Skeleton';
3+
4+
const meta = {
5+
title: 'Foundation/Skeleton',
6+
component: Skeleton,
7+
tags: ['autodocs'],
8+
parameters: {
9+
layout: 'centered',
10+
actions: { disable: true },
11+
},
12+
args: {
13+
size: 'sm',
14+
},
15+
argTypes: {
16+
size: {
17+
control: 'radio',
18+
},
19+
direction: {
20+
control: 'radio',
21+
},
22+
count: {
23+
control: 'number',
24+
},
25+
},
26+
} satisfies Meta<typeof Skeleton>;
27+
28+
export default meta;
29+
type Story = StoryObj<typeof Skeleton>;
30+
31+
export const Playground: Story = {
32+
globals: {
33+
backgrounds: 'dark',
34+
},
35+
};
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import type { Meta, StoryObj } from '@storybook/react';
2+
import Button from '@/shared/components/Button';
3+
4+
const options = [
5+
'신입(1년차 미만)',
6+
'주니어(1년~3년)',
7+
'미드레벨(3년~6년)',
8+
'시니어(6~10년)',
9+
'리드/매니저(10년 이상)',
10+
];
11+
12+
const meta = {
13+
title: 'Navigation/Button',
14+
component: Button,
15+
tags: ['autodocs'],
16+
parameters: {
17+
layout: 'centered',
18+
actions: { disable: true },
19+
},
20+
args: {
21+
size: 'lg',
22+
text: 'Button CTA',
23+
disabled: false,
24+
isPending: false,
25+
},
26+
decorators: [
27+
Story => (
28+
<div style={{ width: 386, padding: 50 }}>
29+
<Story />
30+
</div>
31+
),
32+
],
33+
argTypes: {
34+
size: {
35+
control: {
36+
type: 'radio',
37+
},
38+
options: ['sm', 'ml', 'lg', 'xl'],
39+
},
40+
text: {
41+
control: 'text',
42+
},
43+
disabled: {
44+
type: 'boolean',
45+
},
46+
isPending: {
47+
type: 'boolean',
48+
},
49+
},
50+
} satisfies Meta<typeof Button>;
51+
52+
export default meta;
53+
type Story = StoryObj<typeof Button>;
54+
55+
export const Playground: Story = {
56+
globals: {
57+
backgrounds: 'dark',
58+
},
59+
};

0 commit comments

Comments
 (0)