"use client"; // 글로벌 sticky 헤더 — 모든 페이지 상단에 고정. 토스 톱바 톤. // 좌: 로고/타이틀, 중: 컴팩트 검색 (debounced + popover), 우: 관심종목 링크. // // 홈의 큰 SearchBox 와 별개로 동작. 결과 클릭 시 popover 가 닫히고 페이지 이동. // Esc 또는 바깥 클릭으로 popover 닫힘. import Link from "next/link"; import { useRouter } from "next/navigation"; import { useEffect, useMemo, useRef, useState } from "react"; import { api, type Symbol } from "../lib/api"; import { recent } from "../lib/recent"; export function TopNav() { const router = useRouter(); const [q, setQ] = useState(""); const [items, setItems] = useState([]); const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); const [recentCodes, setRecentCodes] = useState([]); const [hi, setHi] = useState(0); // popover 선택 인덱스 (검색 결과 또는 recent) const wrapRef = useRef(null); const inputRef = useRef(null); // 최근 본 종목 구독 — popover 빈 상태에서 보여줌. useEffect(() => { const refresh = () => setRecentCodes(recent.get()); refresh(); return recent.subscribe(refresh); }, []); // 디바운스 검색. 입력 비면 결과 클리어. useEffect(() => { const term = q.trim(); if (!term) { setItems([]); return; } setLoading(true); const h = setTimeout(async () => { try { const r = await api.search(term, false, 8, false); setItems(r.items); } catch { setItems([]); } finally { setLoading(false); } }, 200); return () => clearTimeout(h); }, [q]); // 바깥 클릭으로 popover 닫기 + 글로벌 단축키. // - "/" 또는 Cmd/Ctrl+K → 검색 인풋 포커스 + popover 열기 // (단, 다른 input/textarea/contenteditable 에 포커스 중이면 무시) // - Esc → popover 닫기 useEffect(() => { const onDoc = (e: MouseEvent) => { if (!wrapRef.current) return; if (!wrapRef.current.contains(e.target as Node)) setOpen(false); }; const isEditableTarget = (t: EventTarget | null): boolean => { if (!(t instanceof HTMLElement)) return false; const tag = t.tagName; if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true; if (t.isContentEditable) return true; return false; }; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { setOpen(false); inputRef.current?.blur(); return; } // Cmd/Ctrl+K — OS 공통 검색 단축키. 단, 다른 인풋/편집 영역에서 입력 중이면 // 브라우저/편집기 단축키를 뺏지 않도록 동일하게 무시. if ( (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k" && !isEditableTarget(e.target) ) { e.preventDefault(); inputRef.current?.focus(); setOpen(true); return; } // "/" — 다른 인풋에 포커스 없을 때만. if (e.key === "/" && !isEditableTarget(e.target)) { e.preventDefault(); inputRef.current?.focus(); setOpen(true); } }; document.addEventListener("mousedown", onDoc); document.addEventListener("keydown", onKey); return () => { document.removeEventListener("mousedown", onDoc); document.removeEventListener("keydown", onKey); }; }, []); // 현재 선택 가능한 코드 목록 — 검색 모드면 items, 빈 모드면 recent. const navCodes = useMemo(() => { if (q.trim()) return items.map((it) => it.code); return recentCodes; }, [q, items, recentCodes]); // 목록 길이가 바뀌면 hi 를 clamp. useEffect(() => { setHi((h) => (navCodes.length === 0 ? 0 : Math.min(h, navCodes.length - 1))); }, [navCodes]); // 인풋 키 이벤트 — ↑↓ 이동, Enter 이동, Esc 닫기. const onInputKey = (e: React.KeyboardEvent) => { if (e.key === "ArrowDown") { if (navCodes.length === 0) return; e.preventDefault(); setOpen(true); setHi((h) => (h + 1) % navCodes.length); } else if (e.key === "ArrowUp") { if (navCodes.length === 0) return; e.preventDefault(); setOpen(true); setHi((h) => (h - 1 + navCodes.length) % navCodes.length); } else if (e.key === "Enter") { if (navCodes.length === 0) return; e.preventDefault(); const code = navCodes[hi] ?? navCodes[0]; router.push(`/${code}`); setOpen(false); setQ(""); inputRef.current?.blur(); } }; return (
Stock Chart
{ setQ(e.target.value); setOpen(true); setHi(0); }} onFocus={() => setOpen(true)} onKeyDown={onInputKey} placeholder="종목 검색 ( / )" aria-label="종목 검색" className="w-full rounded-md border border-zinc-800 bg-zinc-900 px-3 py-1.5 text-xs text-zinc-100 outline-none focus:border-zinc-600" /> {open && q.trim() && (
{loading && (
검색중…
)} {!loading && items.length === 0 && (
결과 없음
)}
    {items.map((it, i) => (
  • setHi(i)} onClick={() => { setOpen(false); setQ(""); }} className={`flex items-center justify-between gap-2 px-3 py-2 text-xs ${ i === hi ? "bg-zinc-800/80" : "hover:bg-zinc-800/60" }`} > {it.name} {it.is_seed && ( SEED )} {it.code} · {it.market}
  • ))}
)} {open && !q.trim() && recentCodes.length > 0 && (
최근 본 종목
    {recentCodes.map((c, i) => (
  • setHi(i)} onClick={() => setOpen(false)} className={`block px-3 py-2 text-xs text-zinc-200 ${ i === hi ? "bg-zinc-800/80" : "hover:bg-zinc-800/60" }`} > {c}
  • ))}
)}
비교 🔔 ★ 관심
); }