'use client'; import '@/app/styles/header-search.scss'; import '@/app/styles/stock-common.scss'; import Styles from '../styles/layout.module.scss'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useRouter } from 'next/navigation'; import { fetchApi } from '@/lib/utils/client'; import { StockSuggestItem, StockSuggestResponse } from '@/types/stock'; const DEBOUNCE_MS = 300; const LISTBOX_ID = 'header-suggest-listbox'; // 헤더 중앙 통합 검색 — 디바운스 suggest + combobox 키보드 탐색 (↑↓ Enter Esc), 선택 시 /stock/{code} 이동 export default function HeaderSearch() { const router = useRouter(); const boxRef = useRef(null); const inputRef = useRef(null); const debounceRef = useRef|null>(null); const requestSeq = useRef(0); const [keyword, setKeyword] = useState(''); const [items, setItems] = useState([]); const [open, setOpen] = useState(false); const [activeIndex, setActiveIndex] = useState(-1); // 통합 검색 포커스 단축키: Ctrl+K(⌘+K) / '/' (finviz 관례) useEffect(() => { const handler = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); inputRef.current?.focus(); return; } if (e.key === '/' && !e.ctrlKey && !e.metaKey && !e.altKey) { const target = e.target as HTMLElement|null; const tag = target?.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target?.isContentEditable) { return; } e.preventDefault(); inputRef.current?.focus(); } }; window.addEventListener('keydown', handler); return () => { window.removeEventListener('keydown', handler); }; }, []); // 외부 클릭 시 드롭다운 닫기 useEffect(() => { const handler = (e: MouseEvent) => { if (boxRef.current && !boxRef.current.contains(e.target as Node)) { setOpen(false); } }; document.addEventListener('mousedown', handler); return () => { document.removeEventListener('mousedown', handler); }; }, []); useEffect(() => { return () => { if (debounceRef.current) { clearTimeout(debounceRef.current); } }; }, []); const requestSuggest = useCallback(async (q: string) => { const seq = ++requestSeq.current; const res = await fetchApi(`/api/stocks/suggest?q=${encodeURIComponent(q)}`, { silent: true }); // 늦게 도착한 이전 응답 무시 (out-of-order 방지) if (seq !== requestSeq.current) { return; } setItems(res.success ? (res.data?.list ?? []) : []); setActiveIndex(-1); setOpen(true); }, []); // 입력 디바운스 300ms → suggest API const handleChange = useCallback((value: string) => { setKeyword(value); setActiveIndex(-1); if (debounceRef.current) { clearTimeout(debounceRef.current); } const trimmed = value.trim(); if (!trimmed) { requestSeq.current++; setItems([]); setOpen(false); return; } debounceRef.current = setTimeout(() => { requestSuggest(trimmed); }, DEBOUNCE_MS); }, [requestSuggest]); const moveTo = useCallback((item: StockSuggestItem) => { setOpen(false); setKeyword(''); setItems([]); setActiveIndex(-1); inputRef.current?.blur(); router.push(`/stock/${item.code}`); }, [router]); const handleKeyDown = useCallback((e: React.KeyboardEvent) => { // 한글 IME 조합 중 Enter 오동작 방지 if (e.nativeEvent.isComposing) { return; } if (e.key === 'ArrowDown') { if (open && items.length > 0) { e.preventDefault(); setActiveIndex(prev => (prev + 1) % items.length); } return; } if (e.key === 'ArrowUp') { if (open && items.length > 0) { e.preventDefault(); setActiveIndex(prev => (prev <= 0 ? items.length - 1 : prev - 1)); } return; } if (e.key === 'Enter') { e.preventDefault(); if (open && activeIndex >= 0 && activeIndex < items.length) { moveTo(items[activeIndex]); return; } // 선택 없이 Enter — 종목 목록 검색으로 이동 const trimmed = keyword.trim(); if (trimmed) { setOpen(false); router.push(`/stock?q=${encodeURIComponent(trimmed)}`); } return; } if (e.key === 'Escape') { setOpen(false); setActiveIndex(-1); } }, [open, items, activeIndex, keyword, moveTo, router]); return (
= 0 ? `${LISTBOX_ID}-${activeIndex}` : undefined} value={keyword} onChange={e => handleChange(e.target.value)} onKeyDown={handleKeyDown} onFocus={() => { if (items.length > 0 && keyword.trim()) { setOpen(true); } }} /> {open && (
    {items.length === 0 ? (
  • 검색 결과가 없습니다.
  • ) : ( items.map((item, index) => (
  • { // blur 로 드롭다운이 먼저 닫히지 않게 기본 동작 차단 후 이동 e.preventDefault(); moveTo(item); }} onMouseEnter={() => setActiveIndex(index)} > {item.name} {item.code} {item.market}
  • )) )}
)}
); }