Skip to content

Commit ef03310

Browse files
authored
feat(#479): add global keyboard shortcut registry with help overlay (#536)
- Add ShortcutRegistryProvider context (src/context/ShortcutRegistry.tsx): - Central registry where any component can register/unregister shortcuts - Global keydown listener lives here — single source of truth - useRegisterShortcuts() hook: auto-registers on mount, cleans up on unmount - useShortcutRegistry() for reading the full list of registered shortcuts - Shortcuts support grouping via 'group' field for overlay sections - Rewrite useKeyboardShortcuts to register all built-in shortcuts via registry: - ? -> open/close help overlay - Esc -> close overlay / cancel G-chord - N -> create new invoice (on /dashboard only) - G then D/S/L/N -> navigate to Dashboard/Search/Leaderboard/New Invoice - Uses refs for chord state so handlers stay stable - Rewrite KeyboardShortcutsModal to pull from registry dynamically: - Groups shortcuts by 'group' field (General, Navigation, Invoices, etc.) - Shows total registered shortcut count in header - Renders multi-key chords with 'then' separator between keys - Scrollable with max-height so long lists don't overflow viewport - New entries appear automatically when components register shortcuts - Mount ShortcutRegistryProvider in root layout wrapping all children
1 parent 5214bea commit ef03310

4 files changed

Lines changed: 464 additions & 163 deletions

File tree

src/app/layout.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import QueryProvider from "@/contexts/QueryProvider";
2323
import LanguageSwitcher from "@/components/LanguageSwitcher";
2424
import { UserPreferencesProvider } from "@/context/UserPreferencesContext";
2525
import { FiatRateProvider } from "@/hooks/useFiatRate";
26+
import { ShortcutRegistryProvider } from "@/context/ShortcutRegistry";
2627

2728
const themeBootstrap = `
2829
(function () {
@@ -184,6 +185,7 @@ export default function RootLayout({
184185
<ToastProvider>
185186
<UserPreferencesProvider>
186187
<FiatRateProvider>
188+
<ShortcutRegistryProvider>
187189
<Navbar />
188190
<SimulationBanner />
189191
<UpgradeBanner />
@@ -201,6 +203,7 @@ export default function RootLayout({
201203
<RecipientOnboarding />
202204
<InstallBanner />
203205
<ToastContainer />
206+
</ShortcutRegistryProvider>
204207
</FiatRateProvider>
205208
</UserPreferencesProvider>
206209
</ToastProvider>

src/components/KeyboardShortcutsModal.tsx

Lines changed: 123 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,72 @@
11
"use client";
22

3+
import { useMemo } from "react";
34
import FocusTrap from "@/components/FocusTrap";
5+
import { useShortcutRegistry, type ShortcutDefinition } from "@/context/ShortcutRegistry";
46

5-
interface ShortcutEntry {
6-
keys: string[];
7-
description: string;
7+
interface Props {
8+
onClose: () => void;
89
}
910

10-
const SHORTCUTS: ShortcutEntry[] = [
11-
{ keys: ["?"], description: "Open keyboard shortcuts reference" },
12-
{ keys: ["⌘", "K"], description: "Open command palette" },
13-
{ keys: ["N"], description: "Create new invoice (on dashboard)" },
14-
{ keys: ["Esc"], description: "Close modal / dismiss overlay" },
15-
{ keys: ["G", "D"], description: "Navigate to Dashboard" },
16-
{ keys: ["G", "S"], description: "Navigate to Search" },
17-
{ keys: ["G", "L"], description: "Navigate to Leaderboard" },
18-
];
11+
// ── Helpers ───────────────────────────────────────────────────────────────────
1912

20-
interface Props {
21-
onClose: () => void;
13+
/**
14+
* Group shortcuts by their `group` field.
15+
* Groups are returned in the order they first appear in the registry,
16+
* with "General" always first when present.
17+
*/
18+
function groupShortcuts(
19+
shortcuts: ShortcutDefinition[],
20+
): Array<{ group: string; entries: ShortcutDefinition[] }> {
21+
const map = new Map<string, ShortcutDefinition[]>();
22+
23+
// Always seed General first so it stays at the top
24+
map.set("General", []);
25+
26+
for (const s of shortcuts) {
27+
// Hide internal chord-activation entries from the overlay
28+
if (s.id === "global:g-chord") continue;
29+
30+
const group = s.group ?? "General";
31+
if (!map.has(group)) map.set(group, []);
32+
map.get(group)!.push(s);
33+
}
34+
35+
// Remove empty groups
36+
return Array.from(map.entries())
37+
.filter(([, entries]) => entries.length > 0)
38+
.map(([group, entries]) => ({ group, entries }));
39+
}
40+
41+
// ── Kbd chip ──────────────────────────────────────────────────────────────────
42+
43+
function KbdKey({ label }: { label: string }) {
44+
return (
45+
<kbd className="inline-flex items-center justify-center px-1.5 py-0.5 min-w-[1.5rem] rounded-md text-xs font-mono font-semibold text-gray-200 bg-gray-700 border border-gray-600 shadow-sm shadow-black/30">
46+
{label}
47+
</kbd>
48+
);
2249
}
2350

51+
// ── Modal ─────────────────────────────────────────────────────────────────────
52+
2453
/**
2554
* KeyboardShortcutsModal
2655
*
27-
* A full-screen overlay that lists all global keyboard shortcuts.
28-
* Triggered by pressing `?` outside text inputs, or clicking the `?`
29-
* icon in the header. Closed by pressing Escape (via FocusTrap) or
56+
* A help overlay that lists **all shortcuts registered via ShortcutRegistry**.
57+
* Shortcuts are grouped by their `group` field (defaults to "General").
58+
*
59+
* Triggered by pressing `?` outside text inputs, or clicking the `?` icon in
60+
* the header. Closed by pressing Escape (handled in useKeyboardShortcuts) or
3061
* clicking the backdrop / close button.
62+
*
63+
* Components register shortcuts with `useRegisterShortcuts` — the overlay
64+
* automatically reflects additions and removals without any manual wiring.
3165
*/
3266
export default function KeyboardShortcutsModal({ onClose }: Props) {
67+
const { shortcuts } = useShortcutRegistry();
68+
const grouped = useMemo(() => groupShortcuts(shortcuts), [shortcuts]);
69+
3370
return (
3471
<div
3572
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm px-4"
@@ -40,17 +77,17 @@ export default function KeyboardShortcutsModal({ onClose }: Props) {
4077
>
4178
<FocusTrap onClose={onClose}>
4279
<div
43-
className="relative w-full max-w-lg rounded-2xl border border-gray-700/60 bg-gray-900/95 shadow-2xl shadow-black/60 p-0 overflow-hidden"
80+
className="relative w-full max-w-lg rounded-2xl border border-gray-700/60 bg-gray-900/95 shadow-2xl shadow-black/60 overflow-hidden max-h-[85vh] flex flex-col"
4481
onClick={(e) => e.stopPropagation()}
4582
>
4683
{/* Gradient accent strip */}
47-
<div className="h-1 w-full bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500" />
84+
<div className="h-1 w-full bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 shrink-0" />
4885

49-
<div className="px-6 py-5">
86+
<div className="px-6 pt-5 pb-4 overflow-y-auto">
5087
{/* Header */}
5188
<div className="flex items-center justify-between mb-5">
5289
<div className="flex items-center gap-2.5">
53-
<span className="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-indigo-500/20 border border-indigo-500/30">
90+
<span className="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-indigo-500/20 border border-indigo-500/30 shrink-0">
5491
<svg
5592
xmlns="http://www.w3.org/2000/svg"
5693
className="h-4 w-4 text-indigo-400"
@@ -60,26 +97,28 @@ export default function KeyboardShortcutsModal({ onClose }: Props) {
6097
strokeWidth={2}
6198
aria-hidden="true"
6299
>
63-
<path
64-
strokeLinecap="round"
65-
strokeLinejoin="round"
66-
d="M11 4a1 1 0 011-1h.01a1 1 0 110 2H12a1 1 0 01-1-1zm0 4a1 1 0 011-1h.01a1 1 0 110 2H12a1 1 0 01-1-1zM5 8a1 1 0 011-1h12a1 1 0 110 2H6a1 1 0 01-1-1zM5 12a1 1 0 011-1h12a1 1 0 110 2H6a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H6a1 1 0 01-1-1z"
67-
/>
100+
<rect x="2" y="4" width="20" height="16" rx="2" />
101+
<path d="M6 8h.01M10 8h.01M14 8h.01M18 8h.01M6 12h.01M10 12h.01M14 12h4M6 16h12" />
68102
</svg>
69103
</span>
70-
<h2
71-
id="kbd-shortcuts-title"
72-
className="text-base font-semibold text-gray-100 tracking-tight"
73-
>
74-
Keyboard Shortcuts
75-
</h2>
104+
<div>
105+
<h2
106+
id="kbd-shortcuts-title"
107+
className="text-base font-semibold text-gray-100 tracking-tight"
108+
>
109+
Keyboard Shortcuts
110+
</h2>
111+
<p className="text-xs text-gray-500 mt-0.5">
112+
{shortcuts.length} shortcut{shortcuts.length !== 1 ? "s" : ""} registered
113+
</p>
114+
</div>
76115
</div>
77116

78117
<button
79118
id="kbd-shortcuts-close-btn"
80119
onClick={onClose}
81120
aria-label="Close keyboard shortcuts"
82-
className="p-1.5 rounded-lg text-gray-400 hover:text-gray-100 hover:bg-gray-700/60 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500"
121+
className="p-1.5 rounded-lg text-gray-400 hover:text-gray-100 hover:bg-gray-700/60 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 shrink-0"
83122
>
84123
<svg
85124
xmlns="http://www.w3.org/2000/svg"
@@ -90,60 +129,66 @@ export default function KeyboardShortcutsModal({ onClose }: Props) {
90129
strokeWidth={2}
91130
aria-hidden="true"
92131
>
93-
<path
94-
strokeLinecap="round"
95-
strokeLinejoin="round"
96-
d="M6 18L18 6M6 6l12 12"
97-
/>
132+
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
98133
</svg>
99134
</button>
100135
</div>
101136

102-
{/* Shortcut table */}
103-
<table
104-
className="w-full text-sm border-separate"
105-
style={{ borderSpacing: "0 4px" }}
106-
aria-label="Keyboard shortcuts list"
107-
>
108-
<thead>
109-
<tr>
110-
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider pb-2 pl-3">
111-
Shortcut
112-
</th>
113-
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider pb-2">
114-
Action
115-
</th>
116-
</tr>
117-
</thead>
118-
<tbody>
119-
{SHORTCUTS.map((entry) => (
120-
<tr
121-
key={entry.description}
122-
className="group"
123-
>
124-
<td className="pl-3 pr-4 py-2.5 rounded-l-lg bg-gray-800/50 group-hover:bg-gray-800 transition-colors w-36">
125-
<span className="flex items-center gap-1">
126-
{entry.keys.map((k) => (
127-
<kbd
128-
key={k}
129-
className="inline-flex items-center justify-center px-1.5 py-0.5 min-w-[1.5rem] rounded-md text-xs font-mono font-semibold text-gray-200 bg-gray-700 border border-gray-600 shadow-sm shadow-black/30"
130-
>
131-
{k}
132-
</kbd>
137+
{/* Grouped shortcut sections */}
138+
{grouped.length === 0 ? (
139+
<p className="text-sm text-gray-500 text-center py-4">
140+
No shortcuts registered.
141+
</p>
142+
) : (
143+
<div className="flex flex-col gap-5">
144+
{grouped.map(({ group, entries }) => (
145+
<section key={group} aria-labelledby={`kbd-group-${group}`}>
146+
<h3
147+
id={`kbd-group-${group}`}
148+
className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2 px-1"
149+
>
150+
{group}
151+
</h3>
152+
153+
<table
154+
className="w-full text-sm border-separate"
155+
style={{ borderSpacing: "0 3px" }}
156+
aria-label={`${group} keyboard shortcuts`}
157+
>
158+
<tbody>
159+
{entries.map((entry) => (
160+
<tr key={entry.id} className="group">
161+
<td className="pl-3 pr-4 py-2 rounded-l-lg bg-gray-800/50 group-hover:bg-gray-800 transition-colors w-40">
162+
<span className="flex items-center gap-1 flex-wrap">
163+
{entry.keys.map((k, i) => (
164+
<span key={`${k}-${i}`} className="flex items-center gap-1">
165+
{i > 0 && (
166+
<span className="text-[10px] text-gray-600 mx-0.5">then</span>
167+
)}
168+
<KbdKey label={k} />
169+
</span>
170+
))}
171+
</span>
172+
</td>
173+
<td className="pr-3 py-2 rounded-r-lg bg-gray-800/50 group-hover:bg-gray-800 transition-colors text-gray-300 text-sm">
174+
{entry.description}
175+
</td>
176+
</tr>
133177
))}
134-
</span>
135-
</td>
136-
<td className="pr-3 py-2.5 rounded-r-lg bg-gray-800/50 group-hover:bg-gray-800 transition-colors text-gray-300">
137-
{entry.description}
138-
</td>
139-
</tr>
178+
</tbody>
179+
</table>
180+
</section>
140181
))}
141-
</tbody>
142-
</table>
182+
</div>
183+
)}
143184

144185
{/* Footer hint */}
145186
<p className="mt-5 text-xs text-gray-500 text-center">
146-
Shortcuts are disabled while typing in text fields.
187+
Shortcuts are disabled while typing in text fields.{" "}
188+
<kbd className="rounded bg-gray-700 border border-gray-600 px-1 py-0.5 text-[10px] font-mono text-gray-300">
189+
Esc
190+
</kbd>{" "}
191+
to close.
147192
</p>
148193
</div>
149194
</div>

0 commit comments

Comments
 (0)