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:
@@ -90,7 +90,7 @@ describe("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 () => {
|
||||
|
||||
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