feat: cmd+enter in task chat
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -2,6 +2,6 @@ import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
environment: 'happy-dom',
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user