본문으로 건너뛰기
컴포넌트

단축키 입력

누른 키 조합을 그대로 기록해 단축키로 보여 줘요.

  • #arc
  • #component
  • #input
  • #keyboard
  • #shortcut
  • #hotkey
  • #form

설치

이 명령 한 줄이면 필요한 파일과 패키지가 함께 설치돼요.

터미널bash
npx shadcn@latest add https://dsui.vercel.app/r/shortcut-recorder.json

shortcut-recorder.module.csscss
/* ---------------------------------------------- Kbd ---------------------------------------------- */

/* A key cap: a one pixel edge with a slightly heavier bottom, so it reads as a key without a shadow. Held, it drops a pixel and takes the accent. */
.kbd { display: inline-grid; min-width: 26px; height: 26px; box-sizing: border-box; place-items: center; padding: 0 7px 1px; border: 1px solid var(--border-strong); border-bottom-width: 2px; border-radius: 8px; background: var(--surface-raised); color: var(--foreground); font-family: var(--font-body); font-size: var(--text-xs); font-weight: 500; line-height: 1; letter-spacing: 0; font-variant-numeric: tabular-nums; white-space: nowrap; transition: transform var(--duration-spring) var(--ease-spring), border-color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), border-bottom-width var(--duration-fast) var(--ease-standard); }
.kbd[data-size="sm"] { min-width: 22px; height: 22px; padding: 0 5px 1px; border-radius: 7px; font-size: var(--text-xs); }
.kbd[data-pressed] { transform: translateY(1px); border-color: transparent; border-bottom-width: 1px; background: var(--accent-subtle); color: var(--accent-strong); transition-duration: var(--duration-micro); }
.kbd[data-tone="warning"] { border-color: transparent; background: color-mix(in oklab, var(--warning) 12%, var(--surface-raised)); color: var(--foreground); }
.keys { display: inline-flex; flex: none; align-items: center; gap: 4px; }

/* ---------------------------------------------- Recorder ---------------------------------------------- */

.field { display: grid; min-width: 0; font-family: var(--font-body); letter-spacing: var(--tracking-body); }
.label { justify-self: start; margin-bottom: var(--space-2); color: var(--foreground); font-size: var(--text-sm); font-weight: var(--label-weight); line-height: var(--leading-body); }

.control { position: relative; display: flex; min-width: 0; height: var(--control-height-md); box-sizing: border-box; align-items: center; border: 1px solid var(--border-strong); border-radius: var(--radius-control); background: var(--surface); transition: border-color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard); }
@media (hover: hover) and (pointer: fine) { .control[data-state="idle"]:hover { border-color: var(--foreground); } }
/* Focus and recording fill the control with the accent, like Input; the keycaps stay light so the recorded keys read on it. */
.control:has(.recorder:focus-visible), .control[data-state="recording"] { border-color: var(--accent); background: var(--accent); }
.control:has(.recorder:focus-visible) .recorder, .control[data-state="recording"] .recorder { color: var(--accent-foreground); }
.control:has(.recorder:focus-visible) .placeholder, .control[data-state="recording"] .placeholder, .placeholder[data-recording] { color: var(--accent-foreground-muted); }
.control:has(.recorder:focus-visible) .dot, .control[data-state="recording"] .dot { background: var(--accent-foreground); }
.control:has(.recorder:focus-visible) .action, .control[data-state="recording"] .action { color: var(--accent-foreground); }
@media (hover: hover) and (pointer: fine) { .control:has(.recorder:focus-visible) .action:hover, .control[data-state="recording"] .action:hover { background: color-mix(in oklab, var(--accent-foreground) 16%, transparent); color: var(--accent-foreground); } }
.control[data-state="conflict"] { border-color: var(--warning); }
.field[data-disabled] .control { opacity: .5; background: var(--surface-muted); }

.recorder { display: flex; min-width: 0; height: 100%; flex: 1; align-items: center; padding: 0 6px 0 9px; border: 0; border-radius: var(--radius-control); background: transparent; color: var(--foreground); cursor: pointer; font: inherit; text-align: left; -webkit-tap-highlight-color: transparent; touch-action: manipulation; outline: none; }
.recorder:disabled { cursor: not-allowed; }
/* The global keyboard outline is !important, so it is hidden through its variable; the filled control marks the focus. */
.recorder:focus-visible { --focus-outline: transparent; }
.row { position: relative; display: flex; min-width: 0; align-items: center; gap: 4px; }
.chipSlot { display: inline-flex; }
.placeholder { padding-left: 3px; color: var(--text-muted); font-size: var(--text-sm); white-space: nowrap; }
.placeholder[data-recording] { color: var(--text-secondary); }
/* A recording light: steady in reduced motion, a slow breath otherwise. */
.dot { width: 7px; height: 7px; flex: none; margin: 0 6px 0 3px; border-radius: 50%; background: var(--accent); animation: breathe 1.4s var(--ease-in-out) infinite; }
.dot[data-reduced] { animation: none; }
@keyframes breathe { 50% { opacity: .35; } }

.actions { display: flex; flex: none; align-items: center; gap: 2px; padding-right: 4px; }
.action { display: grid; width: 32px; height: 32px; place-items: center; padding: 0; border: 0; border-radius: calc(var(--radius-control) - 5px); background: transparent; color: var(--text-muted); cursor: pointer; -webkit-tap-highlight-color: transparent; transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard); }
@media (hover: hover) and (pointer: fine) { .action:hover { background: var(--surface-muted); color: var(--foreground); } }
.action:active, .action:focus-visible { background: var(--surface-muted); color: var(--foreground); outline: none; }
.control:has(.recorder:focus-visible) .action:active, .control[data-state="recording"] .action:active, .control:has(.recorder:focus-visible) .action:focus-visible, .control[data-state="recording"] .action:focus-visible { background: color-mix(in oklab, var(--accent-foreground) 16%, transparent); color: var(--accent-foreground); }

.messageSlot { position: relative; overflow: hidden; }
.message { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 2px var(--space-3); padding-top: var(--space-2); font-size: var(--text-xs); line-height: var(--leading-body); }
.message[data-tone="hint"] { color: var(--text-muted); }
.message[data-tone="warning"] { color: var(--text-secondary); }
.message[data-tone="warning"] .messageText::before { content: ""; display: inline-block; width: 6px; height: 6px; margin: 0 7px 1px 1px; border-radius: 50%; background: var(--warning); vertical-align: middle; }
.strong { color: var(--foreground); font-weight: 500; }
.messageActions { display: inline-flex; gap: var(--space-3); }
.link { padding: 0; border: 0; background: none; color: var(--foreground); cursor: pointer; font: inherit; font-weight: 500; text-decoration: underline; text-decoration-color: var(--border-strong); text-underline-offset: 3px; transition: text-decoration-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard); }
.link:focus-visible { text-decoration-color: currentColor; outline: none; }
.link[data-quiet] { color: var(--text-muted); font-weight: 400; text-decoration: none; }
@media (hover: hover) and (pointer: fine) { .link:hover { text-decoration-color: currentColor; } .link[data-quiet]:hover { color: var(--foreground); } }

/* ---------------------------------------------- Cheatsheet ---------------------------------------------- */

.list { container-type: inline-size; display: grid; min-width: 0; gap: var(--space-3); font-family: var(--font-body); letter-spacing: var(--tracking-body); }
/* Focus fills the row with the accent like Input; the icon, count and clear button switch to the accent foreground. */
.searchRow { display: flex; height: var(--control-height-sm); box-sizing: border-box; align-items: center; gap: var(--space-2); padding: 0 6px 0 12px; border: 1px solid var(--border-strong); border-radius: var(--radius-control); background: var(--surface); transition: border-color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard); }
@media (hover: hover) and (pointer: fine) { .searchRow:hover:not(:has(.search:focus-visible)) { border-color: var(--foreground); } }
.searchRow:has(.search:focus-visible) { border-color: var(--accent); background: var(--accent); }
.searchRow:has(.search:focus-visible) .search { color: var(--accent-foreground); caret-color: var(--accent-foreground); }
.searchRow:has(.search:focus-visible) .search::placeholder { color: var(--accent-foreground-muted); }
.searchRow:has(.search:focus-visible) .searchIcon { color: var(--accent-foreground); }
.searchRow:has(.search:focus-visible) .count { color: var(--accent-foreground-muted); }
.searchRow:has(.search:focus-visible) .clear { background: color-mix(in oklab, var(--accent-foreground) 16%, transparent); color: var(--accent-foreground); }
.searchIcon { flex: none; color: var(--text-muted); }
.search { min-width: 0; height: 100%; flex: 1; padding: 0; border: 0; background: transparent; color: var(--foreground); font: inherit; font-size: var(--text-sm); outline: none; -webkit-appearance: none; appearance: none; }
.search::placeholder { color: var(--text-muted); }
.search::-webkit-search-cancel-button { display: none; }
.count { flex: none; color: var(--text-muted); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }
.clear { display: grid; width: 24px; height: 24px; flex: none; place-items: center; padding: 0; border: 0; border-radius: var(--radius-pill); background: color-mix(in oklab, var(--foreground) 8%, transparent); color: var(--text-secondary); cursor: pointer; }
.clear:focus-visible { background: color-mix(in oklab, var(--foreground) 14%, transparent); color: var(--foreground); outline: none; }

.groups { display: grid; align-items: start; gap: 0 var(--space-6); }
@container (min-width: 520px) { .groups { grid-template-columns: 1fr 1fr; } }
.group { overflow: hidden; margin-inline: -8px; padding-inline: 8px; }
.groupLabel { margin: 0; padding: var(--space-3) 0 var(--space-1); color: var(--text-muted); font-size: var(--text-xs); font-weight: 400; }
.rows { display: grid; margin: 0; padding: 0; list-style: none; }
.item { overflow: hidden; margin-inline: -8px; }
/* Rows fade when a held modifier is not in them, and light when their whole combination is down. */
.itemInner { display: flex; min-height: 36px; align-items: center; justify-content: space-between; gap: var(--space-3); padding: 0 8px; border-radius: 10px; transition: opacity var(--duration-standard) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard); }
.item + .item .itemInner { box-shadow: inset 0 1px 0 var(--border-subtle); }
.item[data-dim] .itemInner { opacity: .38; }
.item[data-match] .itemInner { background: var(--accent-subtle); box-shadow: none; }
.item[data-match] + .item .itemInner { box-shadow: none; }
.itemLabel { min-width: 0; overflow: hidden; color: var(--text-secondary); font-size: var(--text-sm); text-overflow: ellipsis; white-space: nowrap; transition: color var(--duration-fast) var(--ease-standard); }
.item[data-match] .itemLabel { color: var(--foreground); }
.empty { margin: 0; padding: var(--space-4) 0; color: var(--text-muted); font-size: var(--text-sm); }

.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }

@media (prefers-reduced-motion: reduce) {
  .kbd, .control, .itemInner { transition-duration: 0ms; }
  .kbd[data-pressed] { transform: none; }
  .dot { animation: none; }
}
shortcut-recorder.tsxtsx
"use client";

import { forwardRef, useCallback, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from "react";
import type { HTMLAttributes, KeyboardEvent as ReactKeyboardEvent, ReactNode } from "react";
import { AnimatePresence, motion } from "motion/react";
import { useReducedMotion } from "../lib/use-reduced-motion";
import type { Variants } from "motion/react";
import { motionTokens } from "../lib/motion-tokens";
import { playUiSound } from "../sound/play";
import styles from "./shortcut-recorder.module.css";
import { UiIcon } from "../theme/ui-icon";

/* ================================================================================================
 * Shortcut model. A shortcut is a string such as "mod+shift+k". `mod` is ⌘ on Apple platforms and
 * Ctrl elsewhere, so one binding reads right everywhere. Keys come from the physical key where it
 * matters, so ⌥K records as "alt+k" instead of "˚".
 * ============================================================================================== */

export type Platform = "mac" | "other";
export interface ShortcutToken { /** What the key cap shows. */ label: string; /** Physical key id, matched against held keys. */ id: string; spoken: string }

const MODIFIER_ORDER = ["mod", "ctrl", "alt", "shift", "meta"] as const;
type Modifier = (typeof MODIFIER_ORDER)[number];
const isModifier = (part: string): part is Modifier => (MODIFIER_ORDER as readonly string[]).includes(part);

const CODE_KEYS: Record<string, string> = {
  Comma: ",", Period: ".", Slash: "/", Semicolon: ";", Quote: "'", BracketLeft: "[", BracketRight: "]", Backslash: "\\", Minus: "-", Equal: "=", Backquote: "`",
  Space: "space", Enter: "enter", NumpadEnter: "enter", Escape: "escape", Backspace: "backspace", Delete: "delete", Tab: "tab",
  ArrowUp: "up", ArrowDown: "down", ArrowLeft: "left", ArrowRight: "right", Home: "home", End: "end", PageUp: "pageup", PageDown: "pagedown",
};
const MODIFIER_KEYS: Record<string, string> = { Meta: "meta", OS: "meta", Control: "ctrl", Alt: "alt", AltGraph: "alt", Shift: "shift" };

/** The physical key id for an event: "k", "1", "enter", "meta", "shift"… `null` for keys that never make a shortcut, such as Caps Lock. */
export function keyId(event: Pick<KeyboardEvent, "key" | "code">): string | null {
  const { code, key } = event;
  if (MODIFIER_KEYS[key]) return MODIFIER_KEYS[key];
  if (/^Key[A-Z]$/.test(code)) return code.slice(3).toLowerCase();
  if (/^Digit\d$/.test(code)) return code.slice(5);
  if (/^Numpad\d$/.test(code)) return code.slice(6);
  if (CODE_KEYS[code]) return CODE_KEYS[code];
  if (/^F\d{1,2}$/.test(key)) return key.toLowerCase();
  if (key === "CapsLock" || key === "Fn" || key === "Dead" || key === "Unidentified" || key === "Process") return null;
  return key.length === 1 ? key.toLowerCase() : null;
}

function splitShortcut(shortcut: string) {
  const parts = shortcut.toLowerCase().split("+").map(part => part.trim()).filter(Boolean);
  const key = parts.filter(part => !isModifier(part)).pop() ?? "";
  return { mods: new Set(parts.filter(isModifier)), key };
}

/** One spelling per combination: on a Mac, Command is `mod`; elsewhere, Ctrl is. Modifiers come in a fixed order. */
export function normalizeShortcut(shortcut: string, platform: Platform) {
  const { mods, key } = splitShortcut(shortcut);
  if (platform === "mac" && mods.delete("meta")) mods.add("mod");
  if (platform === "other" && mods.delete("ctrl")) mods.add("mod");
  return [...MODIFIER_ORDER.filter(mod => mods.has(mod)), key].filter(Boolean).join("+");
}

/** Reads a keydown into a shortcut string, or `null` while only modifiers are down. */
export function shortcutFromEvent(event: Pick<KeyboardEvent, "key" | "code" | "metaKey" | "ctrlKey" | "altKey" | "shiftKey">, platform: Platform) {
  const key = keyId(event);
  if (!key || ["meta", "ctrl", "alt", "shift"].includes(key)) return null;
  const mods: string[] = [];
  if (platform === "mac" ? event.metaKey : event.ctrlKey) mods.push("mod");
  if (platform === "mac" ? event.ctrlKey : false) mods.push("ctrl");
  if (event.altKey) mods.push("alt");
  if (event.shiftKey) mods.push("shift");
  if (platform === "other" && event.metaKey) mods.push("meta");
  return normalizeShortcut([...mods, key].join("+"), platform);
}

/** True when a keydown is this shortcut. Use it to run the action a recorded shortcut is bound to. */
export function matchesShortcut(event: Pick<KeyboardEvent, "key" | "code" | "metaKey" | "ctrlKey" | "altKey" | "shiftKey">, shortcut: string, platform: Platform) {
  const pressed = shortcutFromEvent(event, platform);
  return !!pressed && pressed === normalizeShortcut(shortcut, platform);
}

const MAC_KEYS: Record<string, [string, string]> = { enter: ["↩", "리턴"], backspace: ["⌫", "삭제"], delete: ["⌦", "앞으로 삭제"], tab: ["⇥", "탭"], escape: ["esc", "이스케이프"] };
const SHARED_KEYS: Record<string, [string, string]> = {
  space: ["Space", "스페이스"], enter: ["Enter", "엔터"], escape: ["Esc", "이스케이프"], backspace: ["Backspace", "백스페이스"], delete: ["Del", "딜리트"], tab: ["Tab", "탭"],
  up: ["↑", "위쪽 화살표"], down: ["↓", "아래쪽 화살표"], left: ["←", "왼쪽 화살표"], right: ["→", "오른쪽 화살표"], home: ["Home", "홈"], end: ["End", "엔드"], pageup: ["PgUp", "페이지 업"], pagedown: ["PgDn", "페이지 다운"],
  ",": [",", "쉼표"], ".": [".", "마침표"], "/": ["/", "슬래시"], ";": [";", "세미콜론"], "'": ["'", "작은따옴표"], "[": ["[", "왼쪽 대괄호"], "]": ["]", "오른쪽 대괄호"], "\\": ["\\", "역슬래시"], "-": ["-", "빼기"], "=": ["=", "등호"], "`": ["`", "백틱"],
};

/** Key caps for a shortcut in platform order: ⌃ ⌥ ⇧ ⌘ on a Mac, Ctrl Alt Shift Win elsewhere. */
export function shortcutTokens(shortcut: string, platform: Platform): ShortcutToken[] {
  const { mods, key } = splitShortcut(normalizeShortcut(shortcut, platform));
  const tokens: ShortcutToken[] = [];
  if (platform === "mac") {
    if (mods.has("ctrl")) tokens.push({ label: "⌃", id: "ctrl", spoken: "컨트롤" });
    if (mods.has("alt")) tokens.push({ label: "⌥", id: "alt", spoken: "옵션" });
    if (mods.has("shift")) tokens.push({ label: "⇧", id: "shift", spoken: "시프트" });
    if (mods.has("mod") || mods.has("meta")) tokens.push({ label: "⌘", id: "meta", spoken: "커맨드" });
  } else {
    if (mods.has("mod") || mods.has("ctrl")) tokens.push({ label: "Ctrl", id: "ctrl", spoken: "컨트롤" });
    if (mods.has("alt")) tokens.push({ label: "Alt", id: "alt", spoken: "알트" });
    if (mods.has("shift")) tokens.push({ label: "Shift", id: "shift", spoken: "시프트" });
    if (mods.has("meta")) tokens.push({ label: "Win", id: "meta", spoken: "윈도우" });
  }
  if (key) {
    const named = (platform === "mac" ? MAC_KEYS[key] : undefined) ?? SHARED_KEYS[key];
    tokens.push(named ? { label: named[0], id: key, spoken: named[1] } : { label: key.toUpperCase(), id: key, spoken: key.toUpperCase() });
  }
  return tokens;
}

/** "⌘⇧K" on a Mac, "Ctrl+Shift+K" elsewhere, plus a spoken form for assistive technology. */
export function formatShortcut(shortcut: string, platform: Platform) {
  const tokens = shortcutTokens(shortcut, platform);
  return { text: tokens.map(token => token.label).join(platform === "mac" ? "" : "+"), spoken: tokens.map(token => token.spoken).join(" ") };
}

const subscribe = () => () => {};
/** Reduced motion that only applies after hydration, so server and client render the same styles. */
function useReducedFlag() {
  const hydrated = useSyncExternalStore(subscribe, () => true, () => false);
  return !!useReducedMotion() && hydrated;
}
const detectPlatform = (): Platform => {
  if (typeof navigator === "undefined") return "mac";
  const hint = (navigator as Navigator & { userAgentData?: { platform?: string } }).userAgentData?.platform ?? navigator.platform ?? navigator.userAgent;
  return /mac|iphone|ipad|ipod/i.test(hint) ? "mac" : "other";
};
/** The viewer's platform. Server renders assume a Mac and settle on the first client render. */
export function usePlatform(override?: Platform): Platform {
  const detected = useSyncExternalStore(subscribe, detectPlatform, () => "mac" as Platform);
  return override ?? detected;
}

/** Keys held down right now, by physical id. Clears on window blur, and after Command lifts (macOS drops the other keyups). */
export function usePressedKeys(enabled = true) {
  const [held, setHeld] = useState<ReadonlySet<string>>(() => new Set());
  useEffect(() => {
    if (!enabled) return;
    const update = (change: (next: Set<string>) => void) => setHeld(current => { const next = new Set(current); change(next); return next.size === current.size && [...next].every(key => current.has(key)) ? current : next; });
    const down = (event: KeyboardEvent) => { const id = keyId(event); if (id) update(next => next.add(id)); };
    const up = (event: KeyboardEvent) => {
      const id = keyId(event);
      update(next => {
        if (id) next.delete(id);
        if (id === "meta") [...next].forEach(key => { if (!["ctrl", "alt", "shift"].includes(key)) next.delete(key); });
        if (!event.metaKey) next.delete("meta"); if (!event.ctrlKey) next.delete("ctrl"); if (!event.altKey) next.delete("alt"); if (!event.shiftKey) next.delete("shift");
      });
    };
    const clear = () => setHeld(current => current.size ? new Set() : current);
    // Capture phase, so keys still register while a focused recorder keeps the chord from bubbling.
    window.addEventListener("keydown", down, true);
    window.addEventListener("keyup", up, true);
    window.addEventListener("blur", clear);
    document.addEventListener("visibilitychange", clear);
    return () => { window.removeEventListener("keydown", down, true); window.removeEventListener("keyup", up, true); window.removeEventListener("blur", clear); document.removeEventListener("visibilitychange", clear); };
  }, [enabled]);
  return enabled ? held : EMPTY;
}
const EMPTY: ReadonlySet<string> = new Set();

/* ================================================================================================
 * ShortcutKbd and ShortcutKeys
 * ============================================================================================== */

export interface ShortcutKbdProps extends HTMLAttributes<HTMLElement> {
  children: ReactNode;
  /** Draws the key pushed down and lit, such as while it is held. */
  pressed?: boolean;
  size?: "sm" | "md";
}

/** A key cap. Use it inline in copy, in menus, or through `ShortcutKeys` for a whole combination. */
export const ShortcutKbd = forwardRef<HTMLElement, ShortcutKbdProps>(function ShortcutKbd({ children, pressed = false, size = "md", className, ...props }, ref) {
  return <kbd ref={ref} {...props} className={[styles.kbd, className].filter(Boolean).join(" ")} data-size={size} data-pressed={pressed || undefined}>{children}</kbd>;
});
ShortcutKbd.displayName = "ShortcutKbd";
/** @deprecated Renamed to `ShortcutKbd` so it never clashes with the `kbd` component's `Kbd` in a shared index.ts. */
export const Kbd = ShortcutKbd;
/** @deprecated Renamed to `ShortcutKbdProps`. */
export type KbdProps = ShortcutKbdProps;

export interface ShortcutKeysProps {
  shortcut: string;
  platform?: Platform;
  /** Held key ids from `usePressedKeys`; matching caps light up. */
  pressed?: ReadonlySet<string>;
  size?: "sm" | "md";
  className?: string;
}

/** A shortcut as key caps, with a spoken label for assistive technology. */
export function ShortcutKeys({ shortcut, platform: platformProp, pressed, size = "md", className }: ShortcutKeysProps) {
  const platform = usePlatform(platformProp);
  const tokens = shortcutTokens(shortcut, platform);
  return <span className={[styles.keys, className].filter(Boolean).join(" ")} role="img" aria-label={tokens.map(token => token.spoken).join(" ")}>
    {tokens.map((token, index) => <ShortcutKbd key={`${index}-${token.id}`} size={size} pressed={pressed?.has(token.id)} aria-hidden="true">{token.label}</ShortcutKbd>)}
  </span>;
}

/* ================================================================================================
 * ShortcutRecorder
 * ============================================================================================== */

export interface ShortcutBinding { shortcut: string; label: string }

export interface ShortcutRecorderProps {
  label: string;
  hideLabel?: boolean;
  value?: string | null;
  defaultValue?: string | null;
  /** `replaced` is the binding the new shortcut was taken from after "그래도 쓰기", so the caller can unbind it. */
  onValueChange?: (value: string | null, details: { replaced?: ShortcutBinding }) => void;
  /** What the reset button restores. Defaults to `defaultValue`. */
  resetValue?: string | null;
  /** Shortcuts already in use. Recording one of them asks before taking it. */
  bindings?: ShortcutBinding[];
  /** Also warn about combinations the browser or system keeps, such as ⌘W and ⌘C. On by default. */
  warnReserved?: boolean;
  /** Require ⌘, Ctrl, or ⌥ (function keys are always allowed). On by default. */
  requireModifier?: boolean;
  platform?: Platform;
  placeholder?: string;
  description?: string;
  disabled?: boolean;
  id?: string;
  className?: string;
}

const RESERVED: ShortcutBinding[] = [
  { shortcut: "mod+w", label: "탭 닫기" }, { shortcut: "mod+t", label: "새 탭" }, { shortcut: "mod+n", label: "새 창" }, { shortcut: "mod+q", label: "종료" },
  { shortcut: "mod+l", label: "주소 표시줄" }, { shortcut: "mod+r", label: "새로 고침" }, { shortcut: "mod+shift+t", label: "닫은 탭 다시 열기" },
  { shortcut: "mod+c", label: "복사" }, { shortcut: "mod+v", label: "붙여넣기" }, { shortcut: "mod+x", label: "잘라내기" }, { shortcut: "mod+z", label: "되돌리기" }, { shortcut: "mod+a", label: "모두 선택" },
];

type Pending = { shortcut: string; conflict: ShortcutBinding; reserved: boolean };

const chip: Variants = {
  enter: { opacity: 0, scale: .72, y: 4, filter: `blur(${motionTokens.blur.subtle}px)` },
  rest: (index: number) => ({ opacity: 1, scale: 1, y: 0, filter: "blur(0px)", transition: { ...motionTokens.spring.snappy, delay: index * motionTokens.stagger.item, opacity: { duration: motionTokens.duration.instant, delay: index * motionTokens.stagger.item }, filter: { duration: motionTokens.duration.fast, delay: index * motionTokens.stagger.item } } }),
  exit: { opacity: 0, scale: .8, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.instant } },
};
const chipFade: Variants = { enter: { opacity: 0 }, rest: { opacity: 1, scale: 1, y: 0, filter: "none", transition: { duration: motionTokens.duration.instant } }, exit: { opacity: 0, transition: { duration: motionTokens.duration.micro } } };

/**
 * A field that records a keyboard shortcut. Press it, then press the combination: held modifiers light up as caps while you
 * build the chord, the finished shortcut settles into place, and a combination that is already taken asks before it is used.
 * Escape cancels, Backspace clears, and the reset button restores the default. Caps follow the platform: ⌘ on a Mac, Ctrl elsewhere.
 */
export function ShortcutRecorder({
  label, hideLabel = false, value, defaultValue = null, onValueChange, resetValue, bindings = [], warnReserved = true, requireModifier = true,
  platform: platformProp, placeholder = "단축키를 눌러 주세요", description, disabled = false, id, className,
}: ShortcutRecorderProps) {
  const reduced = useReducedFlag();
  const platform = usePlatform(platformProp);
  const uid = useId();
  const buttonId = id ?? `${uid}-button`;
  const labelId = `${uid}-label`, messageId = `${uid}-message`;

  const [inner, setInner] = useState<string | null>(defaultValue);
  const current = value !== undefined ? value : inner;
  const restoreTo = resetValue !== undefined ? resetValue : defaultValue;

  const [recording, setRecording] = useState(false);
  const [live, setLive] = useState<string[]>([]);
  const [pending, setPending] = useState<Pending | null>(null);
  const [nudge, setNudge] = useState<string | null>(null);
  const [settled, setSettled] = useState(0);
  const [announcement, setAnnouncement] = useState("");
  const buttonRef = useRef<HTMLButtonElement>(null);
  /* The message slot animates to the measured height of its content, so a swap to a message that wraps grows instead of snapping. */
  const [messageHeight, setMessageHeight] = useState<number | null>(null);
  const measureMessage = useCallback((node: HTMLDivElement | null) => {
    if (!node) return;
    const observer = new ResizeObserver(() => setMessageHeight(node.getBoundingClientRect().height));
    observer.observe(node);
    return () => { observer.disconnect(); setMessageHeight(null); };
  }, []);

  const spoken = (shortcut: string | null) => shortcut ? formatShortcut(shortcut, platform).spoken : "없음";

  const commit = useCallback((next: string | null, replaced?: ShortcutBinding) => {
    setPending(null);
    setNudge(null);
    if (value === undefined) setInner(next);
    onValueChange?.(next, { replaced });
    playUiSound(next ? "select" : "click");
    setSettled(count => count + 1);
    setAnnouncement(next ? `단축키를 정했어요: ${formatShortcut(next, platform).spoken}` : "단축키를 지웠어요");
  }, [onValueChange, platform, value]);

  const start = () => {
    if (disabled) return;
    setPending(null); setNudge(null); setLive([]);
    setRecording(true);
    setAnnouncement("입력하는 중이에요. 새 단축키를 누르거나, Esc로 취소해요.");
  };
  const stop = () => { setRecording(false); setLive([]); };

  const conflictFor = (shortcut: string): Pending | null => {
    const normal = normalizeShortcut(shortcut, platform);
    if (current && normalizeShortcut(current, platform) === normal) return null;
    const taken = bindings.find(binding => normalizeShortcut(binding.shortcut, platform) === normal);
    if (taken) return { shortcut, conflict: taken, reserved: false };
    const reserved = warnReserved ? RESERVED.find(binding => normalizeShortcut(binding.shortcut, platform) === normal) : undefined;
    return reserved ? { shortcut, conflict: reserved, reserved: true } : null;
  };

  const liveMods = (event: ReactKeyboardEvent) => {
    const mods: string[] = [];
    if (event.ctrlKey && platform === "mac") mods.push("ctrl");
    if (event.altKey) mods.push("alt");
    if (event.shiftKey) mods.push("shift");
    if (platform === "mac" ? event.metaKey : event.ctrlKey) mods.push("mod");
    if (platform === "other" && event.metaKey) mods.push("meta");
    return mods;
  };

  function onKeyDown(event: ReactKeyboardEvent<HTMLButtonElement>) {
    if (!recording) {
      if ((event.key === "Backspace" || event.key === "Delete") && current) { event.preventDefault(); commit(null); }
      return;
    }
    if (event.key === "Tab" && !event.shiftKey && !event.metaKey && !event.ctrlKey && !event.altKey) { stop(); return; }
    // The chord belongs to the recorder: keep it from the page's own shortcuts, such as a ⌘K command menu.
    event.preventDefault();
    event.stopPropagation();
    const bare = !event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey;
    if (bare && event.key === "Escape") { stop(); setAnnouncement("입력을 취소했어요"); return; }
    if (bare && (event.key === "Backspace" || event.key === "Delete")) { stop(); commit(null); return; }
    const shortcut = shortcutFromEvent(event.nativeEvent, platform);
    if (!shortcut) { setLive(liveMods(event)); return; }
    const { mods, key } = splitShortcut(shortcut);
    const strong = mods.has("mod") || mods.has("ctrl") || mods.has("alt") || mods.has("meta");
    if (requireModifier && !strong && !/^f\d+$/.test(key)) {
      const hint = platform === "mac" ? "⌘, ⌃, ⌥ 중 하나를 함께 눌러 주세요" : "Ctrl이나 Alt를 함께 눌러 주세요";
      setNudge(hint); setAnnouncement(hint); setLive(liveMods(event));
      return;
    }
    stop();
    const conflict = conflictFor(shortcut);
    if (conflict) {
      setPending(conflict);
      setAnnouncement(`이미 쓰고 있는 단축키예요: ${conflict.conflict.label}. 그래도 쓰거나, 다른 단축키를 눌러 주세요.`);
      return;
    }
    commit(shortcut);
  }
  function onKeyUp(event: ReactKeyboardEvent<HTMLButtonElement>) { if (recording) setLive(liveMods(event)); }

  const shown = pending?.shortcut ?? current;
  const tokens = recording ? shortcutTokens(live.join("+"), platform) : shown ? shortcutTokens(shown, platform) : [];
  const state = recording ? "recording" : pending ? "conflict" : "idle";
  const canReset = !recording && (pending !== null || (current ?? null) !== (restoreTo ?? null));
  const canClear = !recording && !!current && !pending;
  const message = pending
    ? { tone: "warning" as const, key: `c-${pending.shortcut}`, body: <>{pending.reserved ? "이미 쓰고 있어요:" : "이미 쓰고 있어요:"} <strong className={styles.strong}>{pending.conflict.label}</strong></> }
    : nudge ? { tone: "hint" as const, key: `n-${nudge}`, body: <>{nudge}</> }
    : recording ? { tone: "hint" as const, key: "rec", body: <>Esc로 취소하고, Backspace로 지워요</> }
    : description ? { tone: "hint" as const, key: "d", body: <>{description}</> } : null;

  useEffect(() => {
    if (!settled) return;
    const timer = window.setTimeout(() => setSettled(0), 700);
    return () => window.clearTimeout(timer);
  }, [settled]);

  return <div className={[styles.field, className].filter(Boolean).join(" ")} data-disabled={disabled || undefined}>
    <span id={labelId} className={hideLabel ? styles.srOnly : styles.label}>{label}</span>
    <div className={styles.control} data-state={state}>
      <button ref={buttonRef} id={buttonId} type="button" className={styles.recorder} disabled={disabled} aria-labelledby={`${labelId} ${buttonId}`} aria-describedby={message ? messageId : undefined}
        aria-pressed={recording}
        onClick={event => { if (recording) { if (event.detail !== 0) { stop(); setAnnouncement("입력을 취소했어요"); } return; } start(); }}
        onKeyDown={onKeyDown} onKeyUp={onKeyUp} onBlur={() => { if (recording) { stop(); setNudge(null); } }}>
        <span className={styles.srOnly}>{recording ? "입력 중" : shown ? spoken(shown) : placeholder}</span>
        <span className={styles.row} aria-hidden="true">
          {recording && <span className={styles.dot} data-reduced={reduced || undefined} />}
          <AnimatePresence initial={false} mode="popLayout">
            {tokens.map((token, index) => <motion.span key={`${token.id}`} layout={reduced ? false : "position"} custom={recording ? 0 : index} variants={reduced ? chipFade : chip} initial="enter" animate="rest" exit="exit"
              transition={reduced ? { duration: 0 } : { layout: motionTokens.spring.snappy }} className={styles.chipSlot}>
              <ShortcutKbd pressed={recording || (settled > 0 && !pending)} data-tone={pending ? "warning" : undefined}>{token.label}</ShortcutKbd>
            </motion.span>)}
            {tokens.length === 0 && <motion.span key={recording ? "prompt" : "placeholder"} className={styles.placeholder} data-recording={recording || undefined}
              initial={reduced ? { opacity: 0 } : { opacity: 0, y: 4, filter: `blur(${motionTokens.blur.subtle}px)` }} animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
              exit={{ opacity: 0, transition: { duration: motionTokens.duration.micro } }} transition={reduced ? { duration: motionTokens.duration.instant } : { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] }}>
              {recording ? "키를 눌러 주세요" : placeholder}
            </motion.span>}
          </AnimatePresence>
        </span>
      </button>

      <span className={styles.actions}>
        <AnimatePresence initial={false}>
          {canReset && <motion.button key="reset" type="button" className={styles.action} aria-label={`되돌리기: ${restoreTo ? formatShortcut(restoreTo, platform).spoken : "없음"}`}
            onClick={() => { commit(restoreTo ?? null); buttonRef.current?.focus(); }}
            initial={{ opacity: 0, scale: .6 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: .6, transition: { duration: motionTokens.duration.instant } }} transition={reduced ? { duration: 0 } : motionTokens.spring.snappy}>
            <UiIcon iconRole="reset" size={16} strokeWidth={1.75} aria-hidden="true" />
          </motion.button>}
          {canClear && <motion.button key="clear" type="button" className={styles.action} aria-label="단축키 지우기" onClick={() => { commit(null); buttonRef.current?.focus(); }}
            initial={{ opacity: 0, scale: .6 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: .6, transition: { duration: motionTokens.duration.instant } }} transition={reduced ? { duration: 0 } : motionTokens.spring.snappy}>
            <UiIcon iconRole="close" size={16} strokeWidth={1.75} aria-hidden="true" />
          </motion.button>}
        </AnimatePresence>
      </span>
    </div>

    <AnimatePresence initial={false}>
      {message && <motion.div key="slot" className={styles.messageSlot} initial={reduced ? false : { height: 0, opacity: 0 }} animate={{ height: messageHeight ?? "auto", opacity: 1 }}
        exit={{ height: 0, opacity: 0, transition: reduced ? { duration: 0 } : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.instant } } }}
        transition={reduced ? { duration: 0 } : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.fast } }}>
        <div ref={measureMessage}>
          <AnimatePresence initial={false} mode="popLayout">
            <motion.div key={message.key} id={messageId} className={styles.message} data-tone={message.tone}
              initial={reduced ? { opacity: 0 } : { opacity: 0, y: "0.35em", filter: `blur(${motionTokens.blur.soft}px)` }} animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
              exit={{ opacity: 0, transition: { duration: motionTokens.duration.micro } }} transition={{ duration: reduced ? motionTokens.duration.instant : motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }}>
              <span className={styles.messageText}>{message.body}</span>
              {pending && <span className={styles.messageActions}>
                <button type="button" className={styles.link} onClick={() => { const taken = pending; commit(taken.shortcut, taken.reserved ? undefined : taken.conflict); buttonRef.current?.focus(); }}>그래도 쓰기</button>
                <button type="button" className={styles.link} data-quiet="" onClick={() => { setPending(null); setAnnouncement("이전 단축키를 그대로 뒀어요"); buttonRef.current?.focus(); }}>취소하기</button>
              </span>}
            </motion.div>
          </AnimatePresence>
        </div>
      </motion.div>}
    </AnimatePresence>
    <span className={styles.srOnly} role="status" aria-live="polite">{announcement}</span>
  </div>;
}

/* ================================================================================================
 * ShortcutList
 * ============================================================================================== */

export interface ShortcutListItem { label: string; shortcut: string; /** Extra words that match when searching. */ keywords?: string }
export interface ShortcutListGroup { label: string; items: ShortcutListItem[] }

export interface ShortcutListProps {
  groups: ShortcutListGroup[];
  /** Accessible name of the list. */
  label?: string;
  searchable?: boolean;
  searchPlaceholder?: string;
  /** Light up caps as keys are held, and fade rows that do not use the held modifiers. On by default. */
  highlightPressed?: boolean;
  platform?: Platform;
  className?: string;
}

/**
 * A searchable, grouped cheatsheet. Holding a modifier lights that cap in every row and fades rows that do not use it, so people
 * can explore by holding keys; a row whose whole combination is down lights up. Search matches names, groups, and key names such as "cmd k".
 */
export function ShortcutList({ groups, label = "키보드 단축키", searchable = true, searchPlaceholder = "단축키 검색하기", highlightPressed = true, platform: platformProp, className }: ShortcutListProps) {
  const reduced = useReducedFlag();
  const platform = usePlatform(platformProp);
  const held = usePressedKeys(highlightPressed);
  const [query, setQuery] = useState("");
  const searchRef = useRef<HTMLInputElement>(null);

  const heldMods = useMemo(() => [...held].filter(key => ["meta", "ctrl", "alt", "shift"].includes(key)), [held]);
  const words = query.trim().toLowerCase().replace(/[⌘]/g, " cmd ").replace(/[⇧]/g, " shift ").replace(/[⌥]/g, " alt ").replace(/[⌃]/g, " ctrl ").split(/[\s+]+/).filter(Boolean);
  const aliases: Record<string, string> = { cmd: "command", command: "command", ctrl: "control", control: "control", opt: "option", option: platform === "mac" ? "option" : "alt", alt: platform === "mac" ? "option" : "alt", win: "windows", esc: "escape", return: platform === "mac" ? "return" : "enter" };

  // The spoken names are Korean, so the English key names stay searchable here.
  const english: Record<string, string> = { meta: platform === "mac" ? "command" : "windows", ctrl: "control", alt: platform === "mac" ? "option" : "alt", shift: "shift" };
  const filtered = groups.map(group => ({
    ...group,
    items: group.items.filter(item => {
      if (!words.length) return true;
      const tokens = shortcutTokens(item.shortcut, platform);
      const haystack = `${item.label} ${group.label} ${item.keywords ?? ""} ${tokens.map(token => `${token.spoken} ${token.label} ${english[token.id] ?? token.id}`).join(" ")}`.toLowerCase();
      return words.every(word => haystack.includes(aliases[word] ?? word) || haystack.includes(word));
    }),
  })).filter(group => group.items.length);

  const total = filtered.reduce((sum, group) => sum + group.items.length, 0);
  const rowTransition = reduced ? { duration: 0 } : motionTokens.spring.smooth;

  return <section className={[styles.list, className].filter(Boolean).join(" ")} aria-label={label}>
    {searchable && <div className={styles.searchRow}>
      <UiIcon iconRole="search" className={styles.searchIcon} size={16} strokeWidth={1.75} aria-hidden="true" />
      <input ref={searchRef} className={styles.search} type="search" placeholder={searchPlaceholder} value={query} aria-label={searchPlaceholder} onChange={event => setQuery(event.target.value)}
        onKeyDown={event => { if (event.key === "Escape" && query) { event.preventDefault(); setQuery(""); } }} autoComplete="off" spellCheck={false} />
      <span className={styles.count} aria-live="polite">{query ? `${total}개` : ""}</span>
      <AnimatePresence initial={false}>
        {query && <motion.button key="clear" type="button" className={styles.clear} aria-label="검색어 지우기" onClick={() => { setQuery(""); searchRef.current?.focus(); }}
          initial={{ opacity: 0, scale: .6 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: .6, transition: { duration: motionTokens.duration.instant } }} transition={reduced ? { duration: 0 } : motionTokens.spring.snappy}>
          <UiIcon iconRole="close" size={14} strokeWidth={1.75} aria-hidden="true" />
        </motion.button>}
      </AnimatePresence>
    </div>}

    <div className={styles.groups}>
      <AnimatePresence initial={false}>
        {filtered.map(group => <motion.section key={group.label} className={styles.group} aria-label={group.label}
          initial={reduced ? { opacity: 0 } : { opacity: 0, height: 0 }} animate={{ opacity: 1, height: "auto" }} exit={reduced ? { opacity: 0 } : { opacity: 0, height: 0 }} transition={rowTransition}>
          <h4 className={styles.groupLabel}>{group.label}</h4>
          <ul className={styles.rows}>
            <AnimatePresence initial={false}>
              {group.items.map(item => {
                const tokens = shortcutTokens(item.shortcut, platform);
                const ids = tokens.map(token => token.id);
                const match = held.size > 0 && ids.every(key => held.has(key));
                const dim = heldMods.length > 0 && !match && !heldMods.every(mod => ids.includes(mod));
                return <motion.li key={item.label} className={styles.item} data-match={match || undefined} data-dim={dim || undefined}
                  initial={reduced ? { opacity: 0 } : { opacity: 0, height: 0 }} animate={{ opacity: 1, height: "auto" }} exit={reduced ? { opacity: 0 } : { opacity: 0, height: 0 }} transition={rowTransition}>
                  <span className={styles.itemInner}>
                    <span className={styles.itemLabel}>{item.label}</span>
                    <ShortcutKeys shortcut={item.shortcut} platform={platform} pressed={held} size="sm" />
                  </span>
                </motion.li>;
              })}
            </AnimatePresence>
          </ul>
        </motion.section>)}
      </AnimatePresence>
      {total === 0 && <p className={styles.empty}>“{query.trim()}”에 맞는 단축키가 없어요</p>}
    </div>
  </section>;
}

export default ShortcutRecorder;