"use client"; import * as React from "react"; import { useEffect, useCallback, useRef } from "react"; import { Button, buttonVariants } from "./button"; import { cn } from "@/lib/utils"; import type { VariantProps } from "class-variance-authority"; export interface HotkeyConfig { /** The key to trigger the hotkey (e.g., "Enter", "s", "n") */ key: string; /** Whether the Cmd/Ctrl modifier is required */ cmdCtrl?: boolean; /** Whether the Shift modifier is required */ shift?: boolean; /** Whether the Alt/Option modifier is required */ alt?: boolean; /** Custom display label for the hotkey (overrides auto-generated label) */ label?: string; } export interface HotkeyButtonProps extends React.ComponentProps<"button">, VariantProps { /** Hotkey configuration - can be a simple key string or a full config object */ hotkey?: string | HotkeyConfig; /** Whether to show the hotkey indicator badge */ showHotkeyIndicator?: boolean; /** Whether the hotkey listener is active (registers keyboard listener). Set to false if hotkey is already handled elsewhere. */ hotkeyActive?: boolean; /** Optional scope element ref - hotkey will only work when this element is visible */ scopeRef?: React.RefObject; /** Callback when hotkey is triggered */ onHotkeyTrigger?: () => void; /** Whether to use the Slot component for composition */ asChild?: boolean; } /** * Get the modifier key symbol based on platform */ function getModifierSymbol(isMac: boolean): string { return isMac ? "⌘" : "Ctrl"; } /** * Parse hotkey config into a normalized format */ function parseHotkeyConfig(hotkey: string | HotkeyConfig): HotkeyConfig { if (typeof hotkey === "string") { return { key: hotkey }; } return hotkey; } /** * Generate the display label for the hotkey */ function getHotkeyDisplayLabel( config: HotkeyConfig, isMac: boolean ): React.ReactNode { if (config.label) { return config.label; } const parts: React.ReactNode[] = []; if (config.cmdCtrl) { parts.push( {getModifierSymbol(isMac)} ); } if (config.shift) { parts.push( ); } if (config.alt) { parts.push( {isMac ? "⌥" : "Alt"} ); } // Convert key to display format let keyDisplay = config.key; switch (config.key.toLowerCase()) { case "enter": keyDisplay = "↵"; break; case "escape": case "esc": keyDisplay = "Esc"; break; case "arrowup": keyDisplay = "↑"; break; case "arrowdown": keyDisplay = "↓"; break; case "arrowleft": keyDisplay = "←"; break; case "arrowright": keyDisplay = "→"; break; case "backspace": keyDisplay = "⌫"; break; case "delete": keyDisplay = "⌦"; break; case "tab": keyDisplay = "⇥"; break; case " ": keyDisplay = "Space"; break; default: // Capitalize single letters if (config.key.length === 1) { keyDisplay = config.key.toUpperCase(); } } parts.push( {keyDisplay} ); return {parts}; } /** * Check if an element is a form input */ function isInputElement(element: Element | null): boolean { if (!element) return false; const tagName = element.tagName.toLowerCase(); if (tagName === "input" || tagName === "textarea" || tagName === "select") { return true; } if (element.getAttribute("contenteditable") === "true") { return true; } const role = element.getAttribute("role"); if (role === "textbox" || role === "searchbox" || role === "combobox") { return true; } return false; } /** * A button component that supports keyboard hotkeys * * Features: * - Automatic hotkey listening when mounted * - Visual hotkey indicator badge * - Support for modifier keys (Cmd/Ctrl, Shift, Alt) * - Respects focus context (doesn't trigger when typing in inputs) * - Scoped activation via scopeRef */ export function HotkeyButton({ hotkey, showHotkeyIndicator = true, hotkeyActive = true, scopeRef, onHotkeyTrigger, onClick, disabled, children, className, variant, size, asChild = false, ...props }: HotkeyButtonProps) { const buttonRef = useRef(null); const [isMac, setIsMac] = React.useState(true); // Detect platform on mount useEffect(() => { setIsMac(navigator.platform.toLowerCase().includes("mac")); }, []); const config = hotkey ? parseHotkeyConfig(hotkey) : null; const handleKeyDown = useCallback( (event: KeyboardEvent) => { if (!config || !hotkeyActive || disabled) return; // Don't trigger when typing in inputs (unless explicitly scoped or using cmdCtrl modifier) // cmdCtrl shortcuts like Cmd+Enter should work even in inputs as they're intentional submit actions if ( !scopeRef && !config.cmdCtrl && isInputElement(document.activeElement) ) { return; } // Check modifier keys const cmdCtrlPressed = event.metaKey || event.ctrlKey; const shiftPressed = event.shiftKey; const altPressed = event.altKey; // Validate modifier requirements if (config.cmdCtrl && !cmdCtrlPressed) return; if (!config.cmdCtrl && cmdCtrlPressed) return; if (config.shift && !shiftPressed) return; if (!config.shift && shiftPressed) return; if (config.alt && !altPressed) return; if (!config.alt && altPressed) return; // Check if the key matches if (event.key.toLowerCase() !== config.key.toLowerCase()) return; // If scoped, check that the scope element is visible if (scopeRef && scopeRef.current) { const scopeEl = scopeRef.current; const isVisible = scopeEl.offsetParent !== null || getComputedStyle(scopeEl).display !== "none"; if (!isVisible) return; } event.preventDefault(); event.stopPropagation(); // Trigger the click handler or custom onHotkeyTrigger if (onHotkeyTrigger) { onHotkeyTrigger(); } else if (onClick) { onClick(event as unknown as React.MouseEvent); } else if (buttonRef.current) { buttonRef.current.click(); } }, [config, hotkeyActive, disabled, scopeRef, onHotkeyTrigger, onClick] ); // Set up global key listener useEffect(() => { if (!config || !hotkeyActive) return; window.addEventListener("keydown", handleKeyDown); return () => { window.removeEventListener("keydown", handleKeyDown); }; }, [config, hotkeyActive, handleKeyDown]); // Render the hotkey indicator const hotkeyIndicator = config && showHotkeyIndicator ? ( {getHotkeyDisplayLabel(config, isMac)} ) : null; return ( ); } export { getHotkeyDisplayLabel, parseHotkeyConfig };