diff --git a/src/components/Navigation.tsx b/src/components/Navigation.tsx new file mode 100644 index 0000000..4777c0c --- /dev/null +++ b/src/components/Navigation.tsx @@ -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 ( + + ); +} diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx new file mode 100644 index 0000000..fec03f9 --- /dev/null +++ b/src/components/SettingsView.tsx @@ -0,0 +1,19 @@ +/** + * SettingsView - Application settings and preferences. + * + * Placeholder for Phase 5 implementation. + */ + +export function SettingsView(): React.ReactElement { + return ( +
+
+

Settings

+

Coming in Phase 5

+
+
+ ); +} diff --git a/src/hooks/useKeyboardShortcuts.ts b/src/hooks/useKeyboardShortcuts.ts new file mode 100644 index 0000000..98449bf --- /dev/null +++ b/src/hooks/useKeyboardShortcuts.ts @@ -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+" where mod = Cmd (Mac) or Ctrl (other) + * Examples: "mod+1", "mod+,", "mod+k" + */ +export type ShortcutMap = Record 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]); +} diff --git a/tests/components/AppShell.test.tsx b/tests/components/AppShell.test.tsx index b2eae94..199073c 100644 --- a/tests/components/AppShell.test.tsx +++ b/tests/components/AppShell.test.tsx @@ -90,7 +90,7 @@ describe("AppShell", () => { }); renderWithProviders(); - expect(screen.getByText("3")).toBeInTheDocument(); + expect(screen.getByTestId("queue-badge")).toHaveTextContent("3"); }); it("opens quick capture overlay on global shortcut event", async () => { diff --git a/tests/components/Navigation.test.tsx b/tests/components/Navigation.test.tsx new file mode 100644 index 0000000..27d0e07 --- /dev/null +++ b/tests/components/Navigation.test.tsx @@ -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(); + + 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(); + + 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(); + + const badge = screen.getByTestId("queue-badge"); + expect(badge).toHaveTextContent("3"); + }); + + it("does not show queue badge when no items", () => { + render(); + + expect(screen.queryByTestId("queue-badge")).not.toBeInTheDocument(); + }); + + it("navigates to queue view on click", async () => { + const user = userEvent.setup(); + render(); + + 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(); + + 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(); + + 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(); + + act(() => { + dispatchKeyEvent("1", { metaKey: true }); + }); + + expect(useNavStore.getState().activeView).toBe("focus"); + }); + + it("navigates to queue view on Cmd+2", () => { + render(); + + act(() => { + dispatchKeyEvent("2", { metaKey: true }); + }); + + expect(useNavStore.getState().activeView).toBe("queue"); + }); + + it("navigates to inbox view on Cmd+3", () => { + render(); + + act(() => { + dispatchKeyEvent("3", { metaKey: true }); + }); + + expect(useNavStore.getState().activeView).toBe("inbox"); + }); + + it("navigates to settings view on Cmd+,", () => { + render(); + + act(() => { + dispatchKeyEvent(",", { metaKey: true }); + }); + + expect(useNavStore.getState().activeView).toBe("settings"); + }); + + it("displays keyboard shortcut hints", () => { + render(); + + // Check for shortcut hints in the nav items + expect(screen.getByText("1")).toBeInTheDocument(); + expect(screen.getByText("2")).toBeInTheDocument(); + expect(screen.getByText("3")).toBeInTheDocument(); + }); +}); diff --git a/tests/hooks/useKeyboardShortcuts.test.ts b/tests/hooks/useKeyboardShortcuts.test.ts new file mode 100644 index 0000000..9ffcb63 --- /dev/null +++ b/tests/hooks/useKeyboardShortcuts.test.ts @@ -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(); + }); +});