feat(bd-grs): implement app navigation with keyboard shortcuts
Add navigation with keyboard shortcuts (Cmd+1/2/3/4/,) for Focus, Queue, Inbox, Debug, and Settings views. Components: - useKeyboardShortcuts hook: handles global shortcuts with editable element detection - Navigation component: standalone nav bar (not used, but available) - SettingsView placeholder: Phase 5 stub - AppShell: integrated keyboard shortcuts and Settings button Tests: - useKeyboardShortcuts: 11 tests covering shortcuts, modifiers, editable detection - Navigation: 12 tests covering nav items, badges, click, keyboard shortcuts Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
126
src/components/Navigation.tsx
Normal file
126
src/components/Navigation.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* Navigation - Top navigation bar with keyboard shortcuts
|
||||||
|
*
|
||||||
|
* Provides navigation between Focus, Queue, Inbox, Settings, and Debug views.
|
||||||
|
* Supports keyboard shortcuts (Cmd+1/2/3/,) for quick navigation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useNavStore, type ViewId } from "@/stores/nav-store";
|
||||||
|
import { useFocusStore } from "@/stores/focus-store";
|
||||||
|
import { useInboxStore } from "@/stores/inbox-store";
|
||||||
|
import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts";
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
id: ViewId;
|
||||||
|
label: string;
|
||||||
|
shortcutKey: string;
|
||||||
|
badgeType?: "queue" | "inbox";
|
||||||
|
}
|
||||||
|
|
||||||
|
const NAV_ITEMS: NavItem[] = [
|
||||||
|
{ id: "focus", label: "Focus", shortcutKey: "1" },
|
||||||
|
{ id: "queue", label: "Queue", shortcutKey: "2", badgeType: "queue" },
|
||||||
|
{ id: "inbox", label: "Inbox", shortcutKey: "3", badgeType: "inbox" },
|
||||||
|
{ id: "debug", label: "Debug", shortcutKey: "4" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Navigation(): React.ReactElement {
|
||||||
|
const activeView = useNavStore((s) => s.activeView);
|
||||||
|
const setView = useNavStore((s) => s.setView);
|
||||||
|
const current = useFocusStore((s) => s.current);
|
||||||
|
const queue = useFocusStore((s) => s.queue);
|
||||||
|
const inboxItems = useInboxStore((s) => s.items);
|
||||||
|
|
||||||
|
// Badge counts
|
||||||
|
const queueCount = (current ? 1 : 0) + queue.length;
|
||||||
|
const inboxCount = inboxItems.filter((i) => !i.triaged).length;
|
||||||
|
|
||||||
|
// Register keyboard shortcuts
|
||||||
|
useKeyboardShortcuts({
|
||||||
|
"mod+1": () => setView("focus"),
|
||||||
|
"mod+2": () => setView("queue"),
|
||||||
|
"mod+3": () => setView("inbox"),
|
||||||
|
"mod+4": () => setView("debug"),
|
||||||
|
"mod+,": () => setView("settings"),
|
||||||
|
});
|
||||||
|
|
||||||
|
function getBadgeCount(badgeType?: "queue" | "inbox"): number {
|
||||||
|
if (badgeType === "queue") return queueCount;
|
||||||
|
if (badgeType === "inbox") return inboxCount;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBadgeClasses(badgeType?: "queue" | "inbox"): string {
|
||||||
|
if (badgeType === "inbox") {
|
||||||
|
return "rounded-full bg-amber-600/30 px-1.5 py-0.5 text-[10px] text-amber-400";
|
||||||
|
}
|
||||||
|
return "rounded-full bg-zinc-700 px-1.5 py-0.5 text-[10px] text-zinc-400";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="flex items-center gap-1 border-b border-zinc-800 px-4 py-2">
|
||||||
|
{NAV_ITEMS.map((item) => {
|
||||||
|
const isActive = activeView === item.id;
|
||||||
|
const badgeCount = getBadgeCount(item.badgeType);
|
||||||
|
const showBadge = item.badgeType && badgeCount > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
role="button"
|
||||||
|
data-active={isActive}
|
||||||
|
onClick={() => setView(item.id)}
|
||||||
|
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||||
|
isActive
|
||||||
|
? "bg-zinc-800 text-zinc-100"
|
||||||
|
: "text-zinc-500 hover:text-zinc-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
<kbd className="text-[10px] text-zinc-600">{item.shortcutKey}</kbd>
|
||||||
|
{showBadge && (
|
||||||
|
<span
|
||||||
|
data-testid={item.badgeType === "queue" ? "queue-badge" : "inbox-badge"}
|
||||||
|
className={getBadgeClasses(item.badgeType)}
|
||||||
|
>
|
||||||
|
{badgeCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="button"
|
||||||
|
aria-label="Settings"
|
||||||
|
data-active={activeView === "settings"}
|
||||||
|
onClick={() => setView("settings")}
|
||||||
|
className={`rounded-md p-1.5 transition-colors ${
|
||||||
|
activeView === "settings"
|
||||||
|
? "bg-zinc-800 text-zinc-100"
|
||||||
|
: "text-zinc-500 hover:text-zinc-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
src/components/SettingsView.tsx
Normal file
19
src/components/SettingsView.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* SettingsView - Application settings and preferences.
|
||||||
|
*
|
||||||
|
* Placeholder for Phase 5 implementation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function SettingsView(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex min-h-[calc(100vh-3rem)] items-center justify-center"
|
||||||
|
data-testid="settings-view"
|
||||||
|
>
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-lg font-medium text-zinc-300">Settings</h2>
|
||||||
|
<p className="mt-2 text-sm text-zinc-500">Coming in Phase 5</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
125
src/hooks/useKeyboardShortcuts.ts
Normal file
125
src/hooks/useKeyboardShortcuts.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* useKeyboardShortcuts - Global keyboard shortcut handler
|
||||||
|
*
|
||||||
|
* Provides a declarative way to register keyboard shortcuts that work
|
||||||
|
* across the entire app. Supports mod+key format where "mod" maps to
|
||||||
|
* Cmd on Mac and Ctrl on other platforms.
|
||||||
|
*
|
||||||
|
* Ignores shortcuts when typing in input fields, textareas, or contenteditable.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useCallback } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map of shortcut patterns to handlers.
|
||||||
|
*
|
||||||
|
* Pattern format: "mod+<key>" where mod = Cmd (Mac) or Ctrl (other)
|
||||||
|
* Examples: "mod+1", "mod+,", "mod+k"
|
||||||
|
*/
|
||||||
|
export type ShortcutMap = Record<string, () => void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an element is editable (input, textarea, contenteditable)
|
||||||
|
*/
|
||||||
|
function isEditableElement(element: Element | null): boolean {
|
||||||
|
if (!element) return false;
|
||||||
|
|
||||||
|
const tagName = element.tagName.toLowerCase();
|
||||||
|
if (tagName === "input" || tagName === "textarea") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element instanceof HTMLElement) {
|
||||||
|
// Check multiple ways (browser vs JSDOM compatibility):
|
||||||
|
// - isContentEditable (browser property)
|
||||||
|
// - contentEditable property (works in JSDOM when set via property)
|
||||||
|
// - getAttribute (works when set via setAttribute)
|
||||||
|
if (
|
||||||
|
element.isContentEditable ||
|
||||||
|
element.contentEditable === "true" ||
|
||||||
|
element.getAttribute("contenteditable") === "true"
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the event originated from an editable element
|
||||||
|
*/
|
||||||
|
function isFromEditableElement(event: KeyboardEvent): boolean {
|
||||||
|
// Check event target first (where the event originated)
|
||||||
|
if (event.target instanceof Element && isEditableElement(event.target)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check activeElement as fallback
|
||||||
|
return isEditableElement(document.activeElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a shortcut pattern and check if it matches the keyboard event.
|
||||||
|
*/
|
||||||
|
function matchesShortcut(pattern: string, event: KeyboardEvent): boolean {
|
||||||
|
const parts = pattern.toLowerCase().split("+");
|
||||||
|
const key = parts.pop();
|
||||||
|
const modifiers = new Set(parts);
|
||||||
|
|
||||||
|
// Check if key matches
|
||||||
|
if (event.key.toLowerCase() !== key) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "mod" means Cmd on Mac, Ctrl elsewhere
|
||||||
|
const hasModModifier = modifiers.has("mod");
|
||||||
|
if (hasModModifier) {
|
||||||
|
// Accept either metaKey (Cmd) or ctrlKey (Ctrl)
|
||||||
|
if (!event.metaKey && !event.ctrlKey) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to register global keyboard shortcuts.
|
||||||
|
*
|
||||||
|
* @param shortcuts - Map of shortcut patterns to handler functions
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* useKeyboardShortcuts({
|
||||||
|
* "mod+1": () => setView("focus"),
|
||||||
|
* "mod+2": () => setView("queue"),
|
||||||
|
* "mod+,": () => setView("settings"),
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
export function useKeyboardShortcuts(shortcuts: ShortcutMap): void {
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(event: KeyboardEvent) => {
|
||||||
|
// Don't handle shortcuts when typing in an editable element
|
||||||
|
if (isFromEditableElement(event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [pattern, handler] of Object.entries(shortcuts)) {
|
||||||
|
if (matchesShortcut(pattern, event)) {
|
||||||
|
event.preventDefault();
|
||||||
|
handler();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[shortcuts]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [handleKeyDown]);
|
||||||
|
}
|
||||||
@@ -90,7 +90,7 @@ describe("AppShell", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
renderWithProviders(<AppShell />);
|
renderWithProviders(<AppShell />);
|
||||||
expect(screen.getByText("3")).toBeInTheDocument();
|
expect(screen.getByTestId("queue-badge")).toHaveTextContent("3");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens quick capture overlay on global shortcut event", async () => {
|
it("opens quick capture overlay on global shortcut event", async () => {
|
||||||
|
|||||||
159
tests/components/Navigation.test.tsx
Normal file
159
tests/components/Navigation.test.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
/**
|
||||||
|
* Tests for Navigation component
|
||||||
|
*
|
||||||
|
* Tests navigation UI elements, active state, badge counts, and keyboard shortcuts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { render, screen, act } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { Navigation } from "@/components/Navigation";
|
||||||
|
import { useNavStore } from "@/stores/nav-store";
|
||||||
|
import { useFocusStore } from "@/stores/focus-store";
|
||||||
|
import { useInboxStore } from "@/stores/inbox-store";
|
||||||
|
import { makeFocusItem } from "../helpers/fixtures";
|
||||||
|
|
||||||
|
describe("Navigation", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useNavStore.setState({ activeView: "focus" });
|
||||||
|
useFocusStore.setState({
|
||||||
|
current: null,
|
||||||
|
queue: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
useInboxStore.setState({
|
||||||
|
items: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nav items for all views", () => {
|
||||||
|
render(<Navigation />);
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: /focus/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: /queue/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: /inbox/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("highlights the active view", () => {
|
||||||
|
useNavStore.setState({ activeView: "queue" });
|
||||||
|
|
||||||
|
render(<Navigation />);
|
||||||
|
|
||||||
|
const queueButton = screen.getByRole("button", { name: /queue/i });
|
||||||
|
expect(queueButton).toHaveAttribute("data-active", "true");
|
||||||
|
|
||||||
|
const focusButton = screen.getByRole("button", { name: /focus/i });
|
||||||
|
expect(focusButton).toHaveAttribute("data-active", "false");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows queue badge count when items exist", () => {
|
||||||
|
useFocusStore.setState({
|
||||||
|
current: makeFocusItem({ id: "a" }),
|
||||||
|
queue: [makeFocusItem({ id: "b" }), makeFocusItem({ id: "c" })],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<Navigation />);
|
||||||
|
|
||||||
|
const badge = screen.getByTestId("queue-badge");
|
||||||
|
expect(badge).toHaveTextContent("3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show queue badge when no items", () => {
|
||||||
|
render(<Navigation />);
|
||||||
|
|
||||||
|
expect(screen.queryByTestId("queue-badge")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("navigates to queue view on click", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<Navigation />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: /queue/i }));
|
||||||
|
|
||||||
|
expect(useNavStore.getState().activeView).toBe("queue");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("navigates to inbox view on click", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<Navigation />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: /inbox/i }));
|
||||||
|
|
||||||
|
expect(useNavStore.getState().activeView).toBe("inbox");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("navigates to settings view on settings button click", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<Navigation />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: /settings/i }));
|
||||||
|
|
||||||
|
expect(useNavStore.getState().activeView).toBe("settings");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keyboard shortcut tests
|
||||||
|
function dispatchKeyEvent(
|
||||||
|
key: string,
|
||||||
|
opts: { metaKey?: boolean } = {}
|
||||||
|
): void {
|
||||||
|
const event = new KeyboardEvent("keydown", {
|
||||||
|
key,
|
||||||
|
metaKey: opts.metaKey ?? false,
|
||||||
|
bubbles: true,
|
||||||
|
});
|
||||||
|
document.dispatchEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("navigates to focus view on Cmd+1", () => {
|
||||||
|
useNavStore.setState({ activeView: "queue" });
|
||||||
|
render(<Navigation />);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
dispatchKeyEvent("1", { metaKey: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(useNavStore.getState().activeView).toBe("focus");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("navigates to queue view on Cmd+2", () => {
|
||||||
|
render(<Navigation />);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
dispatchKeyEvent("2", { metaKey: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(useNavStore.getState().activeView).toBe("queue");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("navigates to inbox view on Cmd+3", () => {
|
||||||
|
render(<Navigation />);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
dispatchKeyEvent("3", { metaKey: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(useNavStore.getState().activeView).toBe("inbox");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("navigates to settings view on Cmd+,", () => {
|
||||||
|
render(<Navigation />);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
dispatchKeyEvent(",", { metaKey: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(useNavStore.getState().activeView).toBe("settings");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays keyboard shortcut hints", () => {
|
||||||
|
render(<Navigation />);
|
||||||
|
|
||||||
|
// Check for shortcut hints in the nav items
|
||||||
|
expect(screen.getByText("1")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("2")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("3")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
203
tests/hooks/useKeyboardShortcuts.test.ts
Normal file
203
tests/hooks/useKeyboardShortcuts.test.ts
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* Tests for useKeyboardShortcuts hook
|
||||||
|
*
|
||||||
|
* Verifies keyboard shortcut handling for navigation and actions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { renderHook } from "@testing-library/react";
|
||||||
|
import { useKeyboardShortcuts, type ShortcutMap } from "@/hooks/useKeyboardShortcuts";
|
||||||
|
|
||||||
|
describe("useKeyboardShortcuts", () => {
|
||||||
|
// Helper to dispatch keyboard events
|
||||||
|
function dispatchKeyEvent(
|
||||||
|
key: string,
|
||||||
|
opts: { metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean } = {}
|
||||||
|
): void {
|
||||||
|
const event = new KeyboardEvent("keydown", {
|
||||||
|
key,
|
||||||
|
metaKey: opts.metaKey ?? false,
|
||||||
|
ctrlKey: opts.ctrlKey ?? false,
|
||||||
|
shiftKey: opts.shiftKey ?? false,
|
||||||
|
bubbles: true,
|
||||||
|
});
|
||||||
|
document.dispatchEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("calls handler when shortcut is pressed (meta key on Mac)", () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
const shortcuts: ShortcutMap = { "mod+1": handler };
|
||||||
|
|
||||||
|
renderHook(() => useKeyboardShortcuts(shortcuts));
|
||||||
|
|
||||||
|
dispatchKeyEvent("1", { metaKey: true });
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls handler when shortcut is pressed (ctrl key fallback)", () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
const shortcuts: ShortcutMap = { "mod+2": handler };
|
||||||
|
|
||||||
|
renderHook(() => useKeyboardShortcuts(shortcuts));
|
||||||
|
|
||||||
|
dispatchKeyEvent("2", { ctrlKey: true });
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call handler when wrong key is pressed", () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
const shortcuts: ShortcutMap = { "mod+1": handler };
|
||||||
|
|
||||||
|
renderHook(() => useKeyboardShortcuts(shortcuts));
|
||||||
|
|
||||||
|
dispatchKeyEvent("2", { metaKey: true });
|
||||||
|
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call handler when modifier is missing", () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
const shortcuts: ShortcutMap = { "mod+1": handler };
|
||||||
|
|
||||||
|
renderHook(() => useKeyboardShortcuts(shortcuts));
|
||||||
|
|
||||||
|
dispatchKeyEvent("1"); // No modifier
|
||||||
|
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles comma shortcut (mod+,)", () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
const shortcuts: ShortcutMap = { "mod+,": handler };
|
||||||
|
|
||||||
|
renderHook(() => useKeyboardShortcuts(shortcuts));
|
||||||
|
|
||||||
|
dispatchKeyEvent(",", { metaKey: true });
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports multiple shortcuts", () => {
|
||||||
|
const handler1 = vi.fn();
|
||||||
|
const handler2 = vi.fn();
|
||||||
|
const handler3 = vi.fn();
|
||||||
|
|
||||||
|
const shortcuts: ShortcutMap = {
|
||||||
|
"mod+1": handler1,
|
||||||
|
"mod+2": handler2,
|
||||||
|
"mod+3": handler3,
|
||||||
|
};
|
||||||
|
|
||||||
|
renderHook(() => useKeyboardShortcuts(shortcuts));
|
||||||
|
|
||||||
|
dispatchKeyEvent("1", { metaKey: true });
|
||||||
|
dispatchKeyEvent("3", { metaKey: true });
|
||||||
|
|
||||||
|
expect(handler1).toHaveBeenCalledTimes(1);
|
||||||
|
expect(handler2).not.toHaveBeenCalled();
|
||||||
|
expect(handler3).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes listeners on unmount", () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
const shortcuts: ShortcutMap = { "mod+1": handler };
|
||||||
|
|
||||||
|
const { unmount } = renderHook(() => useKeyboardShortcuts(shortcuts));
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
dispatchKeyEvent("1", { metaKey: true });
|
||||||
|
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores shortcuts when typing in input fields", () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
const shortcuts: ShortcutMap = { "mod+1": handler };
|
||||||
|
|
||||||
|
renderHook(() => useKeyboardShortcuts(shortcuts));
|
||||||
|
|
||||||
|
// Create and focus an input element
|
||||||
|
const input = document.createElement("input");
|
||||||
|
document.body.appendChild(input);
|
||||||
|
input.focus();
|
||||||
|
|
||||||
|
// Dispatch from the input
|
||||||
|
const event = new KeyboardEvent("keydown", {
|
||||||
|
key: "1",
|
||||||
|
metaKey: true,
|
||||||
|
bubbles: true,
|
||||||
|
});
|
||||||
|
input.dispatchEvent(event);
|
||||||
|
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
document.body.removeChild(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores shortcuts when typing in textarea", () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
const shortcuts: ShortcutMap = { "mod+2": handler };
|
||||||
|
|
||||||
|
renderHook(() => useKeyboardShortcuts(shortcuts));
|
||||||
|
|
||||||
|
const textarea = document.createElement("textarea");
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.focus();
|
||||||
|
|
||||||
|
const event = new KeyboardEvent("keydown", {
|
||||||
|
key: "2",
|
||||||
|
metaKey: true,
|
||||||
|
bubbles: true,
|
||||||
|
});
|
||||||
|
textarea.dispatchEvent(event);
|
||||||
|
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores shortcuts when contenteditable is focused", () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
const shortcuts: ShortcutMap = { "mod+3": handler };
|
||||||
|
|
||||||
|
renderHook(() => useKeyboardShortcuts(shortcuts));
|
||||||
|
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.contentEditable = "true";
|
||||||
|
document.body.appendChild(div);
|
||||||
|
div.focus();
|
||||||
|
|
||||||
|
const event = new KeyboardEvent("keydown", {
|
||||||
|
key: "3",
|
||||||
|
metaKey: true,
|
||||||
|
bubbles: true,
|
||||||
|
});
|
||||||
|
div.dispatchEvent(event);
|
||||||
|
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
document.body.removeChild(div);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prevents default behavior when shortcut matches", () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
const shortcuts: ShortcutMap = { "mod+1": handler };
|
||||||
|
|
||||||
|
renderHook(() => useKeyboardShortcuts(shortcuts));
|
||||||
|
|
||||||
|
const event = new KeyboardEvent("keydown", {
|
||||||
|
key: "1",
|
||||||
|
metaKey: true,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const preventDefaultSpy = vi.spyOn(event, "preventDefault");
|
||||||
|
document.dispatchEvent(event);
|
||||||
|
|
||||||
|
expect(preventDefaultSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user