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(); + }); + }); +});