feat(shortcuts): keyboard shortcuts help overlay + F modifier guard
- 글로벌 `ShortcutsHelp` 컴포넌트 추가 — `?` 키로 토글, ESC 또는 바깥
클릭 / 닫기 버튼 / 우하단 floating ? 버튼으로 닫음. /, Ctrl+K, F,
ESC, ↑↓, Enter 등 현재 사이트의 모든 단축키를 scope 별로 표시.
- 입력 필드 포커스 시 단축키 무시 가드 — `?` 입력 의도를 방해하지 않음.
- 열릴 때 닫기 버튼에 포커스 → 키보드 사용자 Tab 흐름 자연스럽게.
- 모달 click-outside → setOpen(false), aria-modal/role=dialog 로 접근성.
- 리뷰어 9f215ac 지적: StockChart F 단축키가 Ctrl+F/Cmd+F/Alt+F 도
가로채던 것을 modifier guard 로 막음. shift/ctrl/meta/alt 어느 것도
눌리지 않은 단순 F 일 때만 toggleFullscreen.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import "./globals.css";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { AlertsToaster } from "../components/AlertsToaster";
|
||||
import { PwaRegister } from "../components/PwaRegister";
|
||||
import { ShortcutsHelp } from "../components/ShortcutsHelp";
|
||||
import { TopNav } from "../components/TopNav";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -36,6 +37,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<TopNav />
|
||||
{children}
|
||||
<AlertsToaster />
|
||||
<ShortcutsHelp />
|
||||
<PwaRegister />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
147
web/components/ShortcutsHelp.tsx
Normal file
147
web/components/ShortcutsHelp.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
// 글로벌 키보드 단축키 헬프 오버레이.
|
||||
// `?` (Shift+/) 로 열고 ESC 또는 바깥 클릭 / 닫기 버튼으로 닫음.
|
||||
// 다른 슬라이스에서 키보드 단축키를 추가해도 발견 가능성을 유지하기 위한 공용 패널.
|
||||
//
|
||||
// 등록되는 단축키는 여기 한 곳에서 정의 — 실제 핸들러는 각 컴포넌트가 가지고 있고,
|
||||
// 이 컴포넌트는 단순 표시 + `?` 토글만 담당. 단일 출처(Single Source of Truth)는 아니지만,
|
||||
// 이 사이트가 사용하는 단축키 수가 적어 수동 동기화로 충분.
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
type Shortcut = {
|
||||
keys: string[]; // ['Ctrl', 'K'] 같은 표시용 토큰. 시각적으로 + 로 join.
|
||||
label: string;
|
||||
scope?: string; // '전역' / '종목 페이지' 등 — group 헤더 라벨.
|
||||
};
|
||||
|
||||
const SHORTCUTS: Shortcut[] = [
|
||||
{ scope: "전역", keys: ["/"], label: "종목 검색창 포커스" },
|
||||
{ scope: "전역", keys: ["Ctrl", "K"], label: "종목 검색창 포커스 (Mac: ⌘K)" },
|
||||
{ scope: "전역", keys: ["Esc"], label: "팝오버 / 오버레이 닫기" },
|
||||
{ scope: "전역", keys: ["?"], label: "이 도움말 열기 / 닫기" },
|
||||
{ scope: "종목 페이지", keys: ["F"], label: "차트 전체화면 토글" },
|
||||
{ scope: "검색 팝오버", keys: ["↑", "↓"], label: "결과 이동" },
|
||||
{ scope: "검색 팝오버", keys: ["Enter"], label: "선택한 종목으로 이동" },
|
||||
];
|
||||
|
||||
function 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;
|
||||
}
|
||||
|
||||
export function ShortcutsHelp() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const closeBtnRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
// `?` 키 = 토글. 입력 중에는 무시 (사용자가 실제 ? 입력하려는 의도).
|
||||
// ESC 는 모달 안에서 onKeyDown 으로도 처리하지만, 글로벌 핸들러로도 닫음 — 포커스가
|
||||
// 모달 밖에 있어도 닫히도록.
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "?" && !isEditableTarget(e.target)) {
|
||||
e.preventDefault();
|
||||
setOpen((v) => !v);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape" && open) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open]);
|
||||
|
||||
// 열릴 때 닫기 버튼에 포커스 — 키보드 사용자가 바로 Tab 흐름을 잡을 수 있게.
|
||||
useEffect(() => {
|
||||
if (open) closeBtnRef.current?.focus();
|
||||
}, [open]);
|
||||
|
||||
// 화면 우하단 floating 버튼 — 모바일/마우스 사용자가 키보드 없이도 발견 가능.
|
||||
// 모달이 열려 있으면 같은 버튼이 닫기 역할.
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="fixed bottom-4 right-4 z-30 flex h-8 w-8 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/80 text-xs text-zinc-300 shadow-lg backdrop-blur transition hover:border-zinc-500 hover:text-zinc-100"
|
||||
title="키보드 단축키 (?)"
|
||||
aria-label="키보드 단축키 도움말"
|
||||
aria-expanded={open}
|
||||
>
|
||||
?
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="키보드 단축키"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) setOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className="w-full max-w-md rounded-lg border border-zinc-700 bg-zinc-950 p-5 shadow-2xl">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-zinc-100">키보드 단축키</h2>
|
||||
<button
|
||||
ref={closeBtnRef}
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded-md border border-zinc-700 px-2 py-0.5 text-[11px] text-zinc-400 hover:border-zinc-500 hover:text-zinc-200"
|
||||
aria-label="닫기"
|
||||
>
|
||||
닫기 (Esc)
|
||||
</button>
|
||||
</div>
|
||||
<ShortcutTable />
|
||||
<p className="mt-3 text-[10px] text-zinc-500">
|
||||
입력 필드에 포커스가 있으면 단축키는 동작하지 않습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// scope 별로 묶어 한 줄에 [키] — 설명 형태로 표시. 같은 scope 가 연속되면 헤더 생략해서
|
||||
// 시각적 노이즈 줄임.
|
||||
function ShortcutTable() {
|
||||
let prevScope: string | undefined;
|
||||
return (
|
||||
<ul className="space-y-1.5">
|
||||
{SHORTCUTS.map((s, i) => {
|
||||
const showScope = s.scope && s.scope !== prevScope;
|
||||
prevScope = s.scope;
|
||||
return (
|
||||
<li key={i}>
|
||||
{showScope && (
|
||||
<div className="mb-0.5 mt-2 first:mt-0 text-[10px] uppercase tracking-wide text-zinc-500">
|
||||
{s.scope}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-3 text-[12px]">
|
||||
<span className="text-zinc-300">{s.label}</span>
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
{s.keys.map((k, j) => (
|
||||
<span key={j} className="flex items-center gap-1">
|
||||
{j > 0 && <span className="text-[10px] text-zinc-600">+</span>}
|
||||
<kbd className="rounded border border-zinc-700 bg-zinc-900 px-1.5 py-0.5 font-mono text-[10px] text-zinc-200">
|
||||
{k}
|
||||
</kbd>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -793,7 +793,15 @@ export function StockChart({ chart, prediction, targets }: Props) {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (e.key === "f" || e.key === "F") {
|
||||
// Ctrl+F / Cmd+F (브라우저 찾기) / Alt+F (브라우저 메뉴) / Shift+F 는 가로채지 않음.
|
||||
// 단순 F 만 차트 전체화면 토글로 사용.
|
||||
if (
|
||||
!e.ctrlKey &&
|
||||
!e.metaKey &&
|
||||
!e.altKey &&
|
||||
!e.shiftKey &&
|
||||
e.key.toLowerCase() === "f"
|
||||
) {
|
||||
e.preventDefault();
|
||||
void toggleFullscreen();
|
||||
} else if (e.key === "Escape" && fullscreen && !document.fullscreenElement) {
|
||||
|
||||
Reference in New Issue
Block a user