Skip to content

Commit 385662e

Browse files
authored
feat(dashboard): keyboard shortcuts to switch dashboard tabs (#1116)
* feat(dashboard): g + key shortcuts to switch dashboard tabs * refactor: move tab shortcuts into useTabShortcuts hook with tests
1 parent 4cf40ca commit 385662e

4 files changed

Lines changed: 133 additions & 9 deletions

File tree

‎dashboard/app/dashboard/layout.tsx‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@ import Sidebar from "@/components/Sidebar";
77
import SearchBar from "@/components/SearchBar";
88
import ThemeSelector from "@/components/ThemeSelector";
99
import NotificationBell from "@/components/NotificationBell";
10-
import CommandPalette from "@/components/CommandPalette";
10+
import CommandPalette, { NAV_ITEMS } from "@/components/CommandPalette";
1111
import MobileNavDrawer from "@/components/MobileNavDrawer";
1212
import { NotificationsProvider } from "@/lib/notifications-context";
1313
import HealthStatusIndicator from "@/components/HealthStatusIndicator";
14+
import { useTabShortcuts } from "@/lib/use-tab-shortcuts";
1415

1516
function DashboardShell({ children }: { children: React.ReactNode }) {
17+
const router = useRouter();
1618
const [paletteOpen, setPaletteOpen] = useState(false);
1719
// #808 — mobile drawer, only reachable below the md breakpoint
1820
const [navOpen, setNavOpen] = useState(false);
@@ -25,6 +27,10 @@ function DashboardShell({ children }: { children: React.ReactNode }) {
2527
}
2628
}, []);
2729

30+
// #1041 — `g` then a tab key (see NAV_ITEMS) jumps between dashboard tabs
31+
const navigate = useCallback((href: string) => router.push(href), [router]);
32+
useTabShortcuts(NAV_ITEMS, navigate, paletteOpen);
33+
2834
useEffect(() => {
2935
document.addEventListener("keydown", handleKeyDown);
3036
return () => document.removeEventListener("keydown", handleKeyDown);

‎dashboard/components/CommandPalette.tsx‎

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,18 @@ interface NavItem {
99
href: string;
1010
icon: string;
1111
keywords?: string;
12+
/** Second key of the `g` + key tab shortcut (#1041), e.g. "t" for `g t`. */
13+
shortcut: string;
1214
}
1315

14-
const NAV_ITEMS: NavItem[] = [
15-
{ label: "Overview", href: "/dashboard", icon: "⬛", keywords: "home summary stats" },
16-
{ label: "Transactions", href: "/dashboard/transactions", icon: "📋", keywords: "payments social feed" },
17-
{ label: "Payouts", href: "/dashboard/payouts", icon: "💸", keywords: "batch disburse withdraw" },
18-
{ label: "QR Codes", href: "/dashboard/qr", icon: "⬜", keywords: "scan generate merchant" },
19-
{ label: "Analytics", href: "/dashboard/analytics", icon: "📈", keywords: "charts reports graph" },
20-
{ label: "Contracts", href: "/dashboard/contracts", icon: "🔗", keywords: "soroban smart contract health" },
21-
{ label: "Yield Vault", href: "/dashboard/yield", icon: "🏦", keywords: "apy tvl deposit" },
16+
export const NAV_ITEMS: NavItem[] = [
17+
{ label: "Overview", href: "/dashboard", icon: "⬛", keywords: "home summary stats", shortcut: "o" },
18+
{ label: "Transactions", href: "/dashboard/transactions", icon: "📋", keywords: "payments social feed", shortcut: "t" },
19+
{ label: "Payouts", href: "/dashboard/payouts", icon: "💸", keywords: "batch disburse withdraw", shortcut: "p" },
20+
{ label: "QR Codes", href: "/dashboard/qr", icon: "⬜", keywords: "scan generate merchant", shortcut: "q" },
21+
{ label: "Analytics", href: "/dashboard/analytics", icon: "📈", keywords: "charts reports graph", shortcut: "a" },
22+
{ label: "Contracts", href: "/dashboard/contracts", icon: "🔗", keywords: "soroban smart contract health", shortcut: "c" },
23+
{ label: "Yield Vault", href: "/dashboard/yield", icon: "🏦", keywords: "apy tvl deposit", shortcut: "y" },
2224
];
2325

2426
interface CommandPaletteProps {
@@ -112,6 +114,9 @@ export default function CommandPalette({ open, onClose }: CommandPaletteProps) {
112114
>
113115
<span aria-hidden="true">{item.icon}</span>
114116
{item.label}
117+
<kbd className="ml-auto font-mono text-[10px] uppercase text-slate-400">
118+
g {item.shortcut}
119+
</kbd>
115120
</Command.Item>
116121
))}
117122
</Command.Group>
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2+
import { renderHook } from "@testing-library/react";
3+
import { NAV_ITEMS } from "@/components/CommandPalette";
4+
import { TAB_SHORTCUT_WINDOW_MS, useTabShortcuts } from "@/lib/use-tab-shortcuts";
5+
6+
function press(key: string, init: KeyboardEventInit = {}, target: EventTarget = document.body) {
7+
target.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, ...init }));
8+
}
9+
10+
describe("useTabShortcuts (#1041)", () => {
11+
const onNavigate = vi.fn();
12+
13+
beforeEach(() => {
14+
vi.useFakeTimers();
15+
onNavigate.mockClear();
16+
});
17+
18+
afterEach(() => {
19+
vi.useRealTimers();
20+
document.body.innerHTML = "";
21+
});
22+
23+
it.each(NAV_ITEMS.map((item) => [item.shortcut, item.href]))(
24+
"g %s navigates to %s",
25+
(shortcut, href) => {
26+
renderHook(() => useTabShortcuts(NAV_ITEMS, onNavigate));
27+
press("g");
28+
press(shortcut);
29+
expect(onNavigate).toHaveBeenCalledWith(href);
30+
},
31+
);
32+
33+
it("ignores the second key once the window has passed", () => {
34+
renderHook(() => useTabShortcuts(NAV_ITEMS, onNavigate));
35+
press("g");
36+
vi.advanceTimersByTime(TAB_SHORTCUT_WINDOW_MS + 1);
37+
press("t");
38+
expect(onNavigate).not.toHaveBeenCalled();
39+
});
40+
41+
it("ignores keys pressed while typing in an input", () => {
42+
renderHook(() => useTabShortcuts(NAV_ITEMS, onNavigate));
43+
const input = document.createElement("input");
44+
document.body.appendChild(input);
45+
press("g", {}, input);
46+
press("t", {}, input);
47+
expect(onNavigate).not.toHaveBeenCalled();
48+
});
49+
50+
it("ignores sequences with modifier keys held", () => {
51+
renderHook(() => useTabShortcuts(NAV_ITEMS, onNavigate));
52+
press("g", { ctrlKey: true });
53+
press("t");
54+
expect(onNavigate).not.toHaveBeenCalled();
55+
});
56+
57+
it("is inert while disabled (command palette open)", () => {
58+
renderHook(() => useTabShortcuts(NAV_ITEMS, onNavigate, true));
59+
press("g");
60+
press("t");
61+
expect(onNavigate).not.toHaveBeenCalled();
62+
});
63+
});

‎dashboard/lib/use-tab-shortcuts.ts‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"use client";
2+
3+
import { useCallback, useEffect, useRef } from "react";
4+
5+
/** How long after a bare `g` the second key still counts (#1041). */
6+
export const TAB_SHORTCUT_WINDOW_MS = 1000;
7+
8+
/** Typing in a form field must never trigger navigation shortcuts. */
9+
export function isTypingTarget(target: EventTarget | null) {
10+
if (!(target instanceof HTMLElement)) return false;
11+
return (
12+
target.isContentEditable ||
13+
["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName)
14+
);
15+
}
16+
17+
/**
18+
* #1041 — `g` then a tab key jumps between dashboard tabs, e.g. `g t` for
19+
* Transactions. `items` maps the second key to a route via `shortcut`.
20+
*/
21+
export function useTabShortcuts(
22+
items: { href: string; shortcut: string }[],
23+
onNavigate: (href: string) => void,
24+
disabled = false,
25+
) {
26+
const pendingGRef = useRef(0);
27+
28+
const handleKeyDown = useCallback(
29+
(e: KeyboardEvent) => {
30+
if (disabled || e.metaKey || e.ctrlKey || e.altKey || isTypingTarget(e.target)) return;
31+
const key = e.key.toLowerCase();
32+
if (Date.now() - pendingGRef.current < TAB_SHORTCUT_WINDOW_MS) {
33+
pendingGRef.current = 0;
34+
const item = items.find((nav) => nav.shortcut === key);
35+
if (item) {
36+
e.preventDefault();
37+
onNavigate(item.href);
38+
}
39+
return;
40+
}
41+
if (key === "g" && !e.shiftKey) pendingGRef.current = Date.now();
42+
},
43+
[items, onNavigate, disabled],
44+
);
45+
46+
useEffect(() => {
47+
document.addEventListener("keydown", handleKeyDown);
48+
return () => document.removeEventListener("keydown", handleKeyDown);
49+
}, [handleKeyDown]);
50+
}

0 commit comments

Comments
 (0)