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>
This commit is contained in:
123
apps/web/src/hooks/use-keyboard-nav.ts
Normal file
123
apps/web/src/hooks/use-keyboard-nav.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
"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 };
|
||||
}
|
||||
Reference in New Issue
Block a user