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
70 changes: 70 additions & 0 deletions frontend/app/components/KeyboardShortcutsModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"use client";

import React, { useRef } from "react";
import { X, Keyboard } from "lucide-react";
import { useFocusTrap } from "../hooks/useFocusTrap";
import { SHORTCUTS } from "../hooks/useKeyboardShortcuts";

interface Props {
isOpen: boolean;
onClose: () => void;
}

export default function KeyboardShortcutsModal({ isOpen, onClose }: Props) {
const modalRef = useRef<HTMLDivElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);

useFocusTrap({ isOpen, containerRef: modalRef, initialFocusRef: closeRef, onEscape: onClose });

if (!isOpen) return null;

return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-labelledby="shortcuts-title"
onClick={(e) => e.target === e.currentTarget && onClose()}
>
<div
ref={modalRef}
className="w-full max-w-md mx-4 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-strong)] shadow-2xl"
>
<div className="flex items-center justify-between p-5 border-b border-[var(--color-border)]">
<div className="flex items-center gap-2 text-[var(--color-text)]">
<Keyboard size={18} className="text-[var(--color-accent)]" />
<h2 id="shortcuts-title" className="m-0 text-base font-semibold">
Keyboard Shortcuts
</h2>
</div>
<button
ref={closeRef}
onClick={onClose}
aria-label="Close shortcuts modal"
className="rounded-lg p-1.5 text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:bg-[var(--color-surface-subtle)]"
>
<X size={16} />
</button>
</div>

<ul className="p-4 space-y-1 list-none m-0" role="list">
{SHORTCUTS.map((s) => (
<li
key={s.key}
className="flex items-center justify-between gap-4 rounded-xl px-3 py-2.5 hover:bg-[var(--color-surface-subtle)]"
>
<span className="text-sm text-[var(--color-text-muted)]">{s.description}</span>
<kbd className="shrink-0 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1 font-mono text-xs font-semibold text-[var(--color-accent)]">
{s.label}
</kbd>
</li>
))}
</ul>

<p className="px-5 pb-4 text-xs text-[var(--color-text-soft)]">
Shortcuts are disabled when focus is inside a text field.
</p>
</div>
</div>
);
}
52 changes: 36 additions & 16 deletions frontend/app/components/ThemeToggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,33 +45,45 @@ export default function ThemeToggle({
const { theme, resolvedTheme, setTheme } = useTheme();
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);

useEffect(() => {
if (!isOpen) {
return;
}
if (!isOpen) return;

const handlePointerDown = (event: MouseEvent) => {
if (!containerRef.current?.contains(event.target as Node)) {
setIsOpen(false);
}
};

const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setIsOpen(false);
}
};

document.addEventListener("mousedown", handlePointerDown);
document.addEventListener("keydown", handleEscape);

return () => {
document.removeEventListener("mousedown", handlePointerDown);
document.removeEventListener("keydown", handleEscape);
};
return () => document.removeEventListener("mousedown", handlePointerDown);
}, [isOpen]);

// Arrow-key navigation inside the open menu
const handleMenuKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
const items = Array.from(
menuRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]') ?? []
);
const focused = document.activeElement as HTMLElement;
const idx = items.indexOf(focused as HTMLButtonElement);

if (e.key === "ArrowDown") {
e.preventDefault();
items[(idx + 1) % items.length]?.focus();
} else if (e.key === "ArrowUp") {
e.preventDefault();
items[(idx - 1 + items.length) % items.length]?.focus();
} else if (e.key === "Escape") {
e.preventDefault();
setIsOpen(false);
triggerRef.current?.focus();
} else if (e.key === "Tab") {
setIsOpen(false);
}
};

const activeOption = useMemo(
() => themeOptions.find((option) => option.value === theme) ?? themeOptions[2],
[theme]
Expand All @@ -83,8 +95,12 @@ export default function ThemeToggle({
return (
<div ref={containerRef} className={clsx("relative", className)}>
<button
ref={triggerRef}
type="button"
onClick={() => setIsOpen((current) => !current)}
onKeyDown={(e) => {
if (e.key === "ArrowDown") { e.preventDefault(); setIsOpen(true); }
}}
aria-label={`Theme: ${activeOption.label}`}
aria-haspopup="menu"
aria-expanded={isOpen}
Expand Down Expand Up @@ -114,14 +130,16 @@ export default function ThemeToggle({

{isOpen ? (
<div
ref={menuRef}
role="menu"
aria-label="Theme selector"
onKeyDown={handleMenuKeyDown}
className={clsx(
"absolute z-50 mt-2 min-w-[220px] rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-1.5 shadow-xl",
compact ? "right-0" : "left-0"
)}
>
{themeOptions.map(({ value, label, description, Icon }) => {
{themeOptions.map(({ value, label, description, Icon }, i) => {
const selected = theme === value;

return (
Expand All @@ -130,9 +148,11 @@ export default function ThemeToggle({
type="button"
role="menuitemradio"
aria-checked={selected}
tabIndex={i === 0 ? 0 : -1}
onClick={() => {
setTheme(value);
setIsOpen(false);
triggerRef.current?.focus();
}}
className={clsx(
"flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--color-accent)] focus-visible:ring-inset",
Expand Down
39 changes: 39 additions & 0 deletions frontend/app/components/ThemedImage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"use client";

import React, { useState } from "react";
import Image, { ImageProps } from "next/image";
import { useTheme } from "../context/ThemeContext";

interface ThemedImageProps extends Omit<ImageProps, "src"> {
srcLight: string;
srcDark: string;
alt: string;
}

/**
* Renders the correct image variant based on the active theme.
* Falls back to a skeleton placeholder while loading.
*/
export default function ThemedImage({ srcLight, srcDark, alt, className, ...props }: ThemedImageProps) {
const { resolvedTheme } = useTheme();
const src = resolvedTheme === "dark" ? srcDark : srcLight;
const [loaded, setLoaded] = useState(false);

return (
<span className="relative inline-block">
{!loaded && (
<span
aria-hidden="true"
className="absolute inset-0 rounded-inherit skeleton-shimmer"
/>
)}
<Image
{...props}
src={src}
alt={alt}
className={`transition-opacity duration-300 ${loaded ? "opacity-100" : "opacity-0"} ${className ?? ""}`}
onLoad={() => setLoaded(true)}
/>
</span>
);
}
12 changes: 12 additions & 0 deletions frontend/app/components/dashboard/TopNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
Copy,
ExternalLink,
HelpCircle,
Keyboard,
LogOut,
Search,
Wallet,
Expand Down Expand Up @@ -132,6 +133,17 @@ const TopNav: React.FC = () => {
<Icon size={16} />
</button>
))}
<button
aria-label="Keyboard shortcuts (?)"
title="Keyboard shortcuts (?)"
className={actionButtonClass}
onClick={() => {
const e = new KeyboardEvent("keydown", { key: "?", bubbles: true });
document.dispatchEvent(e);
}}
>
<Keyboard size={16} />
</button>
</div>
</div>

Expand Down
16 changes: 16 additions & 0 deletions frontend/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -241,3 +241,19 @@ body {
animation: none;
}
}

/* Dark-mode image utilities */
[data-theme="dark"] .img-invert-on-dark,
.dark .img-invert-on-dark {
filter: invert(1) hue-rotate(180deg);
}

[data-theme="dark"] .img-dim-on-dark,
.dark .img-dim-on-dark {
filter: brightness(0.85) saturate(0.9);
}

/* Keyboard shortcut badge */
kbd {
font-family: var(--font-mono, ui-monospace, monospace);
}
117 changes: 117 additions & 0 deletions frontend/app/hooks/useKeyboardShortcuts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
"use client";

import { useEffect } from "react";
import { useRouter } from "next/navigation";

export interface ShortcutDef {
key: string;
label: string;
description: string;
global?: boolean; // fires even inside inputs
}

export const SHORTCUTS: ShortcutDef[] = [
{ key: "Ctrl+K", label: "Ctrl+K", description: "Open search / command palette" },
{ key: "g d", label: "G then D", description: "Go to Dashboard" },
{ key: "g s", label: "G then S", description: "Go to Savings" },
{ key: "g p", label: "G then P", description: "Go to Portfolio" },
{ key: "g t", label: "G then T", description: "Go to Transactions" },
{ key: "?", label: "?", description: "Show keyboard shortcuts" },
{ key: "Escape", label: "Esc", description: "Close modals / dropdowns", global: true },
{ key: "Ctrl+/", label: "Ctrl+/", description: "Toggle theme" },
{ key: "n", label: "N", description: "New goal (on Savings page)" },
];

interface Options {
onSearch?: () => void;
onToggleTheme?: () => void;
onShowHelp?: () => void;
onNewGoal?: () => void;
pathname?: string;
}

export function useKeyboardShortcuts({
onSearch,
onToggleTheme,
onShowHelp,
onNewGoal,
pathname = "",
}: Options) {
const router = useRouter();

useEffect(() => {
let pending: string | null = null;
let pendingTimer: ReturnType<typeof setTimeout> | null = null;

const clear = () => {
pending = null;
if (pendingTimer) clearTimeout(pendingTimer);
};

const handler = (e: KeyboardEvent) => {
const active = document.activeElement;
const inInput =
active instanceof HTMLInputElement ||
active instanceof HTMLTextAreaElement ||
(active as HTMLElement)?.isContentEditable;

// Ctrl+K — always fires
if ((e.ctrlKey || e.metaKey) && e.key === "k") {
e.preventDefault();
onSearch?.();
return;
}

// Ctrl+/ — toggle theme
if ((e.ctrlKey || e.metaKey) && e.key === "/") {
e.preventDefault();
onToggleTheme?.();
return;
}

if (inInput) return;

// ? — show help
if (e.key === "?") {
e.preventDefault();
onShowHelp?.();
return;
}

// N — new goal on savings page
if (e.key === "n" && pathname.startsWith("/savings")) {
e.preventDefault();
onNewGoal?.();
return;
}

// Two-key sequences: g then d/s/p/t
if (e.key === "g") {
pending = "g";
pendingTimer = setTimeout(clear, 1000);
return;
}

if (pending === "g") {
clear();
const routes: Record<string, string> = {
d: "/dashboard",
s: "/savings",
p: "/dashboard/portfolio",
t: "/dashboard/transactions",
};
const route = routes[e.key.toLowerCase()];
if (route) {
e.preventDefault();
router.push(route);
}
}
};

document.addEventListener("keydown", handler);
return () => {
document.removeEventListener("keydown", handler);
clear();
};
}, [onSearch, onToggleTheme, onShowHelp, onNewGoal, pathname, router]);
}
Loading