diff --git a/dashboard/app/dashboard/layout.tsx b/dashboard/app/dashboard/layout.tsx
index b5eafc3..ab1c039 100644
--- a/dashboard/app/dashboard/layout.tsx
+++ b/dashboard/app/dashboard/layout.tsx
@@ -7,12 +7,14 @@ 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";
+import { useTabShortcuts } from "@/lib/use-tab-shortcuts";
function DashboardShell({ children }: { children: React.ReactNode }) {
+ const router = useRouter();
const [paletteOpen, setPaletteOpen] = useState(false);
// #808 — mobile drawer, only reachable below the md breakpoint
const [navOpen, setNavOpen] = useState(false);
@@ -25,6 +27,10 @@ function DashboardShell({ children }: { children: React.ReactNode }) {
}
}, []);
+ // #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);
return () => document.removeEventListener("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.icon}
{item.label}
+
+ g {item.shortcut}
+
))}
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]);
+}