Skip to content

Commit 54db360

Browse files
authored
Merge pull request #32 from Leets-Official/26-feat/모달-컴포넌트-제작
[feat] Modal 컴포넌트 제작
2 parents c6a8c33 + 53351eb commit 54db360

6 files changed

Lines changed: 252 additions & 0 deletions

File tree

src/app/styles/index.css

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
--margin-s: 16px;
7575

7676
/* Padding (Primitive/Scale) */
77+
--padding-xxl: 32px;
7778
--padding-xl: 24px;
7879
--padding-l: 16px;
7980
--padding-m: 12px;
@@ -149,4 +150,35 @@
149150
line-height: var(--line-height-xs);
150151
letter-spacing: var(--letter-spacing);
151152
}
153+
@layer utilities {
154+
.animate-fade-in {
155+
animation: fade-in 0.2s ease-out forwards;
156+
}
157+
158+
.animate-fade-out {
159+
animation: fade-out 0.15s ease-in forwards;
160+
}
161+
162+
@keyframes fade-in {
163+
from {
164+
opacity: 0;
165+
transform: translateY(4px);
166+
}
167+
to {
168+
opacity: 1;
169+
transform: translateY(0);
170+
}
171+
}
172+
173+
@keyframes fade-out {
174+
from {
175+
opacity: 1;
176+
transform: translateY(0);
177+
}
178+
to {
179+
opacity: 0;
180+
transform: translateY(4px);
181+
}
182+
}
183+
}
152184
}

src/pages/playground.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,15 @@ import { Button } from '@shared/ui/Button/Button';
33
import { Card } from '@shared/ui/Card/Card';
44
import { Checkbox } from '@shared/ui/Checkbox/Checkbox';
55
import { Header } from '@shared/ui/Header/Header';
6+
import { Modal } from '@shared/ui/Modal/Modal';
67
import { Profile } from '@shared/ui/Profile/Profile';
78
import { RadioButton } from '@shared/ui/RadioButton/RadioButton';
89
import { TextField } from '@shared/ui/TextField/TextField';
10+
import { useState } from 'react';
911

1012
export default function Playground() {
13+
const [isModalOpen, setIsModalOpen] = useState(false);
14+
1115
return (
1216
<div className="flex flex-col gap-6 p-8">
1317
<h1 className="typo-title-2">UI Playground</h1>
@@ -124,6 +128,24 @@ export default function Playground() {
124128
/>
125129
</section>
126130

131+
{/* Modal */}
132+
<section className="flex flex-col gap-4">
133+
<h2 className="typo-body-1">Modal</h2>
134+
135+
<Button onClick={() => setIsModalOpen(true)}>Open Modal</Button>
136+
137+
{isModalOpen && (
138+
<Modal
139+
title="해당 게시물을 삭제하시겠어요?"
140+
subtitle="subtitle 1줄"
141+
onCancel={() => setIsModalOpen(false)}
142+
onConfirm={() => {
143+
setIsModalOpen(false);
144+
}}
145+
/>
146+
)}
147+
</section>
148+
127149
{/* TextField 예시 */}
128150
<section className="flex flex-col gap-4">
129151
<h2 className="typo-body-1">TextField</h2>
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
export const modalStyles = {
2+
container: ['fixed', 'inset-0', 'z-50', 'flex', 'items-center', 'justify-center'].join(' '),
3+
4+
content: [
5+
'w-[347px]',
6+
'h-[167px]',
7+
'flex',
8+
'flex-col',
9+
'justify-between',
10+
'rounded-[var(--radius-l)]',
11+
'bg-[var(--color-green-500)]',
12+
'px-[var(--padding-xxl)]',
13+
'py-[var(--padding-xl)]',
14+
].join(' '),
15+
16+
textGroup: ['flex', 'flex-col', 'gap-[var(--spacing-xxs)]'].join(' '),
17+
18+
buttonGroup: ['flex', 'gap-3'].join(' '),
19+
20+
buttonWrapper: ['flex-1'].join(' '),
21+
};

src/shared/ui/Modal/Modal.tsx

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { Button } from '@shared/ui/Button/Button';
2+
import { useCallback, useEffect, useState } from 'react';
3+
import { modalStyles } from './Modal.styles';
4+
5+
export interface ModalProps {
6+
title: string;
7+
subtitle?: string;
8+
cancelText?: string;
9+
confirmText?: string;
10+
onCancel: () => void;
11+
onConfirm: () => void;
12+
}
13+
14+
export const Modal = ({
15+
title,
16+
subtitle,
17+
cancelText = '취소',
18+
confirmText = '확인',
19+
onCancel,
20+
onConfirm,
21+
}: ModalProps) => {
22+
const [closing, setClosing] = useState(false);
23+
24+
const handleClose = useCallback(
25+
(callback: () => void) => {
26+
if (closing) {
27+
return;
28+
}
29+
30+
setClosing(true);
31+
setTimeout(callback, 150);
32+
},
33+
[closing]
34+
);
35+
36+
useEffect(() => {
37+
const handleKeyDown = (e: KeyboardEvent) => {
38+
if (e.key === 'Escape') {
39+
handleClose(onCancel);
40+
}
41+
};
42+
43+
window.addEventListener('keydown', handleKeyDown);
44+
return () => {
45+
window.removeEventListener('keydown', handleKeyDown);
46+
};
47+
}, [handleClose, onCancel]);
48+
49+
return (
50+
/* eslint-disable jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */
51+
<div className={modalStyles.container} onClick={() => handleClose(onCancel)}>
52+
<div
53+
className={`${modalStyles.content} ${closing ? 'animate-fade-out' : 'animate-fade-in'}`}
54+
onClick={(e) => e.stopPropagation()}
55+
>
56+
<div className={modalStyles.textGroup}>
57+
<p className="typo-body-2 text-black">{title}</p>
58+
{subtitle && <p className="typo-caption-2 truncate text-black">{subtitle}</p>}
59+
</div>
60+
61+
<div className={modalStyles.buttonGroup}>
62+
<div className={modalStyles.buttonWrapper}>
63+
<Button
64+
variant="outline"
65+
size="auto"
66+
className="w-full"
67+
onClick={() => handleClose(onCancel)}
68+
>
69+
{cancelText}
70+
</Button>
71+
</div>
72+
73+
<div className={modalStyles.buttonWrapper}>
74+
<Button
75+
variant="fill"
76+
size="auto"
77+
className="w-full"
78+
onClick={() => handleClose(onConfirm)}
79+
>
80+
{confirmText}
81+
</Button>
82+
</div>
83+
</div>
84+
</div>
85+
</div>
86+
/* eslint-enable jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */
87+
);
88+
};

src/shared/ui/Modal/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { Modal } from './Modal';
2+
export type { ModalProps } from './Modal';
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { Modal } from '@shared/ui/Modal/Modal';
2+
import { render, screen, waitFor } from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
4+
5+
describe('Modal', () => {
6+
const setup = (props?: Partial<React.ComponentProps<typeof Modal>>) => {
7+
const onCancel = vi.fn();
8+
const onConfirm = vi.fn();
9+
10+
render(
11+
<Modal
12+
title="해당 게시물을 삭제하시겠어요?"
13+
subtitle="subtitle 1줄"
14+
onCancel={onCancel}
15+
onConfirm={onConfirm}
16+
{...props}
17+
/>
18+
);
19+
20+
return { onCancel, onConfirm };
21+
};
22+
23+
describe('Rendering', () => {
24+
it('title 렌더링', () => {
25+
setup();
26+
expect(screen.getByText('해당 게시물을 삭제하시겠어요?')).toBeInTheDocument();
27+
});
28+
29+
it('subtitle 렌더링', () => {
30+
setup();
31+
expect(screen.getByText('subtitle 1줄')).toBeInTheDocument();
32+
});
33+
34+
it('subtitle이 없을 경우 렌더링되지 않음', () => {
35+
render(<Modal title="제목만 있는 모달" onCancel={vi.fn()} onConfirm={vi.fn()} />);
36+
expect(screen.queryByText('subtitle 1줄')).not.toBeInTheDocument();
37+
});
38+
39+
it('취소 / 확인 버튼 렌더링', () => {
40+
setup();
41+
expect(screen.getByRole('button', { name: '취소' })).toBeInTheDocument();
42+
expect(screen.getByRole('button', { name: '확인' })).toBeInTheDocument();
43+
});
44+
});
45+
46+
describe('Interaction', () => {
47+
it('취소 버튼 클릭 시 onCancel 호출', async () => {
48+
const user = userEvent.setup();
49+
const { onCancel } = setup();
50+
51+
await user.click(screen.getByRole('button', { name: '취소' }));
52+
53+
await waitFor(() => {
54+
expect(onCancel).toHaveBeenCalledTimes(1);
55+
});
56+
});
57+
58+
it('확인 버튼 클릭 시 onConfirm 호출', async () => {
59+
const user = userEvent.setup();
60+
const { onConfirm } = setup();
61+
62+
await user.click(screen.getByRole('button', { name: '확인' }));
63+
64+
await waitFor(() => {
65+
expect(onConfirm).toHaveBeenCalledTimes(1);
66+
});
67+
});
68+
});
69+
70+
describe('Accessibility', () => {
71+
it('버튼은 접근 가능한 role을 가짐', () => {
72+
setup();
73+
expect(screen.getAllByRole('button')).toHaveLength(2);
74+
});
75+
76+
it('Tab 키로 버튼 포커스 이동 가능', async () => {
77+
const user = userEvent.setup();
78+
setup();
79+
80+
await user.tab();
81+
expect(screen.getByRole('button', { name: '취소' })).toHaveFocus();
82+
83+
await user.tab();
84+
expect(screen.getByRole('button', { name: '확인' })).toHaveFocus();
85+
});
86+
});
87+
});

0 commit comments

Comments
 (0)