From 7cd865c450d25adf09a55c1bdf7d290ade298b21 Mon Sep 17 00:00:00 2001 From: claude-owner Date: Thu, 28 May 2026 19:19:01 +0900 Subject: [PATCH] =?UTF-8?q?feat(nav):=20TopNav=20=EA=B2=80=EC=83=89=20?= =?UTF-8?q?=ED=82=A4=EB=B3=B4=EB=93=9C=20=EB=84=A4=EB=B9=84=EA=B2=8C?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20+=20=EA=B8=80=EB=A1=9C=EB=B2=8C=20?= =?UTF-8?q?=EB=8B=A8=EC=B6=95=ED=82=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 키보드 조작: - "/" : 검색 인풋 포커스 (다른 인풋 포커스 중이면 무시) - Cmd/Ctrl+K : 동일 (OS 공통) - ↑/↓ : 결과 목록 wrap-around 이동 - Enter : 현재 강조 항목으로 이동 (검색 결과 또는 최근 본 종목) - Esc : popover 닫기 + 인풋 blur 기존 mouse hover 흐름과 충돌 없도록 onMouseEnter 에서 hi 동기화. navCodes 길이 변하면 hi 를 자동 clamp. placeholder 에 "( / )" 힌트 추가. isEditableTarget() 가드로 다른 입력 필드 (PredictionPanel, 검색칩 등) 에 포커스 중일 때 "/" 가로채지 않음. --- web/components/TopNav.tsx | 91 +++++++++++++++++++++++++++++++++++---- 1 file changed, 83 insertions(+), 8 deletions(-) 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}