feat(nav): TopNav 검색 키보드 네비게이션 + 글로벌 단축키
키보드 조작: - "/" : 검색 인풋 포커스 (다른 인풋 포커스 중이면 무시) - Cmd/Ctrl+K : 동일 (OS 공통) - ↑/↓ : 결과 목록 wrap-around 이동 - Enter : 현재 강조 항목으로 이동 (검색 결과 또는 최근 본 종목) - Esc : popover 닫기 + 인풋 blur 기존 mouse hover 흐름과 충돌 없도록 onMouseEnter 에서 hi 동기화. navCodes 길이 변하면 hi 를 자동 clamp. placeholder 에 "( / )" 힌트 추가. isEditableTarget() 가드로 다른 입력 필드 (PredictionPanel, 검색칩 등) 에 포커스 중일 때 "/" 가로채지 않음.
This commit is contained in:
@@ -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<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(() => {
|
||||
@@ -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<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">
|
||||
@@ -76,13 +141,17 @@ export function TopNav() {
|
||||
|
||||
<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)}
|
||||
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() {
|
||||
<div className="px-3 py-2 text-xs text-zinc-500">결과 없음</div>
|
||||
)}
|
||||
<ul className="divide-y divide-zinc-800">
|
||||
{items.map((it) => (
|
||||
{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 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"
|
||||
}`}
|
||||
>
|
||||
<span className="min-w-0 truncate text-zinc-100">
|
||||
{it.name}
|
||||
@@ -135,12 +207,15 @@ export function TopNav() {
|
||||
</button>
|
||||
</div>
|
||||
<ul className="divide-y divide-zinc-800">
|
||||
{recentCodes.map((c) => (
|
||||
{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 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}
|
||||
</Link>
|
||||
|
||||
Reference in New Issue
Block a user