Phase 2: 정적 인라인 스타일을 Tailwind className으로 변환 - common/: MapView, BacktrackReplayBar, LoginPage, LayerTree, ComboBox, SubMenuBar - hns/: HNSSubstanceView, HNSScenarioView, HNSView, HNSLeftPanel 등 8파일 - prediction/: BoomDeploymentTheoryView, OilBoomSection, RecalcModal, RightPanel 등 8파일 - incidents/: IncidentsView, IncidentsLeftPanel, IncidentsRightPanel - rescue/: RescueScenarioView - aerial/: SatelliteRequest, AerialTheoryView - assets/: ShipInsurance, AssetTheory, AssetManagement 등 5파일 - board/: BoardView - reports/: ReportsView, OilSpillReportTemplate, ReportGenerator - weather/: WeatherMapOverlay, WeatherView, WeatherRightPanel 변환 패턴: color/background/border/borderRadius/display/flex/gap/fontSize/fontWeight → Tailwind 동적 스타일(rgba, gradient, 삼항 조건부, 런타임 변수)은 style prop에 유지 JS 번들: 2,921KB → 2,897KB (-24KB) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
97 lines
3.0 KiB
TypeScript
Executable File
97 lines
3.0 KiB
TypeScript
Executable File
import { useState, useRef, useEffect } from 'react'
|
|
|
|
interface ComboBoxOption {
|
|
value: string
|
|
label: string
|
|
}
|
|
|
|
interface ComboBoxProps {
|
|
value: string | number
|
|
onChange: (value: string) => void
|
|
options: ComboBoxOption[]
|
|
placeholder?: string
|
|
className?: string
|
|
}
|
|
|
|
export function ComboBox({ value, onChange, options, placeholder, className }: ComboBoxProps) {
|
|
const [isOpen, setIsOpen] = useState(false)
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
|
|
useEffect(() => {
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
|
setIsOpen(false)
|
|
}
|
|
}
|
|
|
|
document.addEventListener('mousedown', handleClickOutside)
|
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
|
}, [])
|
|
|
|
const selectedOption = options.find(opt => opt.value === String(value))
|
|
const displayText = selectedOption?.label || placeholder || '선택'
|
|
|
|
return (
|
|
<div ref={containerRef} className="relative">
|
|
<div
|
|
className={`cursor-pointer flex items-center justify-between pr-2 ${className ?? ''}`}
|
|
onClick={() => setIsOpen(!isOpen)}
|
|
>
|
|
<span>{displayText}</span>
|
|
<span
|
|
className="text-[8px] text-text-3"
|
|
style={{
|
|
transition: 'transform 0.2s',
|
|
transform: isOpen ? 'rotate(180deg)' : 'rotate(0deg)'
|
|
}}
|
|
>
|
|
▼
|
|
</span>
|
|
</div>
|
|
|
|
{isOpen && (
|
|
<div
|
|
className="absolute left-0 right-0 bg-bg-0 border border-border overflow-y-auto z-[1000]"
|
|
style={{
|
|
top: 'calc(100% + 2px)',
|
|
borderRadius: 'var(--rS)',
|
|
maxHeight: '200px',
|
|
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
|
|
animation: 'fadeSlideDown 0.15s ease-out'
|
|
}}
|
|
>
|
|
{options.map((option) => (
|
|
<div
|
|
key={option.value}
|
|
onClick={() => {
|
|
onChange(option.value)
|
|
setIsOpen(false)
|
|
}}
|
|
className="text-[11px] cursor-pointer"
|
|
style={{
|
|
padding: '8px 10px',
|
|
color: option.value === String(value) ? 'var(--cyan)' : 'var(--t2)',
|
|
background: option.value === String(value) ? 'rgba(6,182,212,0.1)' : 'transparent',
|
|
transition: '0.1s',
|
|
borderLeft: option.value === String(value) ? '2px solid var(--cyan)' : '2px solid transparent'
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
if (option.value !== String(value)) {
|
|
e.currentTarget.style.background = 'rgba(255,255,255,0.03)'
|
|
}
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
if (option.value !== String(value)) {
|
|
e.currentTarget.style.background = 'transparent'
|
|
}
|
|
}}
|
|
>
|
|
{option.label}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|