diff --git a/src/App.test.tsx b/src/App.test.tsx index 9c556942..7c2961fb 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1240,6 +1240,67 @@ describe("App", () => { await screen.findByText("Now") }) + it("switches sidebar tabs with Cmd+Up and Cmd+Down immediately after focus", async () => { + state.loadPluginSettingsMock.mockResolvedValueOnce({ order: ["a", "b"], disabled: [] }) + state.invokeMock.mockImplementation(async (cmd: string) => { + if (cmd === "list_plugins") { + return [ + { id: "a", name: "Alpha", iconUrl: "icon-a", primaryProgressLabel: null, lines: [{ type: "text", label: "Alpha line", scope: "overview" }] }, + { id: "b", name: "Beta", iconUrl: "icon-b", primaryProgressLabel: null, lines: [{ type: "text", label: "Beta line", scope: "overview" }] }, + ] + } + return null + }) + + render() + await waitFor(() => expect(state.startBatchMock).toHaveBeenCalled()) + + state.probeHandlers?.onResult({ + providerId: "a", + displayName: "Alpha", + iconUrl: "icon-a", + lines: [{ type: "text", label: "Alpha line", value: "A" }], + }) + state.probeHandlers?.onResult({ + providerId: "b", + displayName: "Beta", + iconUrl: "icon-b", + lines: [{ type: "text", label: "Beta line", value: "B" }], + }) + + await screen.findByText("Alpha line") + await screen.findByText("Beta line") + + window.dispatchEvent(new Event("focus")) + window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", metaKey: true })) + + await waitFor(() => { + expect(screen.getByText("Alpha line")).toBeInTheDocument() + expect(screen.queryByText("Beta line")).not.toBeInTheDocument() + }) + + window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", metaKey: true })) + + await waitFor(() => { + expect(screen.getByText("Beta line")).toBeInTheDocument() + expect(screen.queryByText("Alpha line")).not.toBeInTheDocument() + }) + + window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp", metaKey: true })) + + await waitFor(() => { + expect(screen.getByText("Alpha line")).toBeInTheDocument() + expect(screen.queryByText("Beta line")).not.toBeInTheDocument() + }) + + window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp", metaKey: true })) + + await waitFor(() => { + expect(screen.getByText("Alpha line")).toBeInTheDocument() + expect(screen.getByText("Beta line")).toBeInTheDocument() + }) + }) + it("coalesces pending tray icon timers on multiple settings changes", async () => { state.loadPluginSettingsMock.mockResolvedValueOnce({ order: ["a", "b"], disabled: [] }) render() diff --git a/src/components/app/app-shell.tsx b/src/components/app/app-shell.tsx index a0d5406b..8de76733 100644 --- a/src/components/app/app-shell.tsx +++ b/src/components/app/app-shell.tsx @@ -67,7 +67,11 @@ export function AppShell({ const { updateStatus, triggerInstall, checkForUpdates } = useAppUpdate() return ( -
+
{ isTauriMock.mockReturnValue(true) invokeMock.mockResolvedValue(undefined) + listenMock.mockResolvedValue(vi.fn()) currentMonitorMock.mockResolvedValue(null) getCurrentWindowMock.mockReturnValue({ setSize: vi.fn().mockResolvedValue(undefined) }) }) @@ -150,4 +151,194 @@ describe("usePanel", () => { expect(unlistenShowAbout).toHaveBeenCalledTimes(1) }) }) + + it("switches views with Cmd+Arrow navigation", () => { + const setActiveView = vi.fn() + + const firstHook = renderHook(() => + usePanel({ + activeView: "home", + setActiveView, + showAbout: false, + setShowAbout: vi.fn(), + displayPlugins: [ + { + meta: { id: "a" }, + data: null, + loading: false, + error: null, + lastManualRefreshAt: null, + } as any, + { + meta: { id: "b" }, + data: null, + loading: false, + error: null, + lastManualRefreshAt: null, + } as any, + ], + }) + ) + + act(() => { + window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", metaKey: true })) + }) + + expect(setActiveView).toHaveBeenCalledWith("a") + + firstHook.unmount() + setActiveView.mockClear() + + const secondHook = renderHook(() => + usePanel({ + activeView: "b", + setActiveView, + showAbout: false, + setShowAbout: vi.fn(), + displayPlugins: [ + { + meta: { id: "a" }, + data: null, + loading: false, + error: null, + lastManualRefreshAt: null, + } as any, + { + meta: { id: "b" }, + data: null, + loading: false, + error: null, + lastManualRefreshAt: null, + } as any, + ], + }) + ) + + act(() => { + window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", metaKey: true })) + }) + + expect(setActiveView).toHaveBeenCalledWith("home") + secondHook.unmount() + }) + + it("ignores Cmd+Arrow navigation from editable targets", () => { + const setActiveView = vi.fn() + const { result } = renderHook(() => + usePanel({ + activeView: "a", + setActiveView, + showAbout: false, + setShowAbout: vi.fn(), + displayPlugins: [ + { + meta: { id: "a" }, + data: null, + loading: false, + error: null, + lastManualRefreshAt: null, + } as any, + ], + }) + ) + + const textbox = document.createElement("div") + textbox.setAttribute("role", "textbox") + document.body.appendChild(textbox) + + act(() => { + window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", metaKey: true, bubbles: true })) + }) + + expect(setActiveView).toHaveBeenCalledWith("home") + + setActiveView.mockClear() + + act(() => { + textbox.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", metaKey: true, bubbles: true })) + }) + + expect(setActiveView).not.toHaveBeenCalled() + document.body.removeChild(textbox) + expect(result.current.containerRef.current).toBeNull() + }) + + it("skips settings when navigating with Cmd+Arrow", () => { + const setActiveView = vi.fn() + + renderHook(() => + usePanel({ + activeView: "settings", + setActiveView, + showAbout: false, + setShowAbout: vi.fn(), + displayPlugins: [ + { + meta: { id: "a" }, + data: null, + loading: false, + error: null, + lastManualRefreshAt: null, + } as any, + { + meta: { id: "b" }, + data: null, + loading: false, + error: null, + lastManualRefreshAt: null, + } as any, + ], + }) + ) + + act(() => { + window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", metaKey: true })) + }) + + expect(setActiveView).toHaveBeenCalledWith("home") + + setActiveView.mockClear() + + act(() => { + window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp", metaKey: true })) + }) + + expect(setActiveView).toHaveBeenCalledWith("b") + }) + + it("focuses the panel container when the window regains focus", () => { + const requestAnimationFrameSpy = vi + .spyOn(window, "requestAnimationFrame") + .mockImplementation((callback: FrameRequestCallback) => { + callback(0) + return 0 + }) + + const { result } = renderHook(() => + usePanel({ + activeView: "home", + setActiveView: vi.fn(), + showAbout: false, + setShowAbout: vi.fn(), + displayPlugins: [], + }) + ) + + const container = document.createElement("div") + container.tabIndex = -1 + document.body.appendChild(container) + + act(() => { + result.current.containerRef.current = container + }) + + act(() => { + window.dispatchEvent(new Event("focus")) + }) + + expect(container).toHaveFocus() + + document.body.removeChild(container) + requestAnimationFrameSpy.mockRestore() + }) }) diff --git a/src/hooks/app/use-panel.ts b/src/hooks/app/use-panel.ts index f0838738..c8d1b5d5 100644 --- a/src/hooks/app/use-panel.ts +++ b/src/hooks/app/use-panel.ts @@ -1,8 +1,9 @@ -import { useEffect, useRef, useState } from "react" +import { useCallback, useEffect, useRef, useState } from "react" import { invoke, isTauri } from "@tauri-apps/api/core" import { listen } from "@tauri-apps/api/event" import { getCurrentWindow, PhysicalSize, currentMonitor } from "@tauri-apps/api/window" import type { ActiveView } from "@/components/side-nav" +import type { DisplayPluginState } from "@/hooks/app/use-app-plugin-views" const PANEL_WIDTH = 400 const MAX_HEIGHT_FALLBACK_PX = 600 @@ -13,7 +14,18 @@ type UsePanelArgs = { setActiveView: (view: ActiveView) => void showAbout: boolean setShowAbout: (value: boolean) => void - displayPlugins: unknown[] + displayPlugins: DisplayPluginState[] +} + +function isEditableTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false + + if (target.isContentEditable) return true + if (target.closest("input, textarea, select, [contenteditable='true'], [role='textbox']")) { + return true + } + + return false } export function usePanel({ @@ -28,6 +40,27 @@ export function usePanel({ const [canScrollDown, setCanScrollDown] = useState(false) const [maxPanelHeightPx, setMaxPanelHeightPx] = useState(null) const maxPanelHeightPxRef = useRef(null) + const focusContainer = useCallback(() => { + window.requestAnimationFrame(() => { + containerRef.current?.focus({ preventScroll: true }) + }) + }, []) + + useEffect(() => { + const handleVisibilityChange = () => { + if (!document.hidden) { + focusContainer() + } + } + + window.addEventListener("focus", focusContainer) + document.addEventListener("visibilitychange", handleVisibilityChange) + + return () => { + window.removeEventListener("focus", focusContainer) + document.removeEventListener("visibilitychange", handleVisibilityChange) + } + }, [focusContainer]) useEffect(() => { if (!isTauri()) return @@ -56,6 +89,7 @@ export function usePanel({ async function setup() { const u1 = await listen("tray:navigate", (event) => { setActiveView(event.payload as ActiveView) + focusContainer() }) if (cancelled) { u1() @@ -65,6 +99,7 @@ export function usePanel({ const u2 = await listen("tray:show-about", () => { setShowAbout(true) + focusContainer() }) if (cancelled) { u2() @@ -79,7 +114,40 @@ export function usePanel({ cancelled = true for (const fn of unlisteners) fn() } - }, [setActiveView, setShowAbout]) + }, [focusContainer, setActiveView, setShowAbout]) + + useEffect(() => { + if (showAbout) return + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented) return + if (!event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return + if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return + if (isEditableTarget(event.target)) return + + const views: ActiveView[] = ["home", ...displayPlugins.map((plugin) => plugin.meta.id)] + if (views.length === 0) return + + let nextView: ActiveView | undefined + + if (activeView === "settings") { + nextView = event.key === "ArrowUp" ? views[views.length - 1] : views[0] + } else { + const currentIndex = views.indexOf(activeView) + if (currentIndex === -1) return + const offset = event.key === "ArrowUp" ? -1 : 1 + nextView = views[(currentIndex + offset + views.length) % views.length] + } + + if (!nextView || nextView === activeView) return + + event.preventDefault() + setActiveView(nextView) + } + + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [activeView, displayPlugins, setActiveView, showAbout]) useEffect(() => { if (!isTauri()) return