Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
adb27aa
chore: chore: 모달 예제 플레이그라운드에 추가
jwj0620gcu Jan 15, 2026
c9b9870
Modal 컴포넌트 신규 추가 및 스타일 적용
jwj0620gcu Jan 15, 2026
6b07d0d
chore: Modal test 통과
jwj0620gcu Jan 15, 2026
b30300c
Merge branch 'main' into 26-feat/모달-컴포넌트-제작
KyeongJooni Jan 16, 2026
be6f6c1
버튼 텍스트를 cancelText, confirmText props로 분리해 모달 재사용성을 개선
jwj0620gcu Jan 17, 2026
a03765b
refactor:overlay 제거
jwj0620gcu Jan 17, 2026
2acec6f
refactor: padding 값 토큰 값 사용 및 새로 추가
jwj0620gcu Jan 17, 2026
023891c
refactor: 커멘트 최종 수정
jwj0620gcu Jan 17, 2026
d1a0c7c
Merge branch '26-feat/모달-컴포넌트-제작' of https://github.com/Leets-Officia…
jwj0620gcu Jan 17, 2026
01ae397
Merge branch 'main' into 26-feat/모달-컴포넌트-제작
KyeongJooni Jan 17, 2026
a0e9bdd
fix: eslint 문제 해결 시도
jwj0620gcu Jan 17, 2026
905f9a4
Merge branch '26-feat/모달-컴포넌트-제작' of https://github.com/Leets-Officia…
jwj0620gcu Jan 17, 2026
bc64b20
오버레이 영역 수정
jwj0620gcu Jan 17, 2026
83a1436
fix: test 코드 수정
jwj0620gcu Jan 17, 2026
f836e1a
chore:prettier 포맷 적용
jwj0620gcu Jan 17, 2026
aec3192
refactor:주석 정리
jwj0620gcu Jan 17, 2026
f2f70e7
chore: 로컬 작업 파일 push 취소
jwj0620gcu Jan 17, 2026
2389d0f
chore: 중복 root 파일 정리
jwj0620gcu Jan 17, 2026
53351eb
Merge branch 'main' into 26-feat/모달-컴포넌트-제작
KyeongJooni Jan 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions src/app/styles/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
--margin-s: 16px;

/* Padding (Primitive/Scale) */
--padding-xxl: 32px;
--padding-xl: 24px;
--padding-l: 16px;
--padding-m: 12px;
Expand Down Expand Up @@ -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);
}
}
}
}
22 changes: 22 additions & 0 deletions src/pages/playground.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="flex flex-col gap-6 p-8">
<h1 className="typo-title-2">UI Playground</h1>
Expand Down Expand Up @@ -124,6 +128,24 @@ export default function Playground() {
/>
</section>

{/* Modal */}
<section className="flex flex-col gap-4">
<h2 className="typo-body-1">Modal</h2>

<Button onClick={() => setIsModalOpen(true)}>Open Modal</Button>

{isModalOpen && (
<Modal
title="해당 게시물을 삭제하시겠어요?"
subtitle="subtitle 1줄"
onCancel={() => setIsModalOpen(false)}
onConfirm={() => {
setIsModalOpen(false);
}}
/>
)}
</section>

{/* TextField 예시 */}
<section className="flex flex-col gap-4">
<h2 className="typo-body-1">TextField</h2>
Expand Down
21 changes: 21 additions & 0 deletions src/shared/ui/Modal/Modal.styles.ts
Original file line number Diff line number Diff line change
@@ -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(' '),
};
88 changes: 88 additions & 0 deletions src/shared/ui/Modal/Modal.tsx

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

현재 구현만 보면 버튼을 통한 닫기(onCancel)만 제공되는 것 같네용 UX 관점에서 ESC 키나 오버레이 클릭으로도 닫히는 동작이 있으면 좋을 것 같아서 참고용으로 리뷰 남깁니다!

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

esc키나 오버레이 배경 클릭하게 해도 닫기 버튼 말씀하신 대로 변경 완료 했습니다!
좋은 아이디어 감사합니다!

Original file line number Diff line number Diff line change
@@ -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 */
<div className={modalStyles.container} onClick={() => handleClose(onCancel)}>
<div
className={`${modalStyles.content} ${closing ? 'animate-fade-out' : 'animate-fade-in'}`}
onClick={(e) => e.stopPropagation()}
>
<div className={modalStyles.textGroup}>
<p className="typo-body-2 text-black">{title}</p>
{subtitle && <p className="typo-caption-2 truncate text-black">{subtitle}</p>}
</div>

<div className={modalStyles.buttonGroup}>
<div className={modalStyles.buttonWrapper}>
<Button
variant="outline"
size="auto"
className="w-full"
onClick={() => handleClose(onCancel)}
>
{cancelText}
</Button>
</div>

<div className={modalStyles.buttonWrapper}>
<Button
variant="fill"
size="auto"
className="w-full"
onClick={() => handleClose(onConfirm)}
>
{confirmText}
</Button>
</div>
</div>
</div>
</div>
/* eslint-enable jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */
);
};
2 changes: 2 additions & 0 deletions src/shared/ui/Modal/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { Modal } from './Modal';
export type { ModalProps } from './Modal';
87 changes: 87 additions & 0 deletions tests/unit/shared/ui/Modal/Modal.test.tsx
Original file line number Diff line number Diff line change
@@ -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<React.ComponentProps<typeof Modal>>) => {
const onCancel = vi.fn();
const onConfirm = vi.fn();

render(
<Modal
title="해당 게시물을 삭제하시겠어요?"
subtitle="subtitle 1줄"
onCancel={onCancel}
onConfirm={onConfirm}
{...props}
/>
);

return { onCancel, onConfirm };
};

describe('Rendering', () => {
it('title 렌더링', () => {
setup();
expect(screen.getByText('해당 게시물을 삭제하시겠어요?')).toBeInTheDocument();
});

it('subtitle 렌더링', () => {
setup();
expect(screen.getByText('subtitle 1줄')).toBeInTheDocument();
});

it('subtitle이 없을 경우 렌더링되지 않음', () => {
render(<Modal title="제목만 있는 모달" onCancel={vi.fn()} onConfirm={vi.fn()} />);
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();
});
});
});
Loading