Select

Interactive UI Explorer

아래에서 각 컴포넌트의 다양한 Variants States를 문서화된 형태로 테스트해볼 수 있습니다.

Live Preview
TypeScript
TypeScript
JavaScript
Python
Rust (disabled)

Select

기본 HTML <select> 대신, Input과 동일한 크기/스타일을 가진 div 기반 Select 컴포넌트입니다.
최대 노출 옵션 개수, 위/아래 방향 자동 결정, 비활성 옵션, 에러/헬퍼 텍스트 등을 지원합니다.

기본 Select

placeholder와 옵션을 가진 가장 기본적인 Select 예시

Source Code
          const [value, setValue] = useState<string | undefined>();

          <Select
            label="언어 선택"
            placeholder="언어를 선택하세요"
            value={value}
            onChange={setValue}
            options={languageOptions}
          />
        
언어 선택

아직 선택되지 않았습니다.

Live Preview

사이즈 변경

Input 높이와 동일한 sm / md / lg 사이즈를 제공합니다.

Small
<Select size="sm" ... />
Small
Live Preview
Medium
<Select size="md" ... />
Medium
Live Preview
Large
<Select size="lg" ... />
Large
Live Preview

최대 표시 개수 + 스크롤

maxVisibleOptions로 노출되는 옵션 개수를 제한하고, 초과분은 스크롤로 표시합니다.

Source Code
          <Select
            maxVisibleOptions={4}
            options={languageOptions}
          />
        
언어 (최대 4개 표시)
Live Preview

상태 선택 (에러 / 헬퍼 텍스트)

에러 메시지와 헬퍼 텍스트를 사용하는 예시

Source Code
          <Select
            label="상태"
            value={statusValue}
            onChange={setStatusValue}
            options={statusOptions}
            error={statusValue === 'archived' ? '보관 상태는 선택할 수 없습니다.' : undefined}
          />
        
상태

프로젝트의 현재 상태를 선택하세요.

Live Preview

옵션을 팝업으로 사용 (usePopup) — 두 가지 방식

usePopup={true}일 때 popupPlacement로 표시 방식을 구분할 수 있습니다. 'anchor'는 트리거 버튼 옆에 드롭다운처럼 붙어서 열리고, 'center'는 화면 중앙에 모달처럼 열립니다.

Source Code
          const [value, setValue] = useState<string | undefined>();

          // 앵커형: 트리거 옆에 붙어서 표시 (기본값)
          <Select
            label="앵커형 팝업"
            placeholder="클릭하여 선택"
            value={value}
            onChange={setValue}
            options={options}
            usePopup
            popupPlacement="anchor"
            popupTitle="선택 (트리거 옆)"
          />

          // 중앙형: 화면 중앙에 모달처럼 표시
          <Select
            label="중앙형 팝업"
            placeholder="클릭하여 선택"
            value={value}
            onChange={setValue}
            options={options}
            usePopup
            popupPlacement="center"
            popupTitle="옵션을 선택하세요"
          />
        

앵커형 (anchor) — 트리거 옆에 붙는 형태

언어 선택 (앵커형)

옵션이 버튼 아래에 붙어서 열립니다.

중앙형 (center) — 화면 중앙 모달 형태

언어 선택 (중앙형)

옵션이 화면 중앙에 팝업으로 열립니다.

Live Preview
Implementation

제작 코드

이 컴포넌트가 실제로 어떻게 구현되어 있는지 — 본체 .tsx 파일을 그대로 보여줍니다. variant 매핑·접근성 처리·forwardRef 패턴 등 디테일을 그대로 확인할 수 있어요.

Selecttypescript
'use client';

/**
 * Select 컴포넌트
 *
 * - 기본 HTML <select> 대신 div 기반으로 구현한 커스텀 셀렉트 박스입니다.
 * - 기존 Input 컴포넌트와 동일한 높이/스타일을 사용합니다.
 * - maxVisible 옵션으로 노출되는 옵션 개수를 제한하고, 초과분은 스크롤로 표시합니다.
 * - 기본적으로 아래로 펼쳐지며, 화면 아래 여유 공간이 부족하면 자동으로 위로 펼쳐집니다.
 * - ESC / 바깥 클릭 시 닫기, 키보드 방향키/Enter 지원 등 실사용에 적합한 동작을 제공합니다.
 */

import {
  useCallback,
  useEffect,
  useMemo,
  useRef,
  useState,
  type HTMLAttributes,
  type ReactNode,
} from 'react';
import { createPortal } from 'react-dom';
import type { ChangeEvent } from 'react';
import { cn } from '@/lib/cn';
import { Modal } from '@/components/Modal';

export interface SelectOption {
  value: string;
  label: string;
  description?: string;
  disabled?: boolean;
}

export type SelectSize = 'sm' | 'md' | 'lg';

export interface SelectProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
  /** 선택된 값 (제어 컴포넌트) */
  value?: string;
  /** 값 변경 시 호출 */
  onChange?: (value: string | undefined) => void;
  /** 옵션 리스트 */
  options: SelectOption[];
  /** placeholder 텍스트 (선택값이 없을 때 표시) */
  placeholder?: string;
  /** 비활성화 여부 */
  disabled?: boolean;
  /** 한 번에 보이는 최대 옵션 개수 (초과 시 스크롤) */
  maxVisibleOptions?: number;
  /** 셀렉트 높이 (Input과 동일한 높이 사용) */
  size?: SelectSize;
  /** 에러 메시지 (Input과 동일한 스타일) */
  error?: string;
  /** 보조 설명 텍스트 */
  helperText?: string;
  /** 라벨 텍스트 */
  label?: string;
  /**
   * "직접 입력"과 같은 커스텀 입력 옵션의 value
   * - 이 value를 가진 옵션이 선택되면 아래에 input 필드가 노출됩니다.
   */
  customInputOptionValue?: string;
  /** 커스텀 입력 필드 placeholder */
  customInputPlaceholder?: string;
  /** 커스텀 입력 값 변경 시 호출 */
  onCustomInputChange?: (value: string) => void;
  /** true면 옵션 목록을 모달(Modal)로 띄워서 선택 (기본 false: 인라인 드롭다운) */
  usePopup?: boolean;
  /**
   * usePopup일 때 팝업 표시 방식.
   * - 'anchor': 선택박스(트리거) 아래에 모달형 패널로 표시
   * - 'center': 화면 중앙에 Modal로 표시
   */
  popupPlacement?: 'anchor' | 'center';
  /** usePopup일 때 모달 상단에 표시할 제목 (선택) */
  popupTitle?: string;
}

const sizeClassMap: Record<SelectSize, string> = {
  sm: 'h-9 text-sm px-3',
  md: 'h-10 text-sm px-3.5',
  lg: 'h-11 text-base px-4',
};

// "직접 입력" 오버레이 input의 폰트 크기를 셀렉트와 동일하게 맞추기 위한 맵
const inputTextSizeClassMap: Record<SelectSize, string> = {
  sm: 'text-sm',
  md: 'text-base',
  lg: 'text-lg',
};

// Live Preview(SelectHero) 목업과 동일한 chevron-down 아이콘
function ChevronDown({ className }: { className?: string }) {
  return (
    <svg
      className={className}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2.5}
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      <path d="M19 9l-7 7-7-7" />
    </svg>
  );
}

// 인라인 드롭다운 / 앵커 팝업 / 중앙 모달 — 세 모드가 공유하는 옵션 행 스타일
function optionRowClass(state: {
  disabled?: boolean;
  selected: boolean;
  highlighted: boolean;
}): string {
  return cn(
    'flex w-full min-w-0 items-start rounded-lg px-3 py-2 text-left text-sm transition-colors',
    state.disabled
      ? 'cursor-not-allowed text-slate-400'
      : state.selected
        ? 'cursor-pointer bg-violet-50 font-semibold text-violet-700'
        : state.highlighted
          ? 'cursor-pointer bg-slate-100 text-slate-900'
          : 'cursor-pointer text-slate-700 hover:bg-slate-50',
  );
}

// 옵션 라벨 + 설명(있을 때) — 세 모드 공통 렌더
function OptionContent({
  option,
  selected,
}: {
  option: SelectOption;
  selected: boolean;
}) {
  return (
    <div className="flex min-w-0 flex-col items-start">
      <span className="truncate">{option.label}</span>
      {option.description && (
        <span
          className={cn(
            'mt-0.5 text-xs',
            selected ? 'text-violet-600/70' : 'text-slate-500',
          )}
        >
          {option.description}
        </span>
      )}
    </div>
  );
}

export function Select({
  value,
  onChange,
  options,
  placeholder = '옵션을 선택하세요',
  disabled,
  maxVisibleOptions = 5,
  size = 'md',
  error,
  helperText,
  label,
  className,
  customInputOptionValue,
  customInputPlaceholder,
  onCustomInputChange,
  usePopup = false,
  popupPlacement = 'anchor',
  popupTitle,
  ...rest
}: SelectProps) {
  const [open, setOpen] = useState(false);
  const [internalValue, setInternalValue] = useState<string | undefined>(value);
  const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
  const [dropdownDirection, setDropdownDirection] = useState<'down' | 'up'>('down');
  const [customInput, setCustomInput] = useState('');
  const [focused, setFocused] = useState(false);

  const rootRef = useRef<HTMLDivElement | null>(null);
  const buttonRef = useRef<HTMLButtonElement | null>(null);
  const listRef = useRef<HTMLDivElement | null>(null);
  const anchorPanelRef = useRef<HTMLDivElement | null>(null);
  const customInputRef = useRef<HTMLInputElement | null>(null);
  const [anchorRect, setAnchorRect] = useState<{ top: number; left: number; width: number } | null>(null);
  const typedRef = useRef('');
  const typeAheadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const lastKeyRef = useRef<string | null>(null);

  // 제어/비제어 동기화
  useEffect(() => {
    setInternalValue(value);
  }, [value]);

  const selectedOption = useMemo(
    () => options.find((opt) => opt.value === internalValue),
    [options, internalValue],
  );

  const visibleOptions = options;
  const isCustomSelected =
    !!customInputOptionValue && internalValue === customInputOptionValue;

  const handleOpen = useCallback(() => {
    if (disabled) return;
    setOpen(true);
    if (visibleOptions.length === 0) {
      setHighlightedIndex(-1);
      return;
    }
    const idx =
      selectedOption != null
        ? visibleOptions.findIndex((o) => o.value === selectedOption.value)
        : 0;
    setHighlightedIndex(idx >= 0 ? idx : 0);
  }, [disabled, selectedOption, visibleOptions]);

  const handleClose = useCallback(() => {
    setOpen(false);
    setHighlightedIndex(-1);
  }, []);

  const handleToggle = () => {
    if (open) {
      handleClose();
    } else {
      handleOpen();
    }
  };

  const handleSelect = useCallback(
    (option: SelectOption) => {
      if (option.disabled) return;
      setInternalValue(option.value);
      onChange?.(option.value);
      handleClose();
    },
    [onChange],
  );

  // Tab 키 감지: Tab으로 포커스가 올 경우 다음 onFocus에서 드롭다운 열기
  useEffect(() => {
    const onKeyDown = (e: KeyboardEvent) => {
      if (e.key === 'Tab') {
        lastKeyRef.current = 'Tab';
      }
    };
    document.addEventListener('keydown', onKeyDown);
    return () => document.removeEventListener('keydown', onKeyDown);
  }, []);

  // 바깥 클릭 감지
  useEffect(() => {
    if (!open) return;

    const handleClickOutside = (event: MouseEvent) => {
      const target = event.target as Node;
      if (rootRef.current?.contains(target)) return;
      if (usePopup && popupPlacement === 'anchor' && anchorPanelRef.current?.contains(target)) return;
      handleClose();
    };

    // 캡처 단계(true)로 듣는다 — Modal 패널의 onMouseDown stopPropagation(버블)보다 먼저 발동해
    // 모달 안에서도 바깥 클릭 시 정상적으로 닫힌다.
    document.addEventListener('mousedown', handleClickOutside, true);
    return () => {
      document.removeEventListener('mousedown', handleClickOutside, true);
    };
  }, [open, handleClose, usePopup, popupPlacement]);

  // ESC, 방향키, Enter, 타입어헤드(스펠링 키)
  useEffect(() => {
    if (!open) return;

    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key === 'Escape') {
        handleClose();
        return;
      }
      if (e.key === 'ArrowDown') {
        e.preventDefault();
        setHighlightedIndex((prev) => {
          const enabledOptions = visibleOptions.filter((o) => !o.disabled);
          if (enabledOptions.length === 0) return -1;
          if (prev < 0) return visibleOptions.indexOf(enabledOptions[0]);
          const current = visibleOptions[prev];
          const currentIdxInEnabled = enabledOptions.indexOf(current);
          const nextEnabled =
            enabledOptions[(currentIdxInEnabled + 1) % enabledOptions.length];
          return visibleOptions.indexOf(nextEnabled);
        });
        return;
      }
      if (e.key === 'ArrowUp') {
        e.preventDefault();
        setHighlightedIndex((prev) => {
          const enabledOptions = visibleOptions.filter((o) => !o.disabled);
          if (enabledOptions.length === 0) return -1;
          if (prev < 0) return visibleOptions.indexOf(
            enabledOptions[enabledOptions.length - 1],
          );
          const current = visibleOptions[prev];
          const currentIdxInEnabled = enabledOptions.indexOf(current);
          const nextIndex =
            currentIdxInEnabled <= 0
              ? enabledOptions.length - 1
              : currentIdxInEnabled - 1;
          const nextEnabled = enabledOptions[nextIndex];
          return visibleOptions.indexOf(nextEnabled);
        });
        return;
      }
      if (e.key === 'Enter' && highlightedIndex >= 0) {
        e.preventDefault();
        const option = visibleOptions[highlightedIndex];
        if (!option.disabled) handleSelect(option);
        return;
      }
      // 타입어헤드: 한 글자(영문/한글) 입력 시 해당 스펠링으로 시작하는 옵션으로 포커스
      const isLetter =
        e.key.length === 1 &&
        !e.ctrlKey &&
        !e.metaKey &&
        !e.altKey &&
        (/\p{L}/u.test(e.key) || /[a-zA-Z]/.test(e.key));
      if (isLetter) {
        e.preventDefault();
        if (typeAheadTimerRef.current) {
          clearTimeout(typeAheadTimerRef.current);
          typeAheadTimerRef.current = null;
        }
        typedRef.current += e.key;
        typeAheadTimerRef.current = setTimeout(() => {
          typedRef.current = '';
          typeAheadTimerRef.current = null;
        }, 500);

        const query = typedRef.current.toLowerCase();
        const enabledOptions = visibleOptions.filter((o) => !o.disabled);
        const found = enabledOptions.findIndex((o) =>
          o.label.toLowerCase().startsWith(query),
        );
        if (found >= 0) {
          const index = visibleOptions.indexOf(enabledOptions[found]);
          setHighlightedIndex(index);
        }
      }
    };

    document.addEventListener('keydown', handleKeyDown);
    return () => {
      document.removeEventListener('keydown', handleKeyDown);
      if (typeAheadTimerRef.current) {
        clearTimeout(typeAheadTimerRef.current);
        typeAheadTimerRef.current = null;
      }
    };
  }, [open, visibleOptions, highlightedIndex, handleClose, handleSelect]);

  // 포커스된 옵션을 뷰 안으로 스크롤
  useEffect(() => {
    if (!open || highlightedIndex < 0 || !listRef.current) return;
    const el = listRef.current.querySelector(
      `[data-option-index="${highlightedIndex}"]`,
    );
    if (el) {
      (el as HTMLElement).scrollIntoView({ block: 'nearest', behavior: 'auto' });
    }
  }, [open, highlightedIndex]);

  // 열릴 때 아래/위 방향 결정
  useEffect(() => {
    if (!open || !buttonRef.current) return;

    const rect = buttonRef.current.getBoundingClientRect();
    const viewportHeight =
      window.innerHeight || document.documentElement.clientHeight;

    const approxItemHeight = 40; // px (옵션 한 줄 높이 추정)
    const estimatedListHeight =
      Math.min(visibleOptions.length, maxVisibleOptions) * approxItemHeight;

    const spaceBelow = viewportHeight - rect.bottom;
    const spaceAbove = rect.top;

    if (spaceBelow < estimatedListHeight && spaceAbove > spaceBelow) {
      setDropdownDirection('up');
    } else {
      setDropdownDirection('down');
    }
  }, [open, visibleOptions.length, maxVisibleOptions]);

  const dropdownMaxHeight = maxVisibleOptions * 40; // 옵션 1줄 높이 기준

  // 앵커 패널 위치 (usePopup + anchor일 때)
  useEffect(() => {
    if (!open || !usePopup || popupPlacement !== 'anchor' || !buttonRef.current) {
      setAnchorRect(null);
      return;
    }
    const rect = buttonRef.current.getBoundingClientRect();
    setAnchorRect({
      top: rect.bottom + 4,
      left: rect.left,
      width: Math.max(rect.width, 280),
    });
    const onScrollOrResize = () => {
      if (buttonRef.current) {
        const r = buttonRef.current.getBoundingClientRect();
        setAnchorRect({ top: r.bottom + 4, left: r.left, width: Math.max(r.width, 280) });
      }
    };
    window.addEventListener('scroll', onScrollOrResize, true);
    window.addEventListener('resize', onScrollOrResize);
    return () => {
      window.removeEventListener('scroll', onScrollOrResize, true);
      window.removeEventListener('resize', onScrollOrResize);
    };
  }, [open, usePopup, popupPlacement]);

  const handleCustomInputChange = (e: ChangeEvent<HTMLInputElement>) => {
    const next = e.target.value;
    setCustomInput(next);
    onCustomInputChange?.(next);
  };

  // "직접 입력" 옵션이 선택되면 입력창에 자동 포커스
  useEffect(() => {
    if (isCustomSelected && customInputRef.current) {
      customInputRef.current.focus();
    }
  }, [isCustomSelected]);

  return (
    <div className={cn('space-y-1.5', className)} ref={rootRef}>
      {label && (
        <div className="block text-xs font-semibold text-slate-700">
          {label}
        </div>
      )}
      <div className="relative">
        {/* 선택 영역 (Input과 같은 스타일의 div) */}
        <button
          type="button"
          ref={buttonRef}
          disabled={disabled}
          onClick={handleToggle}
          onFocus={() => {
            setFocused(true);
            if (lastKeyRef.current === 'Tab') {
              lastKeyRef.current = null;
              handleOpen();
            }
          }}
          onBlur={() => setFocused(false)}
          className={cn(
            'selectedItemButton group flex w-full items-center justify-between rounded-xl border bg-white text-slate-900 shadow-sm transition-colors',
            'hover:cursor-pointer',
            'focus:outline-none',
            'disabled:opacity-60 disabled:cursor-not-allowed disabled:bg-slate-50',
            error ? 'border-rose-400' : 'border-slate-300 hover:border-slate-400',
            sizeClassMap[size],
            focused && (error
              ? 'ring-2 ring-rose-500/20 border-rose-500'
              : 'ring-2 ring-violet-500/20 border-violet-500'),
          )}
        >
          <span
            className={cn(
              'truncate text-left flex-1',
              !selectedOption && 'text-slate-400',
            )}
          >
            {!isCustomSelected && (selectedOption ? selectedOption.label : placeholder)}
          </span>

          {/* 화살표 — Live Preview 목업과 동일한 chevron-down */}
          <span
            className={cn(
              'ml-2 flex flex-shrink-0 items-center text-slate-400 transition-transform duration-200',
              open && 'rotate-180 text-slate-600',
            )}
          >
            <ChevronDown className="size-3.5" />
          </span>
        </button>

        {/* "직접 입력" 모드에서 셀렉트 상단에 동일 사이즈 입력창 오버레이 */
        }
        {isCustomSelected && !disabled && (
          <div className="pointer-events-none absolute inset-0 flex items-stretch">
            {/* 실제 입력 영역: 오른쪽 화살표 버튼 영역을 비워두기 위해 너비를 줄여서 렌더 */}
            <input
              ref={customInputRef}
              type="text"
              value={customInput}
              onChange={handleCustomInputChange}
              onFocus={() => setFocused(true)}
              onBlur={() => setFocused(false)}
              placeholder={customInputPlaceholder || '직접 입력'}
              className={cn(
                'pointer-events-auto h-full w-[calc(100%-2.5rem)] rounded-lg border border-transparent bg-transparent pl-4 text-black outline-none',
                inputTextSizeClassMap[size],
              )}
            />
            {/* 오른쪽 화살표 영역은 비워두어 아래 버튼의 화살표가 그대로 보이고 클릭도 가능하게 함 */}
            <div className="pointer-events-none h-full w-10" />
          </div>
        )}

        {/* 드롭다운 리스트 (usePopup이 false일 때만 인라인) */}
        {!usePopup && open && (
          <div
            ref={listRef}
            className={cn(
              'absolute left-0 right-0 z-50 rounded-2xl border border-slate-200 bg-white p-2 shadow-xl shadow-slate-900/10',
              'animate-in fade-in zoom-in-95 duration-150',
              dropdownDirection === 'up' ? 'bottom-full mb-1 mt-0' : 'top-full mt-1',
            )}
            style={{ maxHeight: dropdownMaxHeight, overflowY: 'auto' }}
          >
            {visibleOptions.length === 0 ? (
              <div className="px-3 py-2.5 text-sm text-slate-500">
                선택할 수 있는 옵션이 없습니다.
              </div>
            ) : (
              <ul className="space-y-0.5">
                {visibleOptions.map((option, index) => {
                  const isSelected = option.value === internalValue;
                  const isHighlighted = index === highlightedIndex;

                  return (
                    <li key={option.value}>
                      <button
                        type="button"
                        data-option-index={index}
                        data-selected={isSelected ? 'true' : 'false'}
                        disabled={option.disabled}
                        onClick={() => handleSelect(option)}
                        className={cn(
                          'selectedItemOptionButton',
                          optionRowClass({
                            disabled: option.disabled,
                            selected: isSelected,
                            highlighted: isHighlighted,
                          }),
                        )}
                        onMouseEnter={() => {
                          if (!option.disabled) setHighlightedIndex(index);
                        }}
                      >
                        <OptionContent option={option} selected={isSelected} />
                      </button>
                    </li>
                  );
                })}
              </ul>
            )}
          </div>
        )}

        {/* 앵커형 모달 패널 (usePopup + anchor: 선택박스 아래 모달 형태) */}
        {usePopup && popupPlacement === 'anchor' && open && anchorRect && typeof window !== 'undefined' && createPortal(
          <div
            className="fixed inset-0 z-[100]"
            role="dialog"
            aria-modal="true"
            aria-label={popupTitle || '선택'}
          >
            <div
              className="absolute inset-0 bg-black/10"
              aria-hidden
              onMouseDown={handleClose}
            />
            <div
              ref={anchorPanelRef}
              className="relative z-10 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-xl shadow-slate-900/10 min-w-[280px] max-w-[90vw]"
              style={{
                position: 'fixed',
                top: anchorRect.top,
                left: anchorRect.left,
                width: anchorRect.width,
                maxHeight: dropdownMaxHeight + (popupTitle ? 52 : 0),
              }}
              onMouseDown={(e) => e.stopPropagation()}
            >
              {popupTitle && (
                <div className="flex items-center justify-between border-b border-slate-100 bg-slate-50/80 px-4 py-2.5">
                  <h3 className="text-sm font-semibold text-slate-900">{popupTitle}</h3>
                  <button
                    type="button"
                    onClick={handleClose}
                    className="flex size-8 shrink-0 items-center justify-center rounded-lg text-slate-500 hover:bg-slate-100 hover:text-slate-700"
                    aria-label="닫기"
                  >
                    <svg className="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
                      <path d="M18 6L6 18" /><path d="M6 6L18 18" />
                    </svg>
                  </button>
                </div>
              )}
              <ul
                className="w-full space-y-0.5 overflow-y-auto p-2"
                style={{ maxHeight: dropdownMaxHeight }}
              >
                {visibleOptions.length === 0 ? (
                  <li className="w-full list-none px-3 py-2.5 text-sm text-slate-500">
                    선택할 수 있는 옵션이 없습니다.
                  </li>
                ) : (
                  visibleOptions.map((option, index) => {
                    const isSelected = option.value === internalValue;
                    const isHighlighted = index === highlightedIndex;
                    return (
                      <li key={option.value} className="w-full list-none">
                        <button
                          type="button"
                          disabled={option.disabled}
                          onMouseEnter={() => {
                            if (!option.disabled) setHighlightedIndex(index);
                          }}
                          onMouseDown={(e) => {
                            e.preventDefault();
                            e.stopPropagation();
                            handleSelect(option);
                          }}
                          onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}
                          className={optionRowClass({
                            disabled: option.disabled,
                            selected: isSelected,
                            highlighted: isHighlighted,
                          })}
                        >
                          <OptionContent option={option} selected={isSelected} />
                        </button>
                      </li>
                    );
                  })
                )}
              </ul>
            </div>
          </div>,
          document.body,
        )}

        {/* 화면 중앙 Modal (usePopup + center) */}
        {usePopup && popupPlacement === 'center' && (
          <Modal
            open={open}
            onClose={handleClose}
            title={popupTitle}
            size="sm"
            showCloseIcon
            closeOnBackdrop
            closeOnEsc
            bodyClass="p-0"
          >
            <ul
              className="w-full space-y-0.5 overflow-y-auto p-2"
              style={{ maxHeight: dropdownMaxHeight }}
            >
              {visibleOptions.length === 0 ? (
                <li className="w-full px-3 py-2.5 text-sm text-slate-500">
                  선택할 수 있는 옵션이 없습니다.
                </li>
              ) : (
                visibleOptions.map((option, index) => {
                  const isSelected = option.value === internalValue;
                  const isHighlighted = index === highlightedIndex;
                  return (
                    <li key={option.value} className="w-full list-none">
                      <button
                        type="button"
                        disabled={option.disabled}
                        onMouseEnter={() => {
                          if (!option.disabled) setHighlightedIndex(index);
                        }}
                        onMouseDown={(e) => {
                          e.preventDefault();
                          e.stopPropagation();
                          handleSelect(option);
                        }}
                        onClick={(e) => {
                          e.preventDefault();
                          e.stopPropagation();
                        }}
                        className={optionRowClass({
                          disabled: option.disabled,
                          selected: isSelected,
                          highlighted: isHighlighted,
                        })}
                      >
                        <OptionContent option={option} selected={isSelected} />
                      </button>
                    </li>
                  );
                })
              )}
            </ul>
          </Modal>
        )}
      </div>

      {error && <p className="text-xs font-medium text-rose-600">{error}</p>}
      {helperText && !error && (
        <p className="text-xs text-slate-500">{helperText}</p>
      )}
    </div>
  );
}