diff --git a/apps/frontend/src/feature/create-poll/components/PollOptionListSection.tsx b/apps/frontend/src/feature/create-poll/components/PollOptionListSection.tsx index 4700c9cf..a3306ce3 100644 --- a/apps/frontend/src/feature/create-poll/components/PollOptionListSection.tsx +++ b/apps/frontend/src/feature/create-poll/components/PollOptionListSection.tsx @@ -1,6 +1,6 @@ -import Button from '@/shared/components/button/Button'; +import { Button } from '@/shared/components/Button'; import { Icon } from '@/shared/components/icon/Icon'; -import Input from '@/shared/components/Input'; +import { Input } from '@/shared/components/Input'; import type { PollOption } from '../types'; interface PollOptionItemProps { diff --git a/apps/frontend/src/shared/components/Modal.tsx b/apps/frontend/src/shared/components/Modal.tsx index a487e3e4..39763962 100644 --- a/apps/frontend/src/shared/components/Modal.tsx +++ b/apps/frontend/src/shared/components/Modal.tsx @@ -2,24 +2,7 @@ import { MouseEvent, useEffect, type ReactNode } from 'react'; import { createPortal } from 'react-dom'; import { cn } from '@/shared/lib/utils'; - -/** - * ESC 키로 모달 닫기 기능을 제공하는 커스텀 훅 - * @param isOpen 모달 열림 상태 - * @param onClose 모달 닫기 함수 - */ -function useModalEscapeClose(isOpen: boolean, onClose: () => void) { - useEffect(() => { - const handleEscape = (e: KeyboardEvent) => { - if (e.key === 'Escape' && isOpen) onClose(); - }; - - if (isOpen) { - document.addEventListener('keydown', handleEscape); - return () => document.removeEventListener('keydown', handleEscape); - } - }, [isOpen, onClose]); -} +import { useEscapeKey } from '@/shared/hooks/useEscapeKey'; /** * 모달 열림 시 body 스크롤 방지 기능을 제공하는 커스텀 훅 @@ -81,8 +64,8 @@ interface ModalProps { * @param className 추가 클래스 이름 * @returns 모달 JSX 요소 */ -export function Modal({ isOpen, onClose, children, className }: ModalProps) { - useModalEscapeClose(isOpen, onClose); +export const Modal = ({ isOpen, onClose, children, className }: ModalProps) => { + useEscapeKey(isOpen, onClose); useModalBodyScrollLock(isOpen); if (!isOpen) return null; @@ -99,4 +82,4 @@ export function Modal({ isOpen, onClose, children, className }: ModalProps) { ); -} +}; diff --git a/apps/frontend/src/shared/components/TimeLimitDropdown.spec.tsx b/apps/frontend/src/shared/components/TimeLimitDropdown.spec.tsx new file mode 100644 index 00000000..8db035e9 --- /dev/null +++ b/apps/frontend/src/shared/components/TimeLimitDropdown.spec.tsx @@ -0,0 +1,134 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import { TimeLimitDropdown } from './TimeLimitDropdown'; + +describe('TimeLimitDropdown', () => { + it('기본 상태에서 "제한 없음"이 표시된다', () => { + const handleChange = vi.fn(); + render(); + + expect(screen.getByText('제한 없음')).toBeInTheDocument(); + }); + + it('selectedTime prop에 따라 선택된 옵션이 표시된다', () => { + const handleChange = vi.fn(); + render( + , + ); + + expect(screen.getByText('1분')).toBeInTheDocument(); + }); + + it('드롭다운 버튼을 클릭하면 옵션 리스트가 열린다', async () => { + const handleChange = vi.fn(); + const user = userEvent.setup(); + + render(); + + const button = screen.getByRole('button'); + await user.click(button); + + expect(screen.getByRole('listbox')).toBeInTheDocument(); + expect(screen.getByText('30초')).toBeInTheDocument(); + expect(screen.getByText('3분')).toBeInTheDocument(); + expect(screen.getByText('5분')).toBeInTheDocument(); + expect(screen.getByText('10분')).toBeInTheDocument(); + }); + + it('옵션을 클릭하면 onChange가 호출되고 드롭다운이 닫힌다', async () => { + const handleChange = vi.fn(); + const user = userEvent.setup(); + + render(); + + const button = screen.getByRole('button'); + await user.click(button); + + const option = screen.getByText('1분'); + await user.click(option); + + expect(handleChange).toHaveBeenCalledWith(60); + expect(handleChange).toHaveBeenCalledTimes(1); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); + + it('열린 드롭다운을 다시 클릭하면 닫힌다', async () => { + const handleChange = vi.fn(); + const user = userEvent.setup(); + + render(); + + const button = screen.getByRole('button'); + await user.click(button); + + expect(screen.getByRole('listbox')).toBeInTheDocument(); + + await user.click(button); + + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); + + it('선택된 옵션에 aria-selected가 적용된다', async () => { + const handleChange = vi.fn(); + const user = userEvent.setup(); + + render( + , + ); + + const button = screen.getByRole('button'); + await user.click(button); + + const selectedOption = screen.getByRole('option', { name: '1분' }); + expect(selectedOption).toHaveAttribute('aria-selected', 'true'); + }); + + it('모든 시간 제한 옵션이 정확하게 표시된다', async () => { + const handleChange = vi.fn(); + const user = userEvent.setup(); + + render(); + + const button = screen.getByRole('button'); + await user.click(button); + + const listbox = screen.getByRole('listbox'); + expect(listbox).toBeInTheDocument(); + + expect(screen.getByRole('option', { name: '제한 없음' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: '30초' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: '1분' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: '3분' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: '5분' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: '10분' })).toBeInTheDocument(); + }); + + it('각 옵션 클릭 시 올바른 값이 전달된다', async () => { + const handleChange = vi.fn(); + const user = userEvent.setup(); + + render(); + + const button = screen.getByRole('button'); + await user.click(button); + + await user.click(screen.getByText('30초')); + expect(handleChange).toHaveBeenCalledWith(30); + + await user.click(button); + await user.click(screen.getByText('3분')); + expect(handleChange).toHaveBeenCalledWith(180); + + await user.click(button); + await user.click(screen.getByText('10분')); + expect(handleChange).toHaveBeenCalledWith(600); + }); +}); diff --git a/apps/frontend/src/shared/components/TimeLimitDropdown.tsx b/apps/frontend/src/shared/components/TimeLimitDropdown.tsx new file mode 100644 index 00000000..39712946 --- /dev/null +++ b/apps/frontend/src/shared/components/TimeLimitDropdown.tsx @@ -0,0 +1,162 @@ +import { useState, useRef } from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { cn } from '@/shared/lib/utils'; +import { Icon } from '@/shared/components/icon/Icon'; +import { useEscapeKey } from '@/shared/hooks/useEscapeKey'; +import { useOutsideClick } from '@/shared/hooks/useOutsideClick'; + +/** + * 시간 제한 옵션 배열 + */ +const TIME_LIMIT_OPTIONS = [ + { label: '제한 없음', value: 0 }, + { label: '30초', value: 30 }, + { label: '1분', value: 60 }, + { label: '3분', value: 180 }, + { label: '5분', value: 300 }, + { label: '10분', value: 600 }, +] as const; + +/** + * 기본 시간 제한 값 (초 단위) + */ +const DEFAULT_TIME_LIMIT = 0; + +/** + * 드롭다운 버튼 스타일 변형 + */ +const dropdownButtonVariants = cva( + 'text-text flex w-full cursor-pointer items-center justify-between gap-2 rounded-lg bg-gray-300 px-4 py-2 text-sm transition-all duration-200 hover:bg-gray-200 focus-visible:ring-2', + { + variants: { + isOpen: { + true: 'bg-gray-300', + false: '', + }, + }, + defaultVariants: { + isOpen: false, + }, + }, +); + +/** + * 드롭다운 아이템 스타일 변형 + */ +const dropdownItemVariants = cva( + 'text-text w-full cursor-pointer px-4 py-2 text-left text-sm transition-all duration-150 hover:bg-gray-300 active:bg-gray-200', + { + variants: { + isSelected: { + true: 'text-primary bg-gray-300 font-bold', + false: '', + }, + }, + defaultVariants: { + isSelected: false, + }, + }, +); + +interface TimeLimitDropdownListProps { + selectedTime: number; + handleSelectTime: (time: number) => void; +} + +/** + * 시간 제한 드롭다운 리스트 컴포넌트 + * @param selectedTime 현재 선택된 시간 제한 값 + * @param handleSelectTime 시간 제한 선택 핸들러 + * @returns 시간 제한 드롭다운 리스트 JSX 요소 + */ + +function TimeLimitDropdownList({ selectedTime, handleSelectTime }: TimeLimitDropdownListProps) { + return ( +
    + {TIME_LIMIT_OPTIONS.map((option) => { + const isSelected = option.value === selectedTime; + return ( +
  • handleSelectTime(option.value)} + className={dropdownItemVariants({ isSelected })} + > + {option.label} +
  • + ); + })} +
+ ); +} + +interface TimeLimitDropdownProps extends VariantProps { + onChange: (time: number) => void; + selectedTime?: number; + className?: string; +} + +/** + * 시간 제한 드롭다운 컴포넌트 + * @param className 추가 클래스 이름 + * @param onChange 시간 제한 변경 핸들러 + * @param selectedTime 현재 선택된 시간 제한 값 + * @returns 시간 제한 드롭다운 JSX 요소 + */ +export function TimeLimitDropdown({ + onChange, + className, + selectedTime = DEFAULT_TIME_LIMIT, +}: TimeLimitDropdownProps) { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + const selectedOption = TIME_LIMIT_OPTIONS.find((option) => option.value === selectedTime); + const displayLabel = selectedOption?.label ?? '시간 선택'; + + useOutsideClick(dropdownRef, isOpen, () => setIsOpen(false)); + useEscapeKey(isOpen, () => setIsOpen(false)); + + const handleToggle = () => { + setIsOpen((prev) => !prev); + }; + + const handleSelect = (optionValue: number) => { + onChange(optionValue); + setIsOpen(false); + }; + + return ( +
+ + + {isOpen && ( + + )} +
+ ); +} diff --git a/apps/frontend/src/shared/hooks/useEscapeKey.ts b/apps/frontend/src/shared/hooks/useEscapeKey.ts new file mode 100644 index 00000000..41b113f8 --- /dev/null +++ b/apps/frontend/src/shared/hooks/useEscapeKey.ts @@ -0,0 +1,23 @@ +import { useEffect } from 'react'; + +/** + * ESC 키 이벤트를 감지하여 콜백 함수를 실행하는 커스텀 훅 + * @param isActive 훅 활성화 상태 (true일 때만 ESC 키 감지) + * @param onEscape ESC 키 입력 시 실행할 콜백 함수 + */ +export function useEscapeKey(isActive: boolean, onEscape: () => void) { + useEffect(() => { + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onEscape(); + } + }; + + if (isActive) { + document.addEventListener('keydown', handleEscape); + return () => { + document.removeEventListener('keydown', handleEscape); + }; + } + }, [isActive, onEscape]); +} diff --git a/apps/frontend/src/shared/hooks/useOutsideClick.ts b/apps/frontend/src/shared/hooks/useOutsideClick.ts new file mode 100644 index 00000000..292bde3b --- /dev/null +++ b/apps/frontend/src/shared/hooks/useOutsideClick.ts @@ -0,0 +1,28 @@ +import { RefObject, useEffect } from 'react'; + +/** + * 특정 요소 외부 클릭을 감지하여 콜백 함수를 실행하는 커스텀 훅 + * @param ref 외부 클릭을 감지할 요소의 ref + * @param isActive 훅 활성화 상태 (true일 때만 외부 클릭 감지) + * @param onOutsideClick 외부 클릭 시 실행할 콜백 함수 + */ +export function useOutsideClick( + ref: RefObject, + isActive: boolean, + onOutsideClick: () => void, +) { + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (ref.current && !ref.current.contains(event.target as Node)) { + onOutsideClick(); + } + }; + + if (isActive) { + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + } + }, [ref, isActive, onOutsideClick]); +}