mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 08:53:36 +00:00
refactor: restructure project to monorepo with apps directory
This commit is contained in:
305
apps/app/src/components/ui/hotkey-button.tsx
Normal file
305
apps/app/src/components/ui/hotkey-button.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
"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 };
|
||||
Reference in New Issue
Block a user