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>
204 lines
5.4 KiB
TypeScript
204 lines
5.4 KiB
TypeScript
/**
|
|
* 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();
|
|
});
|
|
});
|