feat: cmd+enter in task chat

This commit is contained in:
Carlos Florêncio
2026-01-14 19:07:34 +00:00
parent 85ecf7debb
commit 5813bd8f14
10 changed files with 975 additions and 5 deletions
@@ -0,0 +1,96 @@
/**
* Reusable tooltip component for displaying keyboard shortcuts
*/
import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
import { Kbd, KbdGroup } from '@kandev/ui/kbd';
import type { KeyboardShortcut } from '@/lib/keyboard/constants';
import { formatShortcut, detectPlatform } from '@/lib/keyboard/utils';
type KeyboardShortcutTooltipProps = {
shortcut: KeyboardShortcut;
children: React.ReactNode;
/** Additional description text to show alongside the shortcut */
description?: string;
/** Whether to show the tooltip (default: true) */
enabled?: boolean;
};
/**
* Tooltip that displays a keyboard shortcut hint
*
* @example
* ```tsx
* <KeyboardShortcutTooltip shortcut={SHORTCUTS.SUBMIT}>
* <Button type="submit">Submit</Button>
* </KeyboardShortcutTooltip>
* ```
*/
export function KeyboardShortcutTooltip({
shortcut,
children,
description,
enabled = true,
}: KeyboardShortcutTooltipProps) {
if (!enabled) {
return <>{children}</>;
}
const platform = detectPlatform();
const formattedShortcut = formatShortcut(shortcut, platform);
const parts = formattedShortcut.split('+');
return (
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent>
<div className="flex items-center gap-2">
{description && <span>{description}</span>}
<KbdGroup>
{parts.map((part, index) => (
<Kbd key={index}>{part}</Kbd>
))}
</KbdGroup>
</div>
</TooltipContent>
</Tooltip>
);
}
/**
* Simple text-only version that just shows the formatted shortcut
* Useful for inline display without tooltip
*
* @example
* ```tsx
* <KeyboardShortcutText shortcut={SHORTCUTS.SUBMIT} />
* // Renders: Cmd+Enter (on Mac) or Ctrl+Enter (on Windows/Linux)
* ```
*/
export function KeyboardShortcutText({ shortcut }: { shortcut: KeyboardShortcut }) {
const platform = detectPlatform();
return <span>{formatShortcut(shortcut, platform)}</span>;
}
/**
* Kbd group version for displaying shortcuts with proper styling
*
* @example
* ```tsx
* <KeyboardShortcutKbd shortcut={SHORTCUTS.SUBMIT} />
* ```
*/
export function KeyboardShortcutKbd({ shortcut }: { shortcut: KeyboardShortcut }) {
const platform = detectPlatform();
const formattedShortcut = formatShortcut(shortcut, platform);
const parts = formattedShortcut.split('+');
return (
<KbdGroup>
{parts.map((part, index) => (
<Kbd key={index}>{part}</Kbd>
))}
</KbdGroup>
);
}
+14 -3
View File
@@ -40,6 +40,9 @@ import { useAppStore } from '@/components/state-provider';
import { useRepositories } from '@/hooks/use-repositories';
import { useRepositoryBranches } from '@/hooks/use-repository-branches';
import { useSettingsData } from '@/hooks/use-settings-data';
import { SHORTCUTS } from '@/lib/keyboard/constants';
import { useKeyboardShortcutHandler } from '@/hooks/use-keyboard-shortcut';
import { KeyboardShortcutTooltip } from '@/components/keyboard-shortcut-tooltip';
interface TaskCreateDialogProps {
open: boolean;
@@ -184,6 +187,11 @@ export function TaskCreateDialog({
textarea.style.height = `${textarea.scrollHeight}px`;
}, [description]);
// Use keyboard shortcut hook for Cmd+Enter / Ctrl+Enter
const handleKeyDown = useKeyboardShortcutHandler(SHORTCUTS.SUBMIT, (event) => {
handleSubmit(event as unknown as FormEvent);
});
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
const trimmedTitle = title.trim();
@@ -283,6 +291,7 @@ export function TaskCreateDialog({
placeholder="Write a prompt for the agent..."
value={description}
onChange={(e) => setDescription(e.target.value)}
onKeyDown={handleKeyDown}
rows={2}
className="min-h-[96px] max-h-[240px] resize-y overflow-auto"
/>
@@ -498,9 +507,11 @@ export function TaskCreateDialog({
Cancel
</Button>
</DialogClose>
<Button type="submit" disabled={!title.trim() || (!isEditMode && !repositoryId)}>
{submitLabel}
</Button>
<KeyboardShortcutTooltip shortcut={SHORTCUTS.SUBMIT}>
<Button type="submit" disabled={!title.trim() || (!isEditMode && !repositoryId)}>
{submitLabel}
</Button>
</KeyboardShortcutTooltip>
</DialogFooter>
</form>
</DialogContent>
+12 -1
View File
@@ -33,6 +33,9 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
import { cn } from '@/lib/utils';
import { useAppStore } from '@/components/state-provider';
import type { Comment } from '@/lib/types/http';
import { SHORTCUTS } from '@/lib/keyboard/constants';
import { useKeyboardShortcutHandler } from '@/hooks/use-keyboard-shortcut';
import { KeyboardShortcutTooltip } from '@/components/keyboard-shortcut-tooltip';
type AgentOption = {
id: string;
@@ -266,6 +269,11 @@ export function TaskChatPanel({ agents, onSend, isLoading, isAgentWorking, taskD
}
};
// Use keyboard shortcut hook for Cmd+Enter / Ctrl+Enter
const handleKeyDown = useKeyboardShortcutHandler(SHORTCUTS.SUBMIT, (event) => {
handleSubmit(event as unknown as FormEvent);
});
const formatContent = (comment: Comment) => {
// For content type comments, the content might be streamed chunks
// For message type, show as-is
@@ -344,6 +352,7 @@ export function TaskChatPanel({ agents, onSend, isLoading, isAgentWorking, taskD
<Textarea
value={messageInput}
onChange={(event) => setMessageInput(event.target.value)}
onKeyDown={handleKeyDown}
placeholder="Write to submit work to the agent..."
className={cn(
'min-h-[90px] resize-none',
@@ -416,7 +425,9 @@ export function TaskChatPanel({ agents, onSend, isLoading, isAgentWorking, taskD
</TooltipTrigger>
<TooltipContent>Add attachments</TooltipContent>
</Tooltip>
<Button type="submit">Submit</Button>
<KeyboardShortcutTooltip shortcut={SHORTCUTS.SUBMIT}>
<Button type="submit">Submit</Button>
</KeyboardShortcutTooltip>
</div>
</div>
</form>
+119
View File
@@ -0,0 +1,119 @@
/**
* React hook for handling keyboard shortcuts
*/
import { useEffect, useCallback, useRef } from 'react';
import type { KeyboardShortcut } from '@/lib/keyboard/constants';
import { matchesShortcut } from '@/lib/keyboard/utils';
export type KeyboardShortcutOptions = {
/** Whether the shortcut is enabled (default: true) */
enabled?: boolean;
/** Whether to prevent default behavior (default: true) */
preventDefault?: boolean;
/** Whether to stop event propagation (default: false) */
stopPropagation?: boolean;
/** Target element (default: window) */
target?: HTMLElement | Window | null;
};
/**
* Hook for handling global keyboard shortcuts
*
* @example
* ```tsx
* useKeyboardShortcut(SHORTCUTS.SUBMIT, handleSubmit);
* ```
*/
export function useKeyboardShortcut(
shortcut: KeyboardShortcut,
callback: (event: KeyboardEvent) => void,
options: KeyboardShortcutOptions = {}
) {
const {
enabled = true,
preventDefault = true,
stopPropagation = false,
target = typeof window !== 'undefined' ? window : null,
} = options;
// Use ref to avoid recreating the handler on every render
const callbackRef = useRef(callback);
callbackRef.current = callback;
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
if (!enabled) return;
if (matchesShortcut(event, shortcut)) {
if (preventDefault) {
event.preventDefault();
}
if (stopPropagation) {
event.stopPropagation();
}
callbackRef.current(event);
}
},
[enabled, preventDefault, stopPropagation, shortcut]
);
useEffect(() => {
if (!enabled || !target) return;
target.addEventListener('keydown', handleKeyDown as EventListener);
return () => {
target.removeEventListener('keydown', handleKeyDown as EventListener);
};
}, [enabled, target, handleKeyDown]);
}
/**
* Hook for handling keyboard shortcuts on a specific element (e.g., textarea)
* Returns a handler to attach to onKeyDown
*
* @example
* ```tsx
* const handleKeyDown = useKeyboardShortcutHandler(SHORTCUTS.SUBMIT, handleSubmit);
*
* return <textarea onKeyDown={handleKeyDown} />;
* ```
*/
export function useKeyboardShortcutHandler(
shortcut: KeyboardShortcut,
callback: (event: React.KeyboardEvent) => void,
options: Omit<KeyboardShortcutOptions, 'target'> = {}
) {
const {
enabled = true,
preventDefault = true,
stopPropagation = false,
} = options;
// Use ref to avoid recreating the handler on every render
const callbackRef = useRef(callback);
// Update ref in useEffect to avoid updating during render
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
return useCallback(
(event: React.KeyboardEvent) => {
if (!enabled) return;
if (matchesShortcut(event, shortcut)) {
if (preventDefault) {
event.preventDefault();
}
if (stopPropagation) {
event.stopPropagation();
}
callbackRef.current(event);
}
},
[enabled, preventDefault, stopPropagation, shortcut]
);
}
+144
View File
@@ -0,0 +1,144 @@
import { describe, expect, it } from 'vitest';
import { MODIFIER_KEYS, KEYS, SHORTCUTS } from './constants';
describe('MODIFIER_KEYS', () => {
it('defines all modifier keys', () => {
expect(MODIFIER_KEYS.CTRL).toBe('Control');
expect(MODIFIER_KEYS.CMD).toBe('Meta');
expect(MODIFIER_KEYS.ALT).toBe('Alt');
expect(MODIFIER_KEYS.SHIFT).toBe('Shift');
});
it('has correct type', () => {
// TypeScript ensures these are readonly at compile time
const keys: typeof MODIFIER_KEYS = MODIFIER_KEYS;
expect(keys).toBeDefined();
});
});
describe('KEYS', () => {
it('defines special keys', () => {
expect(KEYS.ENTER).toBe('Enter');
expect(KEYS.ESCAPE).toBe('Escape');
expect(KEYS.SPACE).toBe(' ');
expect(KEYS.TAB).toBe('Tab');
expect(KEYS.BACKSPACE).toBe('Backspace');
expect(KEYS.DELETE).toBe('Delete');
});
it('defines arrow keys', () => {
expect(KEYS.ARROW_UP).toBe('ArrowUp');
expect(KEYS.ARROW_DOWN).toBe('ArrowDown');
expect(KEYS.ARROW_LEFT).toBe('ArrowLeft');
expect(KEYS.ARROW_RIGHT).toBe('ArrowRight');
});
it('defines letter keys A-Z', () => {
expect(KEYS.A).toBe('a');
expect(KEYS.B).toBe('b');
expect(KEYS.Z).toBe('z');
});
it('defines number keys 0-9', () => {
expect(KEYS.ZERO).toBe('0');
expect(KEYS.ONE).toBe('1');
expect(KEYS.NINE).toBe('9');
});
it('has correct type', () => {
// TypeScript ensures these are readonly at compile time
const keys: typeof KEYS = KEYS;
expect(keys).toBeDefined();
});
});
describe('SHORTCUTS', () => {
describe('SUBMIT', () => {
it('uses Enter key with ctrlOrCmd modifier', () => {
expect(SHORTCUTS.SUBMIT.key).toBe('Enter');
expect(SHORTCUTS.SUBMIT.modifiers?.ctrlOrCmd).toBe(true);
});
});
describe('SAVE', () => {
it('uses S key with ctrlOrCmd modifier', () => {
expect(SHORTCUTS.SAVE.key).toBe('s');
expect(SHORTCUTS.SAVE.modifiers?.ctrlOrCmd).toBe(true);
});
});
describe('CANCEL', () => {
it('uses Escape key without modifiers', () => {
expect(SHORTCUTS.CANCEL.key).toBe('Escape');
expect(SHORTCUTS.CANCEL.modifiers).toBeUndefined();
});
});
describe('SEARCH', () => {
it('uses K key with ctrlOrCmd modifier', () => {
expect(SHORTCUTS.SEARCH.key).toBe('k');
expect(SHORTCUTS.SEARCH.modifiers?.ctrlOrCmd).toBe(true);
});
});
describe('NEW_TASK', () => {
it('uses N key with ctrlOrCmd modifier', () => {
expect(SHORTCUTS.NEW_TASK.key).toBe('n');
expect(SHORTCUTS.NEW_TASK.modifiers?.ctrlOrCmd).toBe(true);
});
});
describe('TOGGLE_SIDEBAR', () => {
it('uses B key with ctrlOrCmd modifier', () => {
expect(SHORTCUTS.TOGGLE_SIDEBAR.key).toBe('b');
expect(SHORTCUTS.TOGGLE_SIDEBAR.modifiers?.ctrlOrCmd).toBe(true);
});
});
it('has correct type', () => {
// TypeScript ensures these are readonly at compile time
const shortcuts: typeof SHORTCUTS = SHORTCUTS;
expect(shortcuts).toBeDefined();
});
it('all shortcuts have valid key property', () => {
Object.values(SHORTCUTS).forEach((shortcut) => {
expect(shortcut).toHaveProperty('key');
expect(typeof shortcut.key).toBe('string');
});
});
it('all shortcuts with modifiers have valid modifier properties', () => {
Object.values(SHORTCUTS).forEach((shortcut) => {
if (shortcut.modifiers) {
const validModifiers = ['ctrl', 'cmd', 'alt', 'shift', 'ctrlOrCmd'];
Object.keys(shortcut.modifiers).forEach((modifier) => {
expect(validModifiers).toContain(modifier);
});
}
});
});
});
describe('Type definitions', () => {
it('KeyboardShortcut type accepts valid shortcuts', () => {
// These should compile without errors
const shortcut1: import('./constants').KeyboardShortcut = {
key: 'Enter',
};
expect(shortcut1.key).toBe('Enter');
const shortcut2: import('./constants').KeyboardShortcut = {
key: 's',
modifiers: { ctrlOrCmd: true },
};
expect(shortcut2.modifiers?.ctrlOrCmd).toBe(true);
const shortcut3: import('./constants').KeyboardShortcut = {
key: 'a',
modifiers: { ctrl: true, shift: true, alt: true },
};
expect(shortcut3.modifiers?.ctrl).toBe(true);
});
});
+117
View File
@@ -0,0 +1,117 @@
/**
* Keyboard shortcut constants and key definitions
*/
// Modifier keys
export const MODIFIER_KEYS = {
CTRL: 'Control',
CMD: 'Meta',
ALT: 'Alt',
SHIFT: 'Shift',
} as const;
// Common keys
export const KEYS = {
ENTER: 'Enter',
ESCAPE: 'Escape',
SPACE: ' ',
TAB: 'Tab',
BACKSPACE: 'Backspace',
DELETE: 'Delete',
ARROW_UP: 'ArrowUp',
ARROW_DOWN: 'ArrowDown',
ARROW_LEFT: 'ArrowLeft',
ARROW_RIGHT: 'ArrowRight',
// Letters
A: 'a',
B: 'b',
C: 'c',
D: 'd',
E: 'e',
F: 'f',
G: 'g',
H: 'h',
I: 'i',
J: 'j',
K: 'k',
L: 'l',
M: 'm',
N: 'n',
O: 'o',
P: 'p',
Q: 'q',
R: 'r',
S: 's',
T: 't',
U: 'u',
V: 'v',
W: 'w',
X: 'x',
Y: 'y',
Z: 'z',
// Numbers
ZERO: '0',
ONE: '1',
TWO: '2',
THREE: '3',
FOUR: '4',
FIVE: '5',
SIX: '6',
SEVEN: '7',
EIGHT: '8',
NINE: '9',
} as const;
// Platform types
export type Platform = 'mac' | 'windows' | 'linux' | 'unknown';
// Modifier key type
export type ModifierKey = keyof typeof MODIFIER_KEYS;
// Key type
export type Key = (typeof KEYS)[keyof typeof KEYS];
/**
* Keyboard shortcut definition
*/
export type KeyboardShortcut = {
key: Key;
modifiers?: {
ctrl?: boolean;
cmd?: boolean;
alt?: boolean;
shift?: boolean;
/** Use Cmd on Mac, Ctrl on Windows/Linux */
ctrlOrCmd?: boolean;
};
};
/**
* Common keyboard shortcuts used across the app
*/
export const SHORTCUTS = {
SUBMIT: {
key: KEYS.ENTER,
modifiers: { ctrlOrCmd: true },
},
SAVE: {
key: KEYS.S,
modifiers: { ctrlOrCmd: true },
},
CANCEL: {
key: KEYS.ESCAPE,
},
SEARCH: {
key: KEYS.K,
modifiers: { ctrlOrCmd: true },
},
NEW_TASK: {
key: KEYS.N,
modifiers: { ctrlOrCmd: true },
},
TOGGLE_SIDEBAR: {
key: KEYS.B,
modifiers: { ctrlOrCmd: true },
},
} as const;
+315
View File
@@ -0,0 +1,315 @@
import { describe, expect, it, beforeEach, vi } from 'vitest';
import { detectPlatform, isMac, formatShortcut, matchesShortcut } from './utils';
import { KEYS, SHORTCUTS } from './constants';
import type { KeyboardShortcut } from './constants';
describe('detectPlatform', () => {
beforeEach(() => {
// Reset navigator mock before each test
vi.unstubAllGlobals();
});
it('detects Mac platform from navigator.platform', () => {
vi.stubGlobal('navigator', {
platform: 'MacIntel',
userAgent: 'Mozilla/5.0',
});
expect(detectPlatform()).toBe('mac');
});
it('detects Mac platform from userAgent', () => {
vi.stubGlobal('navigator', {
platform: 'unknown',
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)',
});
expect(detectPlatform()).toBe('mac');
});
it('detects Windows platform', () => {
vi.stubGlobal('navigator', {
platform: 'Win32',
userAgent: 'Mozilla/5.0 (Windows NT 10.0)',
});
expect(detectPlatform()).toBe('windows');
});
it('detects Linux platform', () => {
vi.stubGlobal('navigator', {
platform: 'Linux x86_64',
userAgent: 'Mozilla/5.0 (X11; Linux x86_64)',
});
expect(detectPlatform()).toBe('linux');
});
it('returns unknown when navigator is undefined', () => {
vi.stubGlobal('navigator', undefined);
expect(detectPlatform()).toBe('unknown');
});
it('returns unknown for unrecognized platform', () => {
vi.stubGlobal('navigator', {
platform: 'FreeBSD',
userAgent: 'Mozilla/5.0',
});
expect(detectPlatform()).toBe('unknown');
});
});
describe('isMac', () => {
it('returns true on Mac platform', () => {
vi.stubGlobal('navigator', {
platform: 'MacIntel',
userAgent: 'Mozilla/5.0',
});
expect(isMac()).toBe(true);
});
it('returns false on Windows platform', () => {
vi.stubGlobal('navigator', {
platform: 'Win32',
userAgent: 'Mozilla/5.0',
});
expect(isMac()).toBe(false);
});
});
describe('formatShortcut', () => {
it('formats Cmd+Enter on Mac', () => {
const shortcut: KeyboardShortcut = {
key: KEYS.ENTER,
modifiers: { ctrlOrCmd: true },
};
expect(formatShortcut(shortcut, 'mac')).toBe('Cmd+Enter');
});
it('formats Ctrl+Enter on Windows', () => {
const shortcut: KeyboardShortcut = {
key: KEYS.ENTER,
modifiers: { ctrlOrCmd: true },
};
expect(formatShortcut(shortcut, 'windows')).toBe('Ctrl+Enter');
});
it('formats Ctrl+Enter on Linux', () => {
const shortcut: KeyboardShortcut = {
key: KEYS.ENTER,
modifiers: { ctrlOrCmd: true },
};
expect(formatShortcut(shortcut, 'linux')).toBe('Ctrl+Enter');
});
it('formats Ctrl+S', () => {
const shortcut: KeyboardShortcut = {
key: KEYS.S,
modifiers: { ctrl: true },
};
expect(formatShortcut(shortcut, 'windows')).toBe('Ctrl+S');
});
it('formats Cmd+S on Mac', () => {
const shortcut: KeyboardShortcut = {
key: KEYS.S,
modifiers: { cmd: true },
};
expect(formatShortcut(shortcut, 'mac')).toBe('Cmd+S');
});
it('formats Alt as Option on Mac', () => {
const shortcut: KeyboardShortcut = {
key: KEYS.A,
modifiers: { alt: true },
};
expect(formatShortcut(shortcut, 'mac')).toBe('Option+A');
});
it('formats Alt on Windows', () => {
const shortcut: KeyboardShortcut = {
key: KEYS.A,
modifiers: { alt: true },
};
expect(formatShortcut(shortcut, 'windows')).toBe('Alt+A');
});
it('formats Shift+Ctrl+S', () => {
const shortcut: KeyboardShortcut = {
key: KEYS.S,
modifiers: { ctrl: true, shift: true },
};
expect(formatShortcut(shortcut, 'windows')).toBe('Ctrl+Shift+S');
});
it('formats key without modifiers', () => {
const shortcut: KeyboardShortcut = {
key: KEYS.ESCAPE,
};
expect(formatShortcut(shortcut, 'mac')).toBe('Esc');
});
it('formats special keys correctly', () => {
expect(formatShortcut({ key: KEYS.ESCAPE }, 'mac')).toBe('Esc');
expect(formatShortcut({ key: KEYS.SPACE }, 'mac')).toBe('Space');
expect(formatShortcut({ key: KEYS.TAB }, 'mac')).toBe('Tab');
expect(formatShortcut({ key: KEYS.DELETE }, 'mac')).toBe('Del');
});
it('formats arrow keys with symbols', () => {
expect(formatShortcut({ key: KEYS.ARROW_UP }, 'mac')).toBe('↑');
expect(formatShortcut({ key: KEYS.ARROW_DOWN }, 'mac')).toBe('↓');
expect(formatShortcut({ key: KEYS.ARROW_LEFT }, 'mac')).toBe('←');
expect(formatShortcut({ key: KEYS.ARROW_RIGHT }, 'mac')).toBe('→');
});
it('uppercases single letter keys', () => {
expect(formatShortcut({ key: KEYS.A }, 'mac')).toBe('A');
expect(formatShortcut({ key: KEYS.Z }, 'mac')).toBe('Z');
});
it('uses current platform when not specified', () => {
vi.stubGlobal('navigator', {
platform: 'MacIntel',
userAgent: 'Mozilla/5.0',
});
const shortcut: KeyboardShortcut = {
key: KEYS.ENTER,
modifiers: { ctrlOrCmd: true },
};
expect(formatShortcut(shortcut)).toBe('Cmd+Enter');
});
});
describe('matchesShortcut', () => {
it('matches exact key without modifiers', () => {
const shortcut: KeyboardShortcut = { key: KEYS.ESCAPE };
const event = new KeyboardEvent('keydown', { key: 'Escape' });
expect(matchesShortcut(event, shortcut)).toBe(true);
});
it('does not match different key', () => {
const shortcut: KeyboardShortcut = { key: KEYS.ESCAPE };
const event = new KeyboardEvent('keydown', { key: 'Enter' });
expect(matchesShortcut(event, shortcut)).toBe(false);
});
it('matches ctrlOrCmd with Ctrl key', () => {
const shortcut: KeyboardShortcut = {
key: KEYS.ENTER,
modifiers: { ctrlOrCmd: true },
};
const event = new KeyboardEvent('keydown', { key: 'Enter', ctrlKey: true });
expect(matchesShortcut(event, shortcut)).toBe(true);
});
it('matches ctrlOrCmd with Meta key', () => {
const shortcut: KeyboardShortcut = {
key: KEYS.ENTER,
modifiers: { ctrlOrCmd: true },
};
const event = new KeyboardEvent('keydown', { key: 'Enter', metaKey: true });
expect(matchesShortcut(event, shortcut)).toBe(true);
});
it('does not match ctrlOrCmd without modifier', () => {
const shortcut: KeyboardShortcut = {
key: KEYS.ENTER,
modifiers: { ctrlOrCmd: true },
};
const event = new KeyboardEvent('keydown', { key: 'Enter' });
expect(matchesShortcut(event, shortcut)).toBe(false);
});
it('matches Ctrl modifier', () => {
const shortcut: KeyboardShortcut = {
key: KEYS.S,
modifiers: { ctrl: true },
};
const event = new KeyboardEvent('keydown', { key: 's', ctrlKey: true });
expect(matchesShortcut(event, shortcut)).toBe(true);
});
it('matches Cmd modifier', () => {
const shortcut: KeyboardShortcut = {
key: KEYS.S,
modifiers: { cmd: true },
};
const event = new KeyboardEvent('keydown', { key: 's', metaKey: true });
expect(matchesShortcut(event, shortcut)).toBe(true);
});
it('matches Alt modifier', () => {
const shortcut: KeyboardShortcut = {
key: KEYS.A,
modifiers: { alt: true },
};
const event = new KeyboardEvent('keydown', { key: 'a', altKey: true });
expect(matchesShortcut(event, shortcut)).toBe(true);
});
it('matches Shift modifier', () => {
const shortcut: KeyboardShortcut = {
key: KEYS.A,
modifiers: { shift: true },
};
const event = new KeyboardEvent('keydown', { key: 'a', shiftKey: true });
expect(matchesShortcut(event, shortcut)).toBe(true);
});
it('matches multiple modifiers', () => {
const shortcut: KeyboardShortcut = {
key: KEYS.S,
modifiers: { ctrl: true, shift: true },
};
const event = new KeyboardEvent('keydown', {
key: 's',
ctrlKey: true,
shiftKey: true,
});
expect(matchesShortcut(event, shortcut)).toBe(true);
});
it('does not match when extra modifier is pressed', () => {
const shortcut: KeyboardShortcut = {
key: KEYS.S,
modifiers: { ctrl: true },
};
const event = new KeyboardEvent('keydown', {
key: 's',
ctrlKey: true,
shiftKey: true,
});
expect(matchesShortcut(event, shortcut)).toBe(false);
});
it('does not match when required modifier is missing', () => {
const shortcut: KeyboardShortcut = {
key: KEYS.S,
modifiers: { ctrl: true, shift: true },
};
const event = new KeyboardEvent('keydown', { key: 's', ctrlKey: true });
expect(matchesShortcut(event, shortcut)).toBe(false);
});
it('matches ctrlOrCmd with additional modifiers', () => {
const shortcut: KeyboardShortcut = {
key: KEYS.S,
modifiers: { ctrlOrCmd: true, shift: true },
};
const event = new KeyboardEvent('keydown', {
key: 's',
ctrlKey: true,
shiftKey: true,
});
expect(matchesShortcut(event, shortcut)).toBe(true);
});
it('does not match when no modifiers expected but some pressed', () => {
const shortcut: KeyboardShortcut = { key: KEYS.ESCAPE };
const event = new KeyboardEvent('keydown', { key: 'Escape', ctrlKey: true });
expect(matchesShortcut(event, shortcut)).toBe(false);
});
it('works with predefined SHORTCUTS', () => {
const event = new KeyboardEvent('keydown', { key: 'Enter', metaKey: true });
expect(matchesShortcut(event, SHORTCUTS.SUBMIT)).toBe(true);
});
});
+156
View File
@@ -0,0 +1,156 @@
/**
* Keyboard shortcut utility functions
*/
import type { KeyboardShortcut, Platform } from './constants';
/**
* Detect the current platform
*/
export function detectPlatform(): Platform {
if (typeof navigator === 'undefined') {
return 'unknown';
}
const platform = navigator.platform.toLowerCase();
const userAgent = navigator.userAgent.toLowerCase();
if (platform.includes('mac') || userAgent.includes('mac')) {
return 'mac';
}
if (platform.includes('win') || userAgent.includes('win')) {
return 'windows';
}
if (platform.includes('linux') || userAgent.includes('linux')) {
return 'linux';
}
return 'unknown';
}
/**
* Check if the current platform is Mac
*/
export function isMac(): boolean {
return detectPlatform() === 'mac';
}
/**
* Format a keyboard shortcut for display based on platform
* @param shortcut - The keyboard shortcut definition
* @param platform - Optional platform override (defaults to current platform)
* @returns Formatted string like "Cmd+Enter" or "Ctrl+Enter"
*/
export function formatShortcut(shortcut: KeyboardShortcut, platform?: Platform): string {
const currentPlatform = platform ?? detectPlatform();
const parts: string[] = [];
if (shortcut.modifiers) {
// Handle ctrlOrCmd - use Cmd on Mac, Ctrl elsewhere
if (shortcut.modifiers.ctrlOrCmd) {
parts.push(currentPlatform === 'mac' ? 'Cmd' : 'Ctrl');
} else {
// Handle individual modifiers
if (shortcut.modifiers.ctrl) {
parts.push('Ctrl');
}
if (shortcut.modifiers.cmd && currentPlatform === 'mac') {
parts.push('Cmd');
}
if (shortcut.modifiers.alt) {
parts.push(currentPlatform === 'mac' ? 'Option' : 'Alt');
}
if (shortcut.modifiers.shift) {
parts.push('Shift');
}
}
}
// Format the key
const key = formatKey(shortcut.key);
parts.push(key);
return parts.join('+');
}
/**
* Format a key for display
*/
function formatKey(key: string): string {
// Special keys
const specialKeys: Record<string, string> = {
Enter: 'Enter',
Escape: 'Esc',
' ': 'Space',
Tab: 'Tab',
Backspace: 'Backspace',
Delete: 'Del',
ArrowUp: '↑',
ArrowDown: '↓',
ArrowLeft: '←',
ArrowRight: '→',
};
if (key in specialKeys) {
return specialKeys[key];
}
// Uppercase single letters
if (key.length === 1) {
return key.toUpperCase();
}
return key;
}
/**
* Check if a keyboard event matches a shortcut definition
* @param event - The keyboard event
* @param shortcut - The shortcut definition to match against
* @returns true if the event matches the shortcut
*/
export function matchesShortcut(
event: KeyboardEvent | React.KeyboardEvent,
shortcut: KeyboardShortcut
): boolean {
// Check if the key matches
if (event.key !== shortcut.key) {
return false;
}
// If no modifiers specified, ensure no modifiers are pressed
if (!shortcut.modifiers) {
return !event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey;
}
const { ctrl, cmd, alt, shift, ctrlOrCmd } = shortcut.modifiers;
// Handle ctrlOrCmd
if (ctrlOrCmd) {
const hasCorrectModifier = event.metaKey || event.ctrlKey;
if (!hasCorrectModifier) return false;
// Ensure other modifiers match
if (alt && !event.altKey) return false;
if (!alt && event.altKey) return false;
if (shift && !event.shiftKey) return false;
if (!shift && event.shiftKey) return false;
return true;
}
// Check individual modifiers
if (ctrl && !event.ctrlKey) return false;
if (!ctrl && event.ctrlKey) return false;
if (cmd && !event.metaKey) return false;
if (!cmd && event.metaKey) return false;
if (alt && !event.altKey) return false;
if (!alt && event.altKey) return false;
if (shift && !event.shiftKey) return false;
if (!shift && event.shiftKey) return false;
return true;
}
+1
View File
@@ -66,6 +66,7 @@
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.1",
"happy-dom": "^20.1.0",
"tailwindcss": "^4",
"typescript": "^5",
"vitest": "^1.6.0"
+1 -1
View File
@@ -2,6 +2,6 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
environment: 'happy-dom',
},
});