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