diff --git a/web/components/TopNav.tsx b/web/components/TopNav.tsx index 734b469..d77d48b 100644 --- a/web/components/TopNav.tsx +++ b/web/components/TopNav.tsx @@ -7,17 +7,21 @@ // Esc 또는 바깥 클릭으로 popover 닫힘. import Link from "next/link"; -import { useEffect, useRef, useState } from "react"; +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(() => { @@ -47,14 +51,41 @@ export function TopNav() { return () => clearTimeout(h); }, [q]); - // 바깥 클릭으로 popover 닫기. + // 바깥 클릭으로 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); + if (e.key === "Escape") { + setOpen(false); + inputRef.current?.blur(); + return; + } + // Cmd/Ctrl+K — OS 공통 검색 단축키. + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { + 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); @@ -64,6 +95,40 @@ export function TopNav() { }; }, []); + // 현재 선택 가능한 코드 목록 — 검색 모드면 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 (
@@ -76,13 +141,17 @@ export function TopNav() {
{ setQ(e.target.value); setOpen(true); + setHi(0); }} onFocus={() => setOpen(true)} - placeholder="종목 검색" + 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() && ( @@ -94,15 +163,18 @@ export function TopNav() {
결과 없음
)}
    - {items.map((it) => ( + {items.map((it, i) => (
  • setHi(i)} onClick={() => { setOpen(false); setQ(""); }} - className="flex items-center justify-between gap-2 px-3 py-2 text-xs hover:bg-zinc-800/60" + 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} @@ -135,12 +207,15 @@ export function TopNav() {
    - {recentCodes.map((c) => ( + {recentCodes.map((c, i) => (
  • setHi(i)} onClick={() => setOpen(false)} - className="block px-3 py-2 text-xs text-zinc-200 hover:bg-zinc-800/60" + className={`block px-3 py-2 text-xs text-zinc-200 ${ + i === hi ? "bg-zinc-800/80" : "hover:bg-zinc-800/60" + }`} > {c}