diff --git a/apps/web/components/keyboard-shortcut-tooltip.tsx b/apps/web/components/keyboard-shortcut-tooltip.tsx new file mode 100644 index 0000000000..5637b4835e --- /dev/null +++ b/apps/web/components/keyboard-shortcut-tooltip.tsx @@ -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 + * + * Submit + * + * ``` + */ +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 ( + + {children} + + + {description && {description}} + + {parts.map((part, index) => ( + {part} + ))} + + + + + ); +} + +/** + * Simple text-only version that just shows the formatted shortcut + * Useful for inline display without tooltip + * + * @example + * ```tsx + * + * // Renders: Cmd+Enter (on Mac) or Ctrl+Enter (on Windows/Linux) + * ``` + */ +export function KeyboardShortcutText({ shortcut }: { shortcut: KeyboardShortcut }) { + const platform = detectPlatform(); + return {formatShortcut(shortcut, platform)}; +} + +/** + * Kbd group version for displaying shortcuts with proper styling + * + * @example + * ```tsx + * + * ``` + */ +export function KeyboardShortcutKbd({ shortcut }: { shortcut: KeyboardShortcut }) { + const platform = detectPlatform(); + const formattedShortcut = formatShortcut(shortcut, platform); + const parts = formattedShortcut.split('+'); + + return ( + + {parts.map((part, index) => ( + {part} + ))} + + ); +} + diff --git a/apps/web/components/task-create-dialog.tsx b/apps/web/components/task-create-dialog.tsx index 2c94bbe4c9..623b31d914 100644 --- a/apps/web/components/task-create-dialog.tsx +++ b/apps/web/components/task-create-dialog.tsx @@ -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 - - {submitLabel} - + + + {submitLabel} + + diff --git a/apps/web/components/task/task-chat-panel.tsx b/apps/web/components/task/task-chat-panel.tsx index 381b4c33cf..f9c3386dc2 100644 --- a/apps/web/components/task/task-chat-panel.tsx +++ b/apps/web/components/task/task-chat-panel.tsx @@ -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 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 Add attachments - Submit + + Submit + diff --git a/apps/web/hooks/use-keyboard-shortcut.ts b/apps/web/hooks/use-keyboard-shortcut.ts new file mode 100644 index 0000000000..8b38d81356 --- /dev/null +++ b/apps/web/hooks/use-keyboard-shortcut.ts @@ -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 ; + * ``` + */ +export function useKeyboardShortcutHandler( + shortcut: KeyboardShortcut, + callback: (event: React.KeyboardEvent) => void, + options: Omit = {} +) { + 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] + ); +} + diff --git a/apps/web/lib/keyboard/constants.test.ts b/apps/web/lib/keyboard/constants.test.ts new file mode 100644 index 0000000000..9b7cf83c9a --- /dev/null +++ b/apps/web/lib/keyboard/constants.test.ts @@ -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); + }); +}); + diff --git a/apps/web/lib/keyboard/constants.ts b/apps/web/lib/keyboard/constants.ts new file mode 100644 index 0000000000..4ce96d9b4f --- /dev/null +++ b/apps/web/lib/keyboard/constants.ts @@ -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; + diff --git a/apps/web/lib/keyboard/utils.test.ts b/apps/web/lib/keyboard/utils.test.ts new file mode 100644 index 0000000000..d994903028 --- /dev/null +++ b/apps/web/lib/keyboard/utils.test.ts @@ -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); + }); +}); + diff --git a/apps/web/lib/keyboard/utils.ts b/apps/web/lib/keyboard/utils.ts new file mode 100644 index 0000000000..f00c44b33a --- /dev/null +++ b/apps/web/lib/keyboard/utils.ts @@ -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 = { + 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; +} + diff --git a/apps/web/package.json b/apps/web/package.json index 4cae4678b2..fb19e4315a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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" diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index 4ac6027d57..a9f5456ffd 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -2,6 +2,6 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { - environment: 'node', + environment: 'happy-dom', }, });