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
103 changes: 103 additions & 0 deletions src/components/common/KeyboardShortcutsHelp.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Kbd } from '@/components/ui/kbd';
import { TRADE_SHORTCUTS, TRADE_DIALOG_SHORTCUTS } from '@/hooks/useTradeKeyboardShortcuts';
import { Keyboard } from 'lucide-react';

interface KeyboardShortcutsHelpProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}

/**
* Group shortcuts by category for display.
*/
function groupByCategory(
shortcuts: readonly TradeShortcut[]
): Map<string, TradeShortcut[]> {
const groups = new Map<string, TradeShortcut[]>();
for (const shortcut of shortcuts) {
const existing = groups.get(shortcut.category);
if (existing) {
existing.push(shortcut);
} else {
groups.set(shortcut.category, [shortcut]);
}
}
return groups;
}

type TradeShortcut = {
readonly keys: readonly string[];
readonly description: string;
readonly category: string;
};

const KeyboardShortcutsHelp: React.FC<KeyboardShortcutsHelpProps> = ({
open,
onOpenChange,
}) => {
const allShortcuts = [...TRADE_SHORTCUTS, ...TRADE_DIALOG_SHORTCUTS];
const groups = groupByCategory(allShortcuts);

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-w-md"
showCloseButton
showEscapeHint={false}
data-testid="keyboard-shortcuts-help"
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Keyboard className="size-5 text-amber-400" aria-hidden="true" />
Keyboard shortcuts
</DialogTitle>
<DialogDescription>
Keyboard shortcuts for power users. Press{' '}
<Kbd className="mx-0.5">?</Kbd> anytime to toggle this panel.
</DialogDescription>
</DialogHeader>

<div className="max-h-[60vh] space-y-5 overflow-y-auto pr-1">
{Array.from(groups.entries()).map(([category, shortcuts]) => (
<div key={category}>
<h3 className="mb-2 text-xs font-bold uppercase tracking-[0.2em] text-white/45">
{category}
</h3>
<ul className="space-y-1.5">
{shortcuts.map(shortcut => (
<li
key={shortcut.description}
className="flex items-center justify-between gap-4 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-white/[0.04]"
>
<span className="text-white/75">
{shortcut.description}
</span>
<span className="flex shrink-0 items-center gap-1">
{shortcut.keys.map((key, i) => (
<span key={`${key}-${i}`} className="flex items-center gap-1">
{i > 0 && (
<span className="text-[10px] text-white/30">+</span>
)}
<Kbd>{key}</Kbd>
</span>
))}
</span>
</li>
))}
</ul>
</div>
))}
</div>
</DialogContent>
</Dialog>
);
};

export default KeyboardShortcutsHelp;
8 changes: 4 additions & 4 deletions src/components/common/SearchBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,10 @@ const SearchBar: React.FC<SearchBarProps> = ({
<div className="relative">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<Search className="size-5 text-white/50" aria-hidden="true" />
</div>
<input
ref={inputRef}
type="text"
</div> <input
ref={inputRef}
type="text"
data-testid="search-bar-input"
className={cn(
'block w-full rounded-xl border border-white/10 bg-white/5 py-3 pl-10 pr-10 text-sm text-white placeholder:text-white/40 focus:border-amber-500/50 focus:bg-white/10 focus:outline-none focus:ring-2 focus:ring-amber-500/20',
validationMessage &&
Expand Down
96 changes: 96 additions & 0 deletions src/components/common/TradeDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,81 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
}
}, [open]);

// Internal keyboard shortcuts for amount adjustment when the dialog is open.
// Quick presets: 1→1, 2→2, 3→3, 4→5, 5→10, Shift+1→10
// Adjust: +/- to increment/decrement by 1
useEffect(() => {
if (!open || isSubmitting) return;

const handleAmountKey = (event: KeyboardEvent) => {
if (event.defaultPrevented || event.repeat) return;

// Only intercept when the amount input is focused
const activeEl = document.activeElement;
if (
!(activeEl instanceof HTMLInputElement) ||
activeEl.getAttribute('data-testid') !== 'trade-dialog-amount'
) {
return;
}

// Ignore if modifier keys are held (except Shift for !)
if (event.ctrlKey || event.metaKey || event.altKey) return;

const key = event.key;

// Quick amount presets
const presets: Record<string, number> = {
'1': 1,
'2': 2,
'3': 3,
'4': 5,
'5': 10,
};

if (!event.shiftKey && presets[key] !== undefined) {
event.preventDefault();
const clamped = clampBuyQuantity(presets[key].toString());
setAmountText(clamped.value.toString());
setTouched(true);
return;
}

// Shift+1 = 10
if (key === '!' && event.shiftKey) {
event.preventDefault();
const clamped = clampBuyQuantity('10');
setAmountText(clamped.value.toString());
setTouched(true);
return;
}

// Adjust amount: + and -
if (key === '+' || (key === '=' && event.shiftKey)) {
event.preventDefault();
const current = Number(amountText) || 0;
const next = Math.max(1, current + 1);
const clamped = clampBuyQuantity(next.toString());
setAmountText(clamped.value.toString());
setTouched(true);
return;
}

if (key === '-') {
event.preventDefault();
const current = Number(amountText) || 0;
const next = Math.max(1, current - 1);
const clamped = clampBuyQuantity(next.toString());
setAmountText(clamped.value.toString());
setTouched(true);
return;
}
};

window.addEventListener('keydown', handleAmountKey);
return () => window.removeEventListener('keydown', handleAmountKey);
}, [open, isSubmitting, amountText]);

const handleBlur = () => {
setTouched(true);
const normalized = amountText.trim();
Expand Down Expand Up @@ -429,6 +504,27 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
</StableButtonContent>
</Button>
</DialogFooter>

{/* Subtle keyboard shortcut hint for power users */}
<div
className="flex flex-wrap items-center justify-center gap-x-3 gap-y-1 border-t border-white/5 pt-3 text-[11px] text-white/30"
aria-hidden="true"
data-testid="trade-dialog-shortcut-hint"
>
<span className="flex items-center gap-1">
<kbd className="rounded border border-white/10 bg-white/[0.04] px-1 py-0.5 font-mono text-[10px]">Enter</kbd>
confirm
</span>
<span className="flex items-center gap-1">
<kbd className="rounded border border-white/10 bg-white/[0.04] px-1 py-0.5 font-mono text-[10px]">Esc</kbd>
close
</span>
<span className="flex items-center gap-1">
<kbd className="rounded border border-white/10 bg-white/[0.04] px-1 py-0.5 font-mono text-[10px]">+</kbd>
<kbd className="rounded border border-white/10 bg-white/[0.04] px-1 py-0.5 font-mono text-[10px]">-</kbd>
adjust
</span>
</div>
</DialogContent>
</Dialog>
);
Expand Down
93 changes: 93 additions & 0 deletions src/components/common/TradeShortcutHints.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { useEffect, useState } from 'react';
import { cn } from '@/lib/utils';
import { Kbd } from '@/components/ui/kbd';
import { Zap } from 'lucide-react';

interface TradeShortcutHintsProps {
/** Whether the trade dialog is open. */
open: boolean;
/** The current trade side. */
side: 'buy' | 'sell';
/** Whether hints should be visible. Auto-dismisses after a few seconds. */
visible?: boolean;
}

/**
* Floating hint bar that briefly appears when the trade dialog opens,
* showing power-user shortcuts. Auto-dismisses after 4 seconds.
*/
const TradeShortcutHints: React.FC<TradeShortcutHintsProps> = ({
open,
side,
visible: controlledVisible,
}) => {
const [dismissed, setDismissed] = useState(false);
const [show, setShow] = useState(false);

// Reset dismissed state when dialog closes
useEffect(() => {
if (!open) {
setDismissed(false);
setShow(false);
}
}, [open]);

// Show hints briefly when dialog opens
useEffect(() => {
if (open && !dismissed) {
const timer = window.setTimeout(() => setShow(true), 300);
const hideTimer = window.setTimeout(() => {
setShow(false);
setDismissed(true);
}, 4000);
return () => {
clearTimeout(timer);
clearTimeout(hideTimer);
};
}
}, [open, dismissed]);

const isVisible = controlledVisible ?? show;

if (!isVisible) return null;

return (
<div
role="status"
aria-live="polite"
className={cn(
'pointer-events-none fixed bottom-24 left-1/2 z-[60] -translate-x-1/2',
'flex items-center gap-3 rounded-full border border-amber-400/20',
'bg-slate-950/90 px-4 py-2 text-xs text-white/70 shadow-2xl shadow-black/40',
'backdrop-blur-md transition-all duration-300',
'sm:bottom-32',
'motion-reduce:transition-none',
show ? 'translate-y-0 opacity-100' : 'translate-y-2 opacity-0'
)}
data-testid="trade-shortcut-hints"
>
<Zap className="size-3.5 text-amber-400/80" aria-hidden="true" />
<span className="text-white/50">
{side === 'buy' ? 'Buy' : 'Sell'} shortcuts:
</span>
<span className="flex items-center gap-1.5">
<Kbd>Enter</Kbd>
<span className="text-white/40">confirm</span>
</span>
<span className="text-white/20">·</span>
<span className="flex items-center gap-1.5">
<Kbd>+</Kbd>
<Kbd>-</Kbd>
<span className="text-white/40">adjust</span>
</span>
<span className="text-white/20">·</span>
<span className="flex items-center gap-1.5">
<Kbd>1</Kbd>
<Kbd>5</Kbd>
<span className="text-white/40">quick amount</span>
</span>
</div>
);
};

export default TradeShortcutHints;
58 changes: 58 additions & 0 deletions src/components/common/__tests__/KeyboardShortcutsHelp.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import KeyboardShortcutsHelp from '@/components/common/KeyboardShortcutsHelp';

describe('KeyboardShortcutsHelp', () => {
it('renders when open', () => {
render(<KeyboardShortcutsHelp open onOpenChange={vi.fn()} />);

expect(screen.getByTestId('keyboard-shortcuts-help')).toBeInTheDocument();
});

it('does not render when closed', () => {
render(<KeyboardShortcutsHelp open={false} onOpenChange={vi.fn()} />);

expect(
screen.queryByTestId('keyboard-shortcuts-help')
).not.toBeInTheDocument();
});

it('displays all shortcut categories', () => {
render(<KeyboardShortcutsHelp open onOpenChange={vi.fn()} />);

expect(screen.getByText('Trade')).toBeInTheDocument();
expect(screen.getByText('Navigation')).toBeInTheDocument();
expect(screen.getByText('Amount')).toBeInTheDocument();
expect(screen.getByText('Quick amount')).toBeInTheDocument();
});

it('displays trade shortcut descriptions', () => {
render(<KeyboardShortcutsHelp open onOpenChange={vi.fn()} />);

expect(screen.getByText('Open buy dialog')).toBeInTheDocument();
expect(screen.getByText('Open sell dialog')).toBeInTheDocument();
expect(screen.getByText('Confirm trade (when valid)')).toBeInTheDocument();
});

it('displays navigation shortcut descriptions', () => {
render(<KeyboardShortcutsHelp open onOpenChange={vi.fn()} />);

expect(screen.getByText('Navigate to portfolio')).toBeInTheDocument();
expect(screen.getByText('Focus search bar')).toBeInTheDocument();
expect(screen.getByText('Switch to Overview tab')).toBeInTheDocument();
expect(screen.getByText('Switch to Creations tab')).toBeInTheDocument();
expect(screen.getByText('Switch to Collectors tab')).toBeInTheDocument();
expect(screen.getByText('Switch to Activity tab')).toBeInTheDocument();
expect(screen.getByText('Show keyboard shortcuts')).toBeInTheDocument();
});

it('displays keyboard key labels', () => {
render(<KeyboardShortcutsHelp open onOpenChange={vi.fn()} />);

// Check for key labels (kbd elements)
expect(screen.getAllByText('B').length).toBeGreaterThan(0);
expect(screen.getAllByText('S').length).toBeGreaterThan(0);
expect(screen.getAllByText('Enter').length).toBeGreaterThan(0);
expect(screen.getAllByText('/').length).toBeGreaterThan(0);
});
});
Loading
Loading