Files
agentlens/apps/web/src/hooks/use-keyboard-nav.ts
Vectry 64c827ee84 feat: add command palette, accessibility, scroll animations, demo workspace, and keyboard navigation
- COMP-139: Command palette for quick navigation
- COMP-140: Accessibility improvements
- COMP-141: Scroll animations with animate-on-scroll component
- COMP-143: Demo workspace with seed data and demo banner
- COMP-145: Keyboard navigation and shortcuts help

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-Claude)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-10 18:06:36 +00:00

124 lines
3.1 KiB
TypeScript

"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useRouter } from "next/navigation";
function isInputFocused(): boolean {
const el = document.activeElement;
if (!el) return false;
const tag = el.tagName.toLowerCase();
if (tag === "input" || tag === "textarea" || tag === "select") return true;
if ((el as HTMLElement).isContentEditable) return true;
return false;
}
interface UseKeyboardNavOptions {
itemCount: number;
onSelect: (index: number) => void;
enabled?: boolean;
}
export function useKeyboardNav({
itemCount,
onSelect,
enabled = true,
}: UseKeyboardNavOptions) {
const [selectedIndex, setSelectedIndex] = useState(-1);
const router = useRouter();
const gPressedRef = useRef(false);
const gTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const resetSelection = useCallback(() => {
setSelectedIndex(-1);
}, []);
useEffect(() => {
if (!enabled) return;
function handleKeyDown(e: KeyboardEvent) {
if (isInputFocused()) return;
if (gPressedRef.current) {
gPressedRef.current = false;
clearTimeout(gTimerRef.current);
if (e.key === "h") {
e.preventDefault();
router.push("/dashboard");
return;
}
if (e.key === "s") {
e.preventDefault();
router.push("/dashboard/settings");
return;
}
if (e.key === "k") {
e.preventDefault();
router.push("/dashboard/keys");
return;
}
if (e.key === "d") {
e.preventDefault();
router.push("/dashboard/decisions");
return;
}
return;
}
if (e.key === "g" && !e.metaKey && !e.ctrlKey && !e.altKey) {
gPressedRef.current = true;
gTimerRef.current = setTimeout(() => {
gPressedRef.current = false;
}, 500);
return;
}
if (e.key === "j" && !e.metaKey && !e.ctrlKey) {
e.preventDefault();
setSelectedIndex((prev) => {
const next = prev + 1;
return next >= itemCount ? itemCount - 1 : next;
});
return;
}
if (e.key === "k" && !e.metaKey && !e.ctrlKey) {
e.preventDefault();
setSelectedIndex((prev) => {
const next = prev - 1;
return next < 0 ? 0 : next;
});
return;
}
if (e.key === "Enter" && selectedIndex >= 0) {
e.preventDefault();
onSelect(selectedIndex);
return;
}
if (e.key === "Escape") {
setSelectedIndex(-1);
return;
}
}
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
clearTimeout(gTimerRef.current);
};
}, [enabled, itemCount, selectedIndex, onSelect, router]);
useEffect(() => {
if (selectedIndex < 0) return;
const row = document.querySelector(`[data-keyboard-index="${selectedIndex}"]`);
if (row) {
row.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
}, [selectedIndex]);
return { selectedIndex, setSelectedIndex, resetSelection };
}