Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion dashboard/app/dashboard/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down
21 changes: 13 additions & 8 deletions dashboard/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -112,6 +114,9 @@ export default function CommandPalette({ open, onClose }: CommandPaletteProps) {
>
<span aria-hidden="true">{item.icon}</span>
{item.label}
<kbd className="ml-auto font-mono text-[10px] uppercase text-slate-400">
g {item.shortcut}
</kbd>
</Command.Item>
))}
</Command.Group>
Expand Down
63 changes: 63 additions & 0 deletions dashboard/lib/__tests__/use-tab-shortcuts.test.tsx
Original file line number Diff line number Diff line change
@@ -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();
});
});
50 changes: 50 additions & 0 deletions dashboard/lib/use-tab-shortcuts.ts
Original file line number Diff line number Diff line change
@@ -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]);
}
Loading