KBD

PreviousNext

Keyboard key component for displaying keyboard shortcuts.

Docs
opticscomponent

Preview

Loading preview…
registry/optics/kbd.jsx
"use client";

import { cn } from "@/registry/optics/lib/utils";
import { cva } from "class-variance-authority";
import { useHotkeys } from "react-hotkeys-hook";
import {
	useCallback,
	useRef,
	useState,
	useMemo,
	isValidElement,
	useEffect,
} from "react";

/**
 * Extrae texto de children de React
 */
function extractTextFromChildren(children) {
	if (!children) return "";
	if (typeof children === "string") return children;
	if (typeof children === "number") return String(children);
	if (Array.isArray(children)) {
		return children
			.map((child) => extractTextFromChildren(child))
			.filter(Boolean)
			.join("");
	}
	if (isValidElement(children)) {
		return extractTextFromChildren(children.props.children);
	}
	if (typeof children === "object" && children?.props?.children) {
		return extractTextFromChildren(children.props.children);
	}
	return "";
}

/**
 * Normaliza un string de hotkey al formato que react-hotkeys-hook espera
 */
function normalizeHotkeyString(str) {
	if (!str) return null;
	let normalized = str.trim().toLowerCase();
	normalized = normalized
		.replace(/|cmd|command|meta/gi, "mod")
		.replace(/ctrl|control/gi, "ctrl")
		.replace(/alt|option/gi, "alt")
		.replace(/shift/gi, "shift")
		.replace(/[\s,\-]+/g, "+")
		.replace(/\++/g, "+")
		.replace(/^\+|\+$/g, "");
	return normalized || null;
}

/**
 * Lista de modificadores conocidos
 */
const MODIFIER_KEYS = new Set([
	"mod",
	"ctrl",
	"alt",
	"shift",
	"meta",
	"cmd",
	"command",
]);

/**
 * Verifica si una tecla es un modificador
 */
function isModifierKey(key) {
	return MODIFIER_KEYS.has(key.toLowerCase());
}

const kbdVariants = cva(
	"select-none outline-hidden transition-all duration-150",
	{
		variants: {
			variant: {
				default:
					"transform-gpu cursor-pointer rounded-lg border border-neutral-500/50 bg-neutral-300 shadow-[-10px_0px_15px_rgba(255,255,255,1),3px_10px_12.5px_rgba(0,0,0,0.1)] active:shadow-none dark:border-neutral-700 dark:bg-neutral-900 dark:shadow-[-10px_0px_15px_rgba(0,0,0,0.3),3px_10px_12.5px_rgba(255,255,255,0.05)]",
				legacy:
					"bg-muted text-muted-foreground pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium [&_svg:not([class*='size-'])]:size-3 [[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10",
			},
		},
		defaultVariants: {
			variant: "default",
		},
	},
);

const kbdInnerVariants = cva(
	"flex items-center justify-center size-full transform-gpu rounded-[calc(var(--radius)-1px)] transition-all duration-150",
	{
		variants: {
			variant: {
				default:
					"-translate-y-1 z-10 bg-neutral-100 px-3 py-1 text-neutral-500 shadow-[inset_0px_2px_4px_rgba(255,255,255,0.8)] active:translate-y-0 active:shadow-transparent dark:bg-neutral-800 dark:text-neutral-300 dark:shadow-[inset_0px_2px_4px_rgba(255,255,255,0.05)]",
				legacy: "",
			},
		},
		defaultVariants: {
			variant: "default",
		},
	},
);

/**
 * Verifica si las teclas del evento coinciden con el hotkey
 */
function matchesHotkey(hotkey, event) {
	if (!hotkey || !event) return false;

	const parts = hotkey.split("+");
	const lastPart = parts[parts.length - 1].toLowerCase();
	const isOnlyModifier = parts.length === 1 && isModifierKey(lastPart);

	if (isOnlyModifier) {
		// Si es solo un modificador, verificar solo ese modificador
		if (lastPart === "mod") {
			return event.metaKey || event.ctrlKey;
		}
		if (lastPart === "ctrl") {
			return event.ctrlKey;
		}
		if (lastPart === "alt") {
			return event.altKey;
		}
		if (lastPart === "shift") {
			return event.shiftKey;
		}
		return false;
	}

	// Hotkey con modificadores + tecla principal
	const modifiers = parts.slice(0, -1);
	const key = lastPart;

	// Verificar modificadores requeridos
	const requiresMod = modifiers.includes("mod");
	const requiresCtrl = modifiers.includes("ctrl");
	const requiresAlt = modifiers.includes("alt");
	const requiresShift = modifiers.includes("shift");

	// Verificar que los modificadores requeridos estén presionados
	const hasMod = requiresMod ? event.metaKey || event.ctrlKey : true;
	const hasCtrl = requiresCtrl ? event.ctrlKey : true;
	const hasAlt = requiresAlt ? event.altKey : true;
	const hasShift = requiresShift ? event.shiftKey : true;

	// Verificar que no haya modificadores extra no requeridos
	const noExtraMods =
		(!requiresMod && !requiresCtrl ? !event.ctrlKey && !event.metaKey : true) &&
		(!requiresAlt ? !event.altKey : true) &&
		(!requiresShift ? !event.shiftKey : true);

	// Verificar la tecla principal
	const eventKey = event.key?.toLowerCase() || "";
	const eventCode = event.code?.toLowerCase().replace("key", "") || "";
	const keyMatch = eventKey === key || eventCode === key;

	// Para combinaciones, todos los modificadores y la tecla principal deben coincidir
	return hasMod && hasCtrl && hasAlt && hasShift && noExtraMods && keyMatch;
}

function Kbd({
	className = "",
	variant = "default",
	useHotkey = false,
	animate = true,
	onHotkeyPress = undefined,
	hotkey: hotkeyProp = undefined,
	...props
}) {
	const [isPressed, setIsPressed] = useState(false);
	const kbdRef = useRef(null);

	// Parsear hotkey
	const hotkey = useMemo(() => {
		if (!useHotkey) return null;
		if (hotkeyProp) return normalizeHotkeyString(hotkeyProp);
		const text = extractTextFromChildren(props.children);
		return text ? normalizeHotkeyString(text) : null;
	}, [useHotkey, hotkeyProp, props.children]);

	// Detectar si es compuesto
	const isCompound = useMemo(() => {
		if (hotkey?.includes("+")) return true;
		if (
			hotkeyProp &&
			typeof hotkeyProp === "string" &&
			hotkeyProp.includes("+")
		)
			return true;
		const text = extractTextFromChildren(props.children);
		return (
			text?.includes("+") ||
			(Array.isArray(props.children) && props.children.length > 1)
		);
	}, [hotkey, hotkeyProp, props.children]);

	// Callback para el hotkey
	const handleHotkeyPress = useCallback(
		(event) => {
			event?.preventDefault?.();
			onHotkeyPress?.(event);
		},
		[onHotkeyPress],
	);

	// Registrar hotkey con react-hotkeys-hook
	useHotkeys(
		hotkey || "",
		handleHotkeyPress,
		{
			enabled: useHotkey && !!hotkey,
			preventDefault: false,
		},
		[useHotkey, hotkey, handleHotkeyPress],
	);

	// Manejar animación con keydown/keyup
	useEffect(() => {
		if (!useHotkey || !hotkey || !animate) return;

		const parts = hotkey.split("+");
		const lastPart = parts[parts.length - 1].toLowerCase();
		const isOnlyModifier = parts.length === 1 && isModifierKey(lastPart);
		const mainKey = isOnlyModifier ? null : lastPart;
		const modifierKeys = isOnlyModifier ? parts : parts.slice(0, -1);

		const handleKeyDown = (event) => {
			// Verificar si la combinación completa está activa en este momento
			// El evento tiene el estado actual de todas las teclas presionadas
			// Esto funciona incluso si se presiona primero el modificador y luego la tecla principal
			if (matchesHotkey(hotkey, event)) {
				setIsPressed(true);
			}
		};

		const handleKeyUp = (event) => {
			const eventKey = event.key?.toLowerCase() || "";
			const eventCode = event.code?.toLowerCase() || "";

			if (isOnlyModifier) {
				// Si es solo un modificador, verificar si se soltó ese modificador
				let isModifierUp = false;
				if (lastPart === "mod") {
					isModifierUp =
						eventKey === "meta" ||
						eventKey === "control" ||
						eventCode.includes("meta") ||
						eventCode.includes("control") ||
						(!event.metaKey && !event.ctrlKey);
				} else if (lastPart === "ctrl") {
					isModifierUp =
						eventKey === "control" ||
						eventCode.includes("control") ||
						!event.ctrlKey;
				} else if (lastPart === "alt") {
					isModifierUp =
						eventKey === "alt" || eventCode.includes("alt") || !event.altKey;
				} else if (lastPart === "shift") {
					isModifierUp =
						eventKey === "shift" ||
						eventCode.includes("shift") ||
						!event.shiftKey;
				}

				if (isModifierUp) {
					setIsPressed(false);
				}
			} else {
				// Hotkey con tecla principal
				const eventCodeMain = eventCode.replace("key", "");

				// Verificar si se soltó la tecla principal
				const isMainKey = eventKey === mainKey || eventCodeMain === mainKey;

				// Verificar si se soltó un modificador requerido
				let isModifierUp = false;
				if (modifierKeys.includes("mod")) {
					isModifierUp =
						eventKey === "meta" ||
						eventKey === "control" ||
						eventCode.includes("meta") ||
						eventCode.includes("control") ||
						(!event.metaKey && !event.ctrlKey);
				}
				if (modifierKeys.includes("ctrl") && !isModifierUp) {
					isModifierUp =
						eventKey === "control" ||
						eventCode.includes("control") ||
						!event.ctrlKey;
				}
				if (modifierKeys.includes("alt") && !isModifierUp) {
					isModifierUp =
						eventKey === "alt" || eventCode.includes("alt") || !event.altKey;
				}
				if (modifierKeys.includes("shift") && !isModifierUp) {
					isModifierUp =
						eventKey === "shift" ||
						eventCode.includes("shift") ||
						!event.shiftKey;
				}

				// Si se soltó la tecla principal O cualquier modificador, desactivar
				if (isMainKey || isModifierUp) {
					setIsPressed(false);
				}
			}
		};

		window.addEventListener("keydown", handleKeyDown, true);
		window.addEventListener("keyup", handleKeyUp, true);

		return () => {
			window.removeEventListener("keydown", handleKeyDown, true);
			window.removeEventListener("keyup", handleKeyUp, true);
			setIsPressed(false);
		};
	}, [useHotkey, hotkey, animate]);

	if (variant === "legacy") {
		return (
			<kbd
				ref={kbdRef}
				data-slot="kbd"
				className={cn(kbdVariants({ variant }), className)}
				{...props}
			>
				{props.children}
			</kbd>
		);
	}

	return (
		<kbd
			ref={kbdRef}
			data-slot="kbd"
			className={cn(
				!isCompound && "aspect-square",
				kbdVariants({ variant }),
				isPressed && "shadow-none",
				className,
			)}
			{...props}
		>
			<span
				className={cn(
					kbdInnerVariants({ variant }),
					isPressed && "translate-y-0 shadow-transparent",
				)}
			>
				<span className="block text-center align-center text-xs">
					{props.children}
				</span>
			</span>
		</kbd>
	);
}

function KbdGroup({ className, ...props }) {
	return (
		<kbd
			data-slot="kbd-group"
			className={cn("inline-flex items-center gap-1", className)}
			{...props}
		/>
	);
}

export { Kbd, KbdGroup };

Installation

npx shadcn@latest add @optics/kbd

Usage

import { Kbd } from "@/components/kbd"
<Kbd />