diff --git a/src/app/styles/index.css b/src/app/styles/index.css
index 0409ac0e..19b63fc8 100644
--- a/src/app/styles/index.css
+++ b/src/app/styles/index.css
@@ -74,6 +74,7 @@
--margin-s: 16px;
/* Padding (Primitive/Scale) */
+ --padding-xxl: 32px;
--padding-xl: 24px;
--padding-l: 16px;
--padding-m: 12px;
@@ -149,4 +150,35 @@
line-height: var(--line-height-xs);
letter-spacing: var(--letter-spacing);
}
+ @layer utilities {
+ .animate-fade-in {
+ animation: fade-in 0.2s ease-out forwards;
+ }
+
+ .animate-fade-out {
+ animation: fade-out 0.15s ease-in forwards;
+ }
+
+ @keyframes fade-in {
+ from {
+ opacity: 0;
+ transform: translateY(4px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ }
+
+ @keyframes fade-out {
+ from {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ to {
+ opacity: 0;
+ transform: translateY(4px);
+ }
+ }
+ }
}
diff --git a/src/pages/playground.tsx b/src/pages/playground.tsx
index f06d668b..7dcc143b 100644
--- a/src/pages/playground.tsx
+++ b/src/pages/playground.tsx
@@ -3,11 +3,15 @@ import { Button } from '@shared/ui/Button/Button';
import { Card } from '@shared/ui/Card/Card';
import { Checkbox } from '@shared/ui/Checkbox/Checkbox';
import { Header } from '@shared/ui/Header/Header';
+import { Modal } from '@shared/ui/Modal/Modal';
import { Profile } from '@shared/ui/Profile/Profile';
import { RadioButton } from '@shared/ui/RadioButton/RadioButton';
import { TextField } from '@shared/ui/TextField/TextField';
+import { useState } from 'react';
export default function Playground() {
+ const [isModalOpen, setIsModalOpen] = useState(false);
+
return (
UI Playground
@@ -124,6 +128,24 @@ export default function Playground() {
/>
+ {/* Modal */}
+
+ Modal
+
+
+
+ {isModalOpen && (
+ setIsModalOpen(false)}
+ onConfirm={() => {
+ setIsModalOpen(false);
+ }}
+ />
+ )}
+
+
{/* TextField 예시 */}
TextField
diff --git a/src/shared/ui/Modal/Modal.styles.ts b/src/shared/ui/Modal/Modal.styles.ts
new file mode 100644
index 00000000..f5d481f5
--- /dev/null
+++ b/src/shared/ui/Modal/Modal.styles.ts
@@ -0,0 +1,21 @@
+export const modalStyles = {
+ container: ['fixed', 'inset-0', 'z-50', 'flex', 'items-center', 'justify-center'].join(' '),
+
+ content: [
+ 'w-[347px]',
+ 'h-[167px]',
+ 'flex',
+ 'flex-col',
+ 'justify-between',
+ 'rounded-[var(--radius-l)]',
+ 'bg-[var(--color-green-500)]',
+ 'px-[var(--padding-xxl)]',
+ 'py-[var(--padding-xl)]',
+ ].join(' '),
+
+ textGroup: ['flex', 'flex-col', 'gap-[var(--spacing-xxs)]'].join(' '),
+
+ buttonGroup: ['flex', 'gap-3'].join(' '),
+
+ buttonWrapper: ['flex-1'].join(' '),
+};
diff --git a/src/shared/ui/Modal/Modal.tsx b/src/shared/ui/Modal/Modal.tsx
new file mode 100644
index 00000000..6facd440
--- /dev/null
+++ b/src/shared/ui/Modal/Modal.tsx
@@ -0,0 +1,88 @@
+import { Button } from '@shared/ui/Button/Button';
+import { useCallback, useEffect, useState } from 'react';
+import { modalStyles } from './Modal.styles';
+
+export interface ModalProps {
+ title: string;
+ subtitle?: string;
+ cancelText?: string;
+ confirmText?: string;
+ onCancel: () => void;
+ onConfirm: () => void;
+}
+
+export const Modal = ({
+ title,
+ subtitle,
+ cancelText = '취소',
+ confirmText = '확인',
+ onCancel,
+ onConfirm,
+}: ModalProps) => {
+ const [closing, setClosing] = useState(false);
+
+ const handleClose = useCallback(
+ (callback: () => void) => {
+ if (closing) {
+ return;
+ }
+
+ setClosing(true);
+ setTimeout(callback, 150);
+ },
+ [closing]
+ );
+
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ handleClose(onCancel);
+ }
+ };
+
+ window.addEventListener('keydown', handleKeyDown);
+ return () => {
+ window.removeEventListener('keydown', handleKeyDown);
+ };
+ }, [handleClose, onCancel]);
+
+ return (
+ /* eslint-disable jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */
+ handleClose(onCancel)}>
+
e.stopPropagation()}
+ >
+
+
{title}
+ {subtitle &&
{subtitle}
}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ /* eslint-enable jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */
+ );
+};
diff --git a/src/shared/ui/Modal/index.ts b/src/shared/ui/Modal/index.ts
new file mode 100644
index 00000000..05844a90
--- /dev/null
+++ b/src/shared/ui/Modal/index.ts
@@ -0,0 +1,2 @@
+export { Modal } from './Modal';
+export type { ModalProps } from './Modal';
diff --git a/tests/unit/shared/ui/Modal/Modal.test.tsx b/tests/unit/shared/ui/Modal/Modal.test.tsx
new file mode 100644
index 00000000..de08128b
--- /dev/null
+++ b/tests/unit/shared/ui/Modal/Modal.test.tsx
@@ -0,0 +1,87 @@
+import { Modal } from '@shared/ui/Modal/Modal';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+describe('Modal', () => {
+ const setup = (props?: Partial>) => {
+ const onCancel = vi.fn();
+ const onConfirm = vi.fn();
+
+ render(
+
+ );
+
+ return { onCancel, onConfirm };
+ };
+
+ describe('Rendering', () => {
+ it('title 렌더링', () => {
+ setup();
+ expect(screen.getByText('해당 게시물을 삭제하시겠어요?')).toBeInTheDocument();
+ });
+
+ it('subtitle 렌더링', () => {
+ setup();
+ expect(screen.getByText('subtitle 1줄')).toBeInTheDocument();
+ });
+
+ it('subtitle이 없을 경우 렌더링되지 않음', () => {
+ render();
+ expect(screen.queryByText('subtitle 1줄')).not.toBeInTheDocument();
+ });
+
+ it('취소 / 확인 버튼 렌더링', () => {
+ setup();
+ expect(screen.getByRole('button', { name: '취소' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: '확인' })).toBeInTheDocument();
+ });
+ });
+
+ describe('Interaction', () => {
+ it('취소 버튼 클릭 시 onCancel 호출', async () => {
+ const user = userEvent.setup();
+ const { onCancel } = setup();
+
+ await user.click(screen.getByRole('button', { name: '취소' }));
+
+ await waitFor(() => {
+ expect(onCancel).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it('확인 버튼 클릭 시 onConfirm 호출', async () => {
+ const user = userEvent.setup();
+ const { onConfirm } = setup();
+
+ await user.click(screen.getByRole('button', { name: '확인' }));
+
+ await waitFor(() => {
+ expect(onConfirm).toHaveBeenCalledTimes(1);
+ });
+ });
+ });
+
+ describe('Accessibility', () => {
+ it('버튼은 접근 가능한 role을 가짐', () => {
+ setup();
+ expect(screen.getAllByRole('button')).toHaveLength(2);
+ });
+
+ it('Tab 키로 버튼 포커스 이동 가능', async () => {
+ const user = userEvent.setup();
+ setup();
+
+ await user.tab();
+ expect(screen.getByRole('button', { name: '취소' })).toHaveFocus();
+
+ await user.tab();
+ expect(screen.getByRole('button', { name: '확인' })).toHaveFocus();
+ });
+ });
+});