mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 20:23:36 +00:00
306 lines
7.9 KiB
TypeScript
306 lines
7.9 KiB
TypeScript
"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<typeof buttonVariants> {
|
|
/** 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<HTMLElement | null>;
|
|
/** 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(
|
|
<span key="mod" className="leading-none flex items-center justify-center">
|
|
{getModifierSymbol(isMac)}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
if (config.shift) {
|
|
parts.push(
|
|
<span
|
|
key="shift"
|
|
className="leading-none flex items-center justify-center"
|
|
>
|
|
⇧
|
|
</span>
|
|
);
|
|
}
|
|
|
|
if (config.alt) {
|
|
parts.push(
|
|
<span key="alt" className="leading-none flex items-center justify-center">
|
|
{isMac ? "⌥" : "Alt"}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// 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(
|
|
<span key="key" className="leading-none flex items-center justify-center">
|
|
{keyDisplay}
|
|
</span>
|
|
);
|
|
|
|
return <span className="inline-flex items-center gap-1.5">{parts}</span>;
|
|
}
|
|
|
|
/**
|
|
* 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<HTMLButtonElement>(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<HTMLButtonElement>);
|
|
} 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 ? (
|
|
<span
|
|
className="px-2 py-0.5 text-[10px] font-mono rounded bg-primary-foreground/10 border border-primary-foreground/20 inline-flex items-center gap-1.5"
|
|
data-testid="hotkey-indicator"
|
|
>
|
|
{getHotkeyDisplayLabel(config, isMac)}
|
|
</span>
|
|
) : null;
|
|
|
|
return (
|
|
<Button
|
|
ref={buttonRef}
|
|
variant={variant}
|
|
size={size}
|
|
disabled={disabled}
|
|
onClick={onClick}
|
|
className={cn(className)}
|
|
asChild={asChild}
|
|
{...props}
|
|
>
|
|
{typeof children === "string" ? (
|
|
<>
|
|
{children}
|
|
{hotkeyIndicator}
|
|
</>
|
|
) : (
|
|
<>
|
|
{children}
|
|
{hotkeyIndicator}
|
|
</>
|
|
)}
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
export { getHotkeyDisplayLabel, parseHotkeyConfig };
|