From bf0a85162a059cd5d5cde30350c7173350dc64b9 Mon Sep 17 00:00:00 2001 From: ayinde38 Date: Fri, 16 Oct 2026 12:32:56 +0100 Subject: [PATCH 1/2] feat(dashboard): g + key shortcuts to switch dashboard tabs --- dashboard/app/dashboard/layout.tsx | 33 ++++++++++++++++++++++--- dashboard/components/CommandPalette.tsx | 21 ++++++++++------ 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/dashboard/app/dashboard/layout.tsx b/dashboard/app/dashboard/layout.tsx index b5eafc3..a25107c 100644 --- a/dashboard/app/dashboard/layout.tsx +++ b/dashboard/app/dashboard/layout.tsx @@ -1,19 +1,31 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { usePrivy } from "@privy-io/react-auth"; import Sidebar from "@/components/Sidebar"; import SearchBar from "@/components/SearchBar"; import ThemeSelector from "@/components/ThemeSelector"; import NotificationBell from "@/components/NotificationBell"; -import CommandPalette from "@/components/CommandPalette"; +import CommandPalette, { NAV_ITEMS } from "@/components/CommandPalette"; import MobileNavDrawer from "@/components/MobileNavDrawer"; import { NotificationsProvider } from "@/lib/notifications-context"; import HealthStatusIndicator from "@/components/HealthStatusIndicator"; +/** Typing in a form field must never trigger navigation shortcuts. */ +function isTypingTarget(target: EventTarget | null) { + if (!(target instanceof HTMLElement)) return false; + return ( + target.isContentEditable || + ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName) + ); +} + function DashboardShell({ children }: { children: React.ReactNode }) { + const router = useRouter(); const [paletteOpen, setPaletteOpen] = useState(false); + // #1041 — timestamp of the last bare "g" press, for `g` + key tab shortcuts + const pendingGRef = useRef(0); // #808 — mobile drawer, only reachable below the md breakpoint const [navOpen, setNavOpen] = useState(false); @@ -22,8 +34,23 @@ function DashboardShell({ children }: { children: React.ReactNode }) { if ((e.metaKey || e.ctrlKey) && e.key === "k") { e.preventDefault(); setPaletteOpen((prev) => !prev); + return; } - }, []); + + // #1041 — `g` then a tab key (see NAV_ITEMS) jumps between dashboard tabs + if (paletteOpen || e.metaKey || e.ctrlKey || e.altKey || isTypingTarget(e.target)) return; + const key = e.key.toLowerCase(); + if (Date.now() - pendingGRef.current < 1000) { + pendingGRef.current = 0; + const item = NAV_ITEMS.find((nav) => nav.shortcut === key); + if (item) { + e.preventDefault(); + router.push(item.href); + } + return; + } + if (key === "g" && !e.shiftKey) pendingGRef.current = Date.now(); + }, [paletteOpen, router]); useEffect(() => { document.addEventListener("keydown", handleKeyDown); diff --git a/dashboard/components/CommandPalette.tsx b/dashboard/components/CommandPalette.tsx index d320ce2..b3904b6 100644 --- a/dashboard/components/CommandPalette.tsx +++ b/dashboard/components/CommandPalette.tsx @@ -9,16 +9,18 @@ interface NavItem { href: string; icon: string; keywords?: string; + /** Second key of the `g` + key tab shortcut (#1041), e.g. "t" for `g t`. */ + shortcut: string; } -const NAV_ITEMS: NavItem[] = [ - { label: "Overview", href: "/dashboard", icon: "⬛", keywords: "home summary stats" }, - { label: "Transactions", href: "/dashboard/transactions", icon: "📋", keywords: "payments social feed" }, - { label: "Payouts", href: "/dashboard/payouts", icon: "💸", keywords: "batch disburse withdraw" }, - { label: "QR Codes", href: "/dashboard/qr", icon: "⬜", keywords: "scan generate merchant" }, - { label: "Analytics", href: "/dashboard/analytics", icon: "📈", keywords: "charts reports graph" }, - { label: "Contracts", href: "/dashboard/contracts", icon: "🔗", keywords: "soroban smart contract health" }, - { label: "Yield Vault", href: "/dashboard/yield", icon: "🏦", keywords: "apy tvl deposit" }, +export const NAV_ITEMS: NavItem[] = [ + { label: "Overview", href: "/dashboard", icon: "⬛", keywords: "home summary stats", shortcut: "o" }, + { label: "Transactions", href: "/dashboard/transactions", icon: "📋", keywords: "payments social feed", shortcut: "t" }, + { label: "Payouts", href: "/dashboard/payouts", icon: "💸", keywords: "batch disburse withdraw", shortcut: "p" }, + { label: "QR Codes", href: "/dashboard/qr", icon: "⬜", keywords: "scan generate merchant", shortcut: "q" }, + { label: "Analytics", href: "/dashboard/analytics", icon: "📈", keywords: "charts reports graph", shortcut: "a" }, + { label: "Contracts", href: "/dashboard/contracts", icon: "🔗", keywords: "soroban smart contract health", shortcut: "c" }, + { label: "Yield Vault", href: "/dashboard/yield", icon: "🏦", keywords: "apy tvl deposit", shortcut: "y" }, ]; interface CommandPaletteProps { @@ -112,6 +114,9 @@ export default function CommandPalette({ open, onClose }: CommandPaletteProps) { > {item.label} + + g {item.shortcut} + ))} From 1dbae7f7024046031b16cfdf822ac633cbbcf58c Mon Sep 17 00:00:00 2001 From: ayinde38 Date: Fri, 16 Oct 2026 12:41:47 +0100 Subject: [PATCH 2/2] refactor: move tab shortcuts into useTabShortcuts hook with tests --- dashboard/app/dashboard/layout.tsx | 33 ++-------- .../lib/__tests__/use-tab-shortcuts.test.tsx | 63 +++++++++++++++++++ dashboard/lib/use-tab-shortcuts.ts | 50 +++++++++++++++ 3 files changed, 119 insertions(+), 27 deletions(-) create mode 100644 dashboard/lib/__tests__/use-tab-shortcuts.test.tsx create mode 100644 dashboard/lib/use-tab-shortcuts.ts diff --git a/dashboard/app/dashboard/layout.tsx b/dashboard/app/dashboard/layout.tsx index a25107c..ab1c039 100644 --- a/dashboard/app/dashboard/layout.tsx +++ b/dashboard/app/dashboard/layout.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { usePrivy } from "@privy-io/react-auth"; import Sidebar from "@/components/Sidebar"; @@ -11,21 +11,11 @@ import CommandPalette, { NAV_ITEMS } from "@/components/CommandPalette"; import MobileNavDrawer from "@/components/MobileNavDrawer"; import { NotificationsProvider } from "@/lib/notifications-context"; import HealthStatusIndicator from "@/components/HealthStatusIndicator"; - -/** Typing in a form field must never trigger navigation shortcuts. */ -function isTypingTarget(target: EventTarget | null) { - if (!(target instanceof HTMLElement)) return false; - return ( - target.isContentEditable || - ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName) - ); -} +import { useTabShortcuts } from "@/lib/use-tab-shortcuts"; function DashboardShell({ children }: { children: React.ReactNode }) { const router = useRouter(); const [paletteOpen, setPaletteOpen] = useState(false); - // #1041 — timestamp of the last bare "g" press, for `g` + key tab shortcuts - const pendingGRef = useRef(0); // #808 — mobile drawer, only reachable below the md breakpoint const [navOpen, setNavOpen] = useState(false); @@ -34,23 +24,12 @@ function DashboardShell({ children }: { children: React.ReactNode }) { if ((e.metaKey || e.ctrlKey) && e.key === "k") { e.preventDefault(); setPaletteOpen((prev) => !prev); - return; } + }, []); - // #1041 — `g` then a tab key (see NAV_ITEMS) jumps between dashboard tabs - if (paletteOpen || e.metaKey || e.ctrlKey || e.altKey || isTypingTarget(e.target)) return; - const key = e.key.toLowerCase(); - if (Date.now() - pendingGRef.current < 1000) { - pendingGRef.current = 0; - const item = NAV_ITEMS.find((nav) => nav.shortcut === key); - if (item) { - e.preventDefault(); - router.push(item.href); - } - return; - } - if (key === "g" && !e.shiftKey) pendingGRef.current = Date.now(); - }, [paletteOpen, router]); + // #1041 — `g` then a tab key (see NAV_ITEMS) jumps between dashboard tabs + const navigate = useCallback((href: string) => router.push(href), [router]); + useTabShortcuts(NAV_ITEMS, navigate, paletteOpen); useEffect(() => { document.addEventListener("keydown", handleKeyDown); diff --git a/dashboard/lib/__tests__/use-tab-shortcuts.test.tsx b/dashboard/lib/__tests__/use-tab-shortcuts.test.tsx new file mode 100644 index 0000000..60df000 --- /dev/null +++ b/dashboard/lib/__tests__/use-tab-shortcuts.test.tsx @@ -0,0 +1,63 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { NAV_ITEMS } from "@/components/CommandPalette"; +import { TAB_SHORTCUT_WINDOW_MS, useTabShortcuts } from "@/lib/use-tab-shortcuts"; + +function press(key: string, init: KeyboardEventInit = {}, target: EventTarget = document.body) { + target.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, ...init })); +} + +describe("useTabShortcuts (#1041)", () => { + const onNavigate = vi.fn(); + + beforeEach(() => { + vi.useFakeTimers(); + onNavigate.mockClear(); + }); + + afterEach(() => { + vi.useRealTimers(); + document.body.innerHTML = ""; + }); + + it.each(NAV_ITEMS.map((item) => [item.shortcut, item.href]))( + "g %s navigates to %s", + (shortcut, href) => { + renderHook(() => useTabShortcuts(NAV_ITEMS, onNavigate)); + press("g"); + press(shortcut); + expect(onNavigate).toHaveBeenCalledWith(href); + }, + ); + + it("ignores the second key once the window has passed", () => { + renderHook(() => useTabShortcuts(NAV_ITEMS, onNavigate)); + press("g"); + vi.advanceTimersByTime(TAB_SHORTCUT_WINDOW_MS + 1); + press("t"); + expect(onNavigate).not.toHaveBeenCalled(); + }); + + it("ignores keys pressed while typing in an input", () => { + renderHook(() => useTabShortcuts(NAV_ITEMS, onNavigate)); + const input = document.createElement("input"); + document.body.appendChild(input); + press("g", {}, input); + press("t", {}, input); + expect(onNavigate).not.toHaveBeenCalled(); + }); + + it("ignores sequences with modifier keys held", () => { + renderHook(() => useTabShortcuts(NAV_ITEMS, onNavigate)); + press("g", { ctrlKey: true }); + press("t"); + expect(onNavigate).not.toHaveBeenCalled(); + }); + + it("is inert while disabled (command palette open)", () => { + renderHook(() => useTabShortcuts(NAV_ITEMS, onNavigate, true)); + press("g"); + press("t"); + expect(onNavigate).not.toHaveBeenCalled(); + }); +}); diff --git a/dashboard/lib/use-tab-shortcuts.ts b/dashboard/lib/use-tab-shortcuts.ts new file mode 100644 index 0000000..4bd9caf --- /dev/null +++ b/dashboard/lib/use-tab-shortcuts.ts @@ -0,0 +1,50 @@ +"use client"; + +import { useCallback, useEffect, useRef } from "react"; + +/** How long after a bare `g` the second key still counts (#1041). */ +export const TAB_SHORTCUT_WINDOW_MS = 1000; + +/** Typing in a form field must never trigger navigation shortcuts. */ +export function isTypingTarget(target: EventTarget | null) { + if (!(target instanceof HTMLElement)) return false; + return ( + target.isContentEditable || + ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName) + ); +} + +/** + * #1041 — `g` then a tab key jumps between dashboard tabs, e.g. `g t` for + * Transactions. `items` maps the second key to a route via `shortcut`. + */ +export function useTabShortcuts( + items: { href: string; shortcut: string }[], + onNavigate: (href: string) => void, + disabled = false, +) { + const pendingGRef = useRef(0); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (disabled || e.metaKey || e.ctrlKey || e.altKey || isTypingTarget(e.target)) return; + const key = e.key.toLowerCase(); + if (Date.now() - pendingGRef.current < TAB_SHORTCUT_WINDOW_MS) { + pendingGRef.current = 0; + const item = items.find((nav) => nav.shortcut === key); + if (item) { + e.preventDefault(); + onNavigate(item.href); + } + return; + } + if (key === "g" && !e.shiftKey) pendingGRef.current = Date.now(); + }, + [items, onNavigate, disabled], + ); + + useEffect(() => { + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [handleKeyDown]); +}