- 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>
124 lines
3.1 KiB
TypeScript
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 };
|
|
}
|