리뷰어 지적: 주석엔 '다른 인풋/편집 영역에서 무시'라고 적어놨지만 실제 코드는 '/' 키에만 가드를 걸어둬서 Cmd/Ctrl+K 는 모든 input/textarea/contenteditable 에서 브라우저/편집기 단축키를 가로채는 상태였음. 수정: '/' 와 동일하게 !isEditableTarget(e.target) 조건 추가. 검색 인풋 자체에 포커스 중이면 어차피 focus()/setOpen(true) 가 noop 이라 UX 손해 없음.
258 lines
9.0 KiB
TypeScript
258 lines
9.0 KiB
TypeScript
"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<Symbol[]>([]);
|
|
const [open, setOpen] = useState(false);
|
|
const [loading, setLoading] = useState(false);
|
|
const [recentCodes, setRecentCodes] = useState<string[]>([]);
|
|
const [hi, setHi] = useState(0); // popover 선택 인덱스 (검색 결과 또는 recent)
|
|
const wrapRef = useRef<HTMLDivElement | null>(null);
|
|
const inputRef = useRef<HTMLInputElement | null>(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<string[]>(() => {
|
|
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<HTMLInputElement>) => {
|
|
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 (
|
|
<header className="sticky top-0 z-40 border-b border-zinc-800 bg-zinc-950/80 backdrop-blur">
|
|
<div className="mx-auto flex h-12 max-w-5xl items-center gap-4 px-6">
|
|
<Link
|
|
href="/"
|
|
className="shrink-0 text-sm font-semibold tracking-tight text-zinc-100 hover:text-white"
|
|
>
|
|
Stock Chart
|
|
</Link>
|
|
|
|
<div ref={wrapRef} className="relative flex-1 max-w-md">
|
|
<input
|
|
ref={inputRef}
|
|
value={q}
|
|
onChange={(e) => {
|
|
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() && (
|
|
<div className="absolute left-0 right-0 top-full mt-1 max-h-80 overflow-auto rounded-md border border-zinc-800 bg-zinc-950 shadow-xl">
|
|
{loading && (
|
|
<div className="px-3 py-2 text-xs text-zinc-500">검색중…</div>
|
|
)}
|
|
{!loading && items.length === 0 && (
|
|
<div className="px-3 py-2 text-xs text-zinc-500">결과 없음</div>
|
|
)}
|
|
<ul className="divide-y divide-zinc-800">
|
|
{items.map((it, i) => (
|
|
<li key={it.code}>
|
|
<Link
|
|
href={`/${it.code}`}
|
|
onMouseEnter={() => 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"
|
|
}`}
|
|
>
|
|
<span className="min-w-0 truncate text-zinc-100">
|
|
{it.name}
|
|
{it.is_seed && (
|
|
<span className="ml-1.5 rounded-sm bg-emerald-900/60 px-1 py-0.5 text-[9px] font-semibold text-emerald-300">
|
|
SEED
|
|
</span>
|
|
)}
|
|
</span>
|
|
<span className="shrink-0 text-zinc-500">
|
|
{it.code} · {it.market}
|
|
</span>
|
|
</Link>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
{open && !q.trim() && recentCodes.length > 0 && (
|
|
<div className="absolute left-0 right-0 top-full mt-1 max-h-80 overflow-auto rounded-md border border-zinc-800 bg-zinc-950 shadow-xl">
|
|
<div className="flex items-center justify-between px-3 py-1.5 text-[10px] text-zinc-500">
|
|
<span>최근 본 종목</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => recent.clear()}
|
|
className="hover:text-zinc-300"
|
|
>
|
|
지우기
|
|
</button>
|
|
</div>
|
|
<ul className="divide-y divide-zinc-800">
|
|
{recentCodes.map((c, i) => (
|
|
<li key={c}>
|
|
<Link
|
|
href={`/${c}`}
|
|
onMouseEnter={() => 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}
|
|
</Link>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<Link
|
|
href="/compare"
|
|
className="shrink-0 text-xs text-zinc-400 hover:text-zinc-200"
|
|
title="종목 비교"
|
|
>
|
|
비교
|
|
</Link>
|
|
<Link
|
|
href="/alerts"
|
|
className="shrink-0 text-xs text-zinc-400 hover:text-zinc-200"
|
|
title="가격 알람"
|
|
>
|
|
🔔
|
|
</Link>
|
|
<Link
|
|
href="/watchlist"
|
|
className="shrink-0 text-xs text-amber-300 hover:text-amber-200"
|
|
>
|
|
★ 관심
|
|
</Link>
|
|
</div>
|
|
</header>
|
|
);
|
|
}
|