feat(symbol-page): keyboard navigation for period tabs and watchlist
- ←/→ : 기간 탭 이전/다음 (양끝 clamp — wrap-around 안 함, 사용자가 끝에 갇혔는지 인지 가능). - S : 관심종목 토글 (watchlist.toggle, StarButton 자동 동기화). - 입력 필드 포커스 시 무시 — 검색창/메모 텍스트 편집 방해 금지. - modifier(Ctrl/Cmd/Alt) 가 눌리면 단축키 양보 — 브라우저/OS 단축키 보존 (ex. Cmd+→ 줄 끝 이동, Alt+← 뒤로가기 등). - ShortcutsHelp 에 새 단축키 등록 → ? 키 헬프 오버레이에서 발견 가능. - PeriodTabs 에 `periodNeighbor(id, dir)` 헬퍼 export — 페이지가 PERIODS 순서를 직접 알 필요 없도록. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -12,7 +12,7 @@ import { PeerComparePanel } from "../../components/PeerComparePanel";
|
|||||||
import { NewsList } from "../../components/NewsList";
|
import { NewsList } from "../../components/NewsList";
|
||||||
import { OrderbookPanel } from "../../components/OrderbookPanel";
|
import { OrderbookPanel } from "../../components/OrderbookPanel";
|
||||||
import { PeriodReturns } from "../../components/PeriodReturns";
|
import { PeriodReturns } from "../../components/PeriodReturns";
|
||||||
import { PeriodTabs, periodSpec, type Period } from "../../components/PeriodTabs";
|
import { PeriodTabs, periodNeighbor, periodSpec, type Period } from "../../components/PeriodTabs";
|
||||||
import { PredictionPanel } from "../../components/PredictionPanel";
|
import { PredictionPanel } from "../../components/PredictionPanel";
|
||||||
import { PriceHero } from "../../components/PriceHero";
|
import { PriceHero } from "../../components/PriceHero";
|
||||||
import { PriceTargets } from "../../components/PriceTargets";
|
import { PriceTargets } from "../../components/PriceTargets";
|
||||||
@@ -25,6 +25,7 @@ import { TradingValuePanel } from "../../components/TradingValuePanel";
|
|||||||
import { api, type ChartPayload, type LatestPredictionResponse } from "../../lib/api";
|
import { api, type ChartPayload, type LatestPredictionResponse } from "../../lib/api";
|
||||||
import { recent } from "../../lib/recent";
|
import { recent } from "../../lib/recent";
|
||||||
import { readTargets, type Targets } from "../../lib/targets";
|
import { readTargets, type Targets } from "../../lib/targets";
|
||||||
|
import { watchlist } from "../../lib/watchlist";
|
||||||
import { markRecorded, wasRecordedToday } from "../../lib/views";
|
import { markRecorded, wasRecordedToday } from "../../lib/views";
|
||||||
|
|
||||||
export default function CodePage({ params }: { params: { code: string } }) {
|
export default function CodePage({ params }: { params: { code: string } }) {
|
||||||
@@ -78,6 +79,38 @@ export default function CodePage({ params }: { params: { code: string } }) {
|
|||||||
setTargets(readTargets(code));
|
setTargets(readTargets(code));
|
||||||
}, [code]);
|
}, [code]);
|
||||||
|
|
||||||
|
// 종목 페이지 키보드 단축키:
|
||||||
|
// ←/→ : 기간 탭 이전/다음 (양끝 clamp — wrap 안 함, 사용자가 끝 인지)
|
||||||
|
// S : 관심종목 토글
|
||||||
|
// 입력 필드(검색창, 메모 등) 포커스 시 무시 — 텍스트 편집 방해 금지.
|
||||||
|
// modifier(Ctrl/Cmd/Alt) 가 눌리면 브라우저/OS 단축키 양보.
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
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.ctrlKey || e.metaKey || e.altKey) return;
|
||||||
|
if (isEditableTarget(e.target)) return;
|
||||||
|
if (e.key === "ArrowLeft") {
|
||||||
|
e.preventDefault();
|
||||||
|
setPeriod((cur) => periodNeighbor(cur, -1));
|
||||||
|
} else if (e.key === "ArrowRight") {
|
||||||
|
e.preventDefault();
|
||||||
|
setPeriod((cur) => periodNeighbor(cur, 1));
|
||||||
|
} else if (e.key === "s" || e.key === "S") {
|
||||||
|
e.preventDefault();
|
||||||
|
watchlist.toggle(code);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [code]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
setErr(null);
|
setErr(null);
|
||||||
|
|||||||
@@ -23,6 +23,15 @@ export function periodSpec(id: Period): PeriodSpec {
|
|||||||
return PERIODS.find((p) => p.id === id) ?? PERIODS[2];
|
return PERIODS.find((p) => p.id === id) ?? PERIODS[2];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ←/→ 키 네비게이션용 — 양끝 clamp (wrap-around 안 함, 사용자가 끝에 갇혔는지 인지 가능).
|
||||||
|
export function periodNeighbor(id: Period, dir: -1 | 1): Period {
|
||||||
|
const idx = PERIODS.findIndex((p) => p.id === id);
|
||||||
|
if (idx < 0) return id;
|
||||||
|
const next = idx + dir;
|
||||||
|
if (next < 0 || next >= PERIODS.length) return id;
|
||||||
|
return PERIODS[next].id;
|
||||||
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
value: Period;
|
value: Period;
|
||||||
onChange: (id: Period, interval: ChartInterval, days: number) => void;
|
onChange: (id: Period, interval: ChartInterval, days: number) => void;
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ const SHORTCUTS: Shortcut[] = [
|
|||||||
{ scope: "전역", keys: ["Esc"], label: "팝오버 / 오버레이 닫기" },
|
{ scope: "전역", keys: ["Esc"], label: "팝오버 / 오버레이 닫기" },
|
||||||
{ scope: "전역", keys: ["?"], label: "이 도움말 열기 / 닫기" },
|
{ scope: "전역", keys: ["?"], label: "이 도움말 열기 / 닫기" },
|
||||||
{ scope: "종목 페이지", keys: ["F"], label: "차트 전체화면 토글" },
|
{ scope: "종목 페이지", keys: ["F"], label: "차트 전체화면 토글" },
|
||||||
|
{ scope: "종목 페이지", keys: ["←", "→"], label: "기간 탭 이전 / 다음" },
|
||||||
|
{ scope: "종목 페이지", keys: ["S"], label: "관심종목 추가 / 제거" },
|
||||||
{ scope: "검색 팝오버", keys: ["↑", "↓"], label: "결과 이동" },
|
{ scope: "검색 팝오버", keys: ["↑", "↓"], label: "결과 이동" },
|
||||||
{ scope: "검색 팝오버", keys: ["Enter"], label: "선택한 종목으로 이동" },
|
{ scope: "검색 팝오버", keys: ["Enter"], label: "선택한 종목으로 이동" },
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user