{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "shortcut-recorder",
  "type": "registry:ui",
  "title": "Shortcut recorder",
  "description": "Record key combinations into key caps, with conflict warnings, Kbd, and a searchable cheatsheet.",
  "categories": [
    "inputs"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/shortcut-recorder/shortcut-recorder.tsx",
      "content": "\"use client\";\n\nimport { forwardRef, useCallback, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from \"react\";\nimport type { HTMLAttributes, KeyboardEvent as ReactKeyboardEvent, ReactNode } from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport type { Variants } from \"motion/react\";\nimport { RotateCcw, Search, X } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\n\n/* ================================================================================================\n * Shortcut model. A shortcut is a string such as \"mod+shift+k\". `mod` is ⌘ on Apple platforms and\n * Ctrl elsewhere, so one binding reads right everywhere. Keys come from the physical key where it\n * matters, so ⌥K records as \"alt+k\" instead of \"˚\".\n * ============================================================================================== */\n\nexport type Platform = \"mac\" | \"other\";\nexport interface ShortcutToken {\n  /** What the key cap shows. */ label: string;\n  /** Physical key id, matched against held keys. */ id: string;\n  spoken: string;\n}\n\nconst MODIFIER_ORDER = [\"mod\", \"ctrl\", \"alt\", \"shift\", \"meta\"] as const;\ntype Modifier = (typeof MODIFIER_ORDER)[number];\nconst isModifier = (part: string): part is Modifier => (MODIFIER_ORDER as readonly string[]).includes(part);\n\nconst CODE_KEYS: Record<string, string> = {\n  Comma: \",\",\n  Period: \".\",\n  Slash: \"/\",\n  Semicolon: \";\",\n  Quote: \"'\",\n  BracketLeft: \"[\",\n  BracketRight: \"]\",\n  Backslash: \"\\\\\",\n  Minus: \"-\",\n  Equal: \"=\",\n  Backquote: \"`\",\n  Space: \"space\",\n  Enter: \"enter\",\n  NumpadEnter: \"enter\",\n  Escape: \"escape\",\n  Backspace: \"backspace\",\n  Delete: \"delete\",\n  Tab: \"tab\",\n  ArrowUp: \"up\",\n  ArrowDown: \"down\",\n  ArrowLeft: \"left\",\n  ArrowRight: \"right\",\n  Home: \"home\",\n  End: \"end\",\n  PageUp: \"pageup\",\n  PageDown: \"pagedown\",\n};\nconst MODIFIER_KEYS: Record<string, string> = {\n  Meta: \"meta\",\n  OS: \"meta\",\n  Control: \"ctrl\",\n  Alt: \"alt\",\n  AltGraph: \"alt\",\n  Shift: \"shift\",\n};\n\n/** The physical key id for an event: \"k\", \"1\", \"enter\", \"meta\", \"shift\"… `null` for keys that never make a shortcut, such as Caps Lock. */\nexport function keyId(event: Pick<KeyboardEvent, \"key\" | \"code\">): string | null {\n  const { code, key } = event;\n  if (MODIFIER_KEYS[key]) return MODIFIER_KEYS[key];\n  if (/^Key[A-Z]$/.test(code)) return code.slice(3).toLowerCase();\n  if (/^Digit\\d$/.test(code)) return code.slice(5);\n  if (/^Numpad\\d$/.test(code)) return code.slice(6);\n  if (CODE_KEYS[code]) return CODE_KEYS[code];\n  if (/^F\\d{1,2}$/.test(key)) return key.toLowerCase();\n  if (key === \"CapsLock\" || key === \"Fn\" || key === \"Dead\" || key === \"Unidentified\" || key === \"Process\") return null;\n  return key.length === 1 ? key.toLowerCase() : null;\n}\n\nfunction splitShortcut(shortcut: string) {\n  const parts = shortcut\n    .toLowerCase()\n    .split(\"+\")\n    .map((part) => part.trim())\n    .filter(Boolean);\n  const key = parts.filter((part) => !isModifier(part)).pop() ?? \"\";\n  return { mods: new Set(parts.filter(isModifier)), key };\n}\n\n/** One spelling per combination: on a Mac, Command is `mod`; elsewhere, Ctrl is. Modifiers come in a fixed order. */\nexport function normalizeShortcut(shortcut: string, platform: Platform) {\n  const { mods, key } = splitShortcut(shortcut);\n  if (platform === \"mac\" && mods.delete(\"meta\")) mods.add(\"mod\");\n  if (platform === \"other\" && mods.delete(\"ctrl\")) mods.add(\"mod\");\n  return [...MODIFIER_ORDER.filter((mod) => mods.has(mod)), key].filter(Boolean).join(\"+\");\n}\n\n/** Reads a keydown into a shortcut string, or `null` while only modifiers are down. */\nexport function shortcutFromEvent(\n  event: Pick<KeyboardEvent, \"key\" | \"code\" | \"metaKey\" | \"ctrlKey\" | \"altKey\" | \"shiftKey\">,\n  platform: Platform,\n) {\n  const key = keyId(event);\n  if (!key || [\"meta\", \"ctrl\", \"alt\", \"shift\"].includes(key)) return null;\n  const mods: string[] = [];\n  if (platform === \"mac\" ? event.metaKey : event.ctrlKey) mods.push(\"mod\");\n  if (platform === \"mac\" ? event.ctrlKey : false) mods.push(\"ctrl\");\n  if (event.altKey) mods.push(\"alt\");\n  if (event.shiftKey) mods.push(\"shift\");\n  if (platform === \"other\" && event.metaKey) mods.push(\"meta\");\n  return normalizeShortcut([...mods, key].join(\"+\"), platform);\n}\n\n/** True when a keydown is this shortcut. Use it to run the action a recorded shortcut is bound to. */\nexport function matchesShortcut(\n  event: Pick<KeyboardEvent, \"key\" | \"code\" | \"metaKey\" | \"ctrlKey\" | \"altKey\" | \"shiftKey\">,\n  shortcut: string,\n  platform: Platform,\n) {\n  const pressed = shortcutFromEvent(event, platform);\n  return !!pressed && pressed === normalizeShortcut(shortcut, platform);\n}\n\nconst MAC_KEYS: Record<string, [string, string]> = {\n  enter: [\"↩\", \"Return\"],\n  backspace: [\"⌫\", \"Delete\"],\n  delete: [\"⌦\", \"Forward delete\"],\n  tab: [\"⇥\", \"Tab\"],\n  escape: [\"esc\", \"Escape\"],\n};\nconst SHARED_KEYS: Record<string, [string, string]> = {\n  space: [\"Space\", \"Space\"],\n  enter: [\"Enter\", \"Enter\"],\n  escape: [\"Esc\", \"Escape\"],\n  backspace: [\"Backspace\", \"Backspace\"],\n  delete: [\"Del\", \"Delete\"],\n  tab: [\"Tab\", \"Tab\"],\n  up: [\"↑\", \"Up arrow\"],\n  down: [\"↓\", \"Down arrow\"],\n  left: [\"←\", \"Left arrow\"],\n  right: [\"→\", \"Right arrow\"],\n  home: [\"Home\", \"Home\"],\n  end: [\"End\", \"End\"],\n  pageup: [\"PgUp\", \"Page up\"],\n  pagedown: [\"PgDn\", \"Page down\"],\n  \",\": [\",\", \"Comma\"],\n  \".\": [\".\", \"Period\"],\n  \"/\": [\"/\", \"Slash\"],\n  \";\": [\";\", \"Semicolon\"],\n  \"'\": [\"'\", \"Quote\"],\n  \"[\": [\"[\", \"Left bracket\"],\n  \"]\": [\"]\", \"Right bracket\"],\n  \"\\\\\": [\"\\\\\", \"Backslash\"],\n  \"-\": [\"-\", \"Minus\"],\n  \"=\": [\"=\", \"Equals\"],\n  \"`\": [\"`\", \"Backtick\"],\n};\n\n/** Key caps for a shortcut in platform order: ⌃ ⌥ ⇧ ⌘ on a Mac, Ctrl Alt Shift Win elsewhere. */\nexport function shortcutTokens(shortcut: string, platform: Platform): ShortcutToken[] {\n  const { mods, key } = splitShortcut(normalizeShortcut(shortcut, platform));\n  const tokens: ShortcutToken[] = [];\n  if (platform === \"mac\") {\n    if (mods.has(\"ctrl\")) tokens.push({ label: \"⌃\", id: \"ctrl\", spoken: \"Control\" });\n    if (mods.has(\"alt\")) tokens.push({ label: \"⌥\", id: \"alt\", spoken: \"Option\" });\n    if (mods.has(\"shift\")) tokens.push({ label: \"⇧\", id: \"shift\", spoken: \"Shift\" });\n    if (mods.has(\"mod\") || mods.has(\"meta\")) tokens.push({ label: \"⌘\", id: \"meta\", spoken: \"Command\" });\n  } else {\n    if (mods.has(\"mod\") || mods.has(\"ctrl\")) tokens.push({ label: \"Ctrl\", id: \"ctrl\", spoken: \"Control\" });\n    if (mods.has(\"alt\")) tokens.push({ label: \"Alt\", id: \"alt\", spoken: \"Alt\" });\n    if (mods.has(\"shift\")) tokens.push({ label: \"Shift\", id: \"shift\", spoken: \"Shift\" });\n    if (mods.has(\"meta\")) tokens.push({ label: \"Win\", id: \"meta\", spoken: \"Windows\" });\n  }\n  if (key) {\n    const named = (platform === \"mac\" ? MAC_KEYS[key] : undefined) ?? SHARED_KEYS[key];\n    tokens.push(\n      named\n        ? { label: named[0], id: key, spoken: named[1] }\n        : { label: key.toUpperCase(), id: key, spoken: key.toUpperCase() },\n    );\n  }\n  return tokens;\n}\n\n/** \"⌘⇧K\" on a Mac, \"Ctrl+Shift+K\" elsewhere, plus a spoken form for assistive technology. */\nexport function formatShortcut(shortcut: string, platform: Platform) {\n  const tokens = shortcutTokens(shortcut, platform);\n  return {\n    text: tokens.map((token) => token.label).join(platform === \"mac\" ? \"\" : \"+\"),\n    spoken: tokens.map((token) => token.spoken).join(\" \"),\n  };\n}\n\nconst subscribe = () => () => {};\n/** Reduced motion that only applies after hydration, so server and client render the same styles. */\nfunction useReducedFlag() {\n  const hydrated = useSyncExternalStore(\n    subscribe,\n    () => true,\n    () => false,\n  );\n  return !!useReducedMotion() && hydrated;\n}\nconst detectPlatform = (): Platform => {\n  if (typeof navigator === \"undefined\") return \"mac\";\n  const hint =\n    (navigator as Navigator & { userAgentData?: { platform?: string } }).userAgentData?.platform ??\n    navigator.platform ??\n    navigator.userAgent;\n  return /mac|iphone|ipad|ipod/i.test(hint) ? \"mac\" : \"other\";\n};\n/** The viewer's platform. Server renders assume a Mac and settle on the first client render. */\nexport function usePlatform(override?: Platform): Platform {\n  const detected = useSyncExternalStore(subscribe, detectPlatform, () => \"mac\" as Platform);\n  return override ?? detected;\n}\n\n/** Keys held down right now, by physical id. Clears on window blur, and after Command lifts (macOS drops the other keyups). */\nexport function usePressedKeys(enabled = true) {\n  const [held, setHeld] = useState<ReadonlySet<string>>(() => new Set());\n  useEffect(() => {\n    if (!enabled) return;\n    const update = (change: (next: Set<string>) => void) =>\n      setHeld((current) => {\n        const next = new Set(current);\n        change(next);\n        return next.size === current.size && [...next].every((key) => current.has(key)) ? current : next;\n      });\n    const down = (event: KeyboardEvent) => {\n      const id = keyId(event);\n      if (id) update((next) => next.add(id));\n    };\n    const up = (event: KeyboardEvent) => {\n      const id = keyId(event);\n      update((next) => {\n        if (id) next.delete(id);\n        if (id === \"meta\")\n          [...next].forEach((key) => {\n            if (![\"ctrl\", \"alt\", \"shift\"].includes(key)) next.delete(key);\n          });\n        if (!event.metaKey) next.delete(\"meta\");\n        if (!event.ctrlKey) next.delete(\"ctrl\");\n        if (!event.altKey) next.delete(\"alt\");\n        if (!event.shiftKey) next.delete(\"shift\");\n      });\n    };\n    const clear = () => setHeld((current) => (current.size ? new Set() : current));\n    // Capture phase, so keys still register while a focused recorder keeps the chord from bubbling.\n    window.addEventListener(\"keydown\", down, true);\n    window.addEventListener(\"keyup\", up, true);\n    window.addEventListener(\"blur\", clear);\n    document.addEventListener(\"visibilitychange\", clear);\n    return () => {\n      window.removeEventListener(\"keydown\", down, true);\n      window.removeEventListener(\"keyup\", up, true);\n      window.removeEventListener(\"blur\", clear);\n      document.removeEventListener(\"visibilitychange\", clear);\n    };\n  }, [enabled]);\n  return enabled ? held : EMPTY;\n}\nconst EMPTY: ReadonlySet<string> = new Set();\n\n/* ================================================================================================\n * ShortcutKbd and ShortcutKeys\n * ============================================================================================== */\n\nexport interface ShortcutKbdProps extends HTMLAttributes<HTMLElement> {\n  children: ReactNode;\n  /** Draws the key pushed down and lit, such as while it is held. */\n  pressed?: boolean;\n  size?: \"sm\" | \"md\";\n}\n\n/** 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. */\nconst kbdBase =\n  \"box-border inline-grid place-items-center rounded-control border border-b-2 border-line-strong bg-surface pt-0 pb-px font-body text-xs leading-none font-medium tracking-[0] whitespace-nowrap text-ink tabular-nums [transition:translate_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)] motion-reduce:duration-0\";\nconst kbdPressed =\n  \"translate-y-px border-b border-[color:color-mix(in_oklab,var(--accent)_55%,var(--line-strong))] bg-accent-tint text-accent-ink [transition-duration:90ms] motion-reduce:translate-none motion-reduce:duration-0\";\n/** Classes for a cap that warns, such as a combination that is already taken. */\nconst kbdWarning =\n  \"border-[color:color-mix(in_oklab,var(--orange)_55%,var(--line-strong))] bg-[color:color-mix(in_oklab,var(--orange)_12%,var(--surface))] text-ink\";\n\n/** A key cap. Use it inline in copy, in menus, or through `ShortcutKeys` for a whole combination. */\nexport const ShortcutKbd = forwardRef<HTMLElement, ShortcutKbdProps>(function ShortcutKbd(\n  { children, pressed = false, size = \"md\", className, ...props },\n  ref,\n) {\n  return (\n    <kbd\n      ref={ref}\n      {...props}\n      className={cn(\n        kbdBase,\n        size === \"sm\" ? \"h-[22px] min-w-[22px] rounded-[7px] px-[5px]\" : \"h-[26px] min-w-[26px] px-[7px]\",\n        pressed && kbdPressed,\n        className,\n      )}\n      data-size={size}\n      data-pressed={pressed || undefined}\n    >\n      {children}\n    </kbd>\n  );\n});\nShortcutKbd.displayName = \"ShortcutKbd\";\n/** @deprecated Renamed to `ShortcutKbd` so it never clashes with the `kbd` component's `Kbd` in a shared index.ts. */\nexport const Kbd = ShortcutKbd;\n/** @deprecated Renamed to `ShortcutKbdProps`. */\nexport type KbdProps = ShortcutKbdProps;\n\nexport interface ShortcutKeysProps {\n  shortcut: string;\n  platform?: Platform;\n  /** Held key ids from `usePressedKeys`; matching caps light up. */\n  pressed?: ReadonlySet<string>;\n  size?: \"sm\" | \"md\";\n  className?: string;\n}\n\n/** A shortcut as key caps, with a spoken label for assistive technology. */\nexport function ShortcutKeys({ shortcut, platform: platformProp, pressed, size = \"md\", className }: ShortcutKeysProps) {\n  const platform = usePlatform(platformProp);\n  const tokens = shortcutTokens(shortcut, platform);\n  return (\n    <span\n      className={cn(\"inline-flex flex-none items-center gap-1\", className)}\n      role=\"img\"\n      aria-label={tokens.map((token) => token.spoken).join(\" \")}\n    >\n      {tokens.map((token, index) => (\n        <ShortcutKbd key={`${index}-${token.id}`} size={size} pressed={pressed?.has(token.id)} aria-hidden=\"true\">\n          {token.label}\n        </ShortcutKbd>\n      ))}\n    </span>\n  );\n}\n\n/* ================================================================================================\n * ShortcutRecorder\n * ============================================================================================== */\n\nexport interface ShortcutBinding {\n  shortcut: string;\n  label: string;\n}\n\nexport interface ShortcutRecorderProps {\n  label: string;\n  hideLabel?: boolean;\n  value?: string | null;\n  defaultValue?: string | null;\n  /** `replaced` is the binding the new shortcut was taken from after \"Use anyway\", so the caller can unbind it. */\n  onValueChange?: (value: string | null, details: { replaced?: ShortcutBinding }) => void;\n  /** What the reset button restores. Defaults to `defaultValue`. */\n  resetValue?: string | null;\n  /** Shortcuts already in use. Recording one of them asks before taking it. */\n  bindings?: ShortcutBinding[];\n  /** Also warn about combinations the browser or system keeps, such as ⌘W and ⌘C. On by default. */\n  warnReserved?: boolean;\n  /** Require ⌘, Ctrl, or ⌥ (function keys are always allowed). On by default. */\n  requireModifier?: boolean;\n  platform?: Platform;\n  placeholder?: string;\n  description?: string;\n  disabled?: boolean;\n  id?: string;\n  className?: string;\n}\n\nconst RESERVED: ShortcutBinding[] = [\n  { shortcut: \"mod+w\", label: \"Close tab\" },\n  { shortcut: \"mod+t\", label: \"New tab\" },\n  { shortcut: \"mod+n\", label: \"New window\" },\n  { shortcut: \"mod+q\", label: \"Quit\" },\n  { shortcut: \"mod+l\", label: \"Address bar\" },\n  { shortcut: \"mod+r\", label: \"Reload\" },\n  { shortcut: \"mod+shift+t\", label: \"Reopen tab\" },\n  { shortcut: \"mod+c\", label: \"Copy\" },\n  { shortcut: \"mod+v\", label: \"Paste\" },\n  { shortcut: \"mod+x\", label: \"Cut\" },\n  { shortcut: \"mod+z\", label: \"Undo\" },\n  { shortcut: \"mod+a\", label: \"Select all\" },\n];\n\ntype Pending = { shortcut: string; conflict: ShortcutBinding; reserved: boolean };\n\nconst chip: Variants = {\n  enter: { opacity: 0, scale: 0.72, y: 4, filter: `blur(${motionTokens.blur.subtle}px)` },\n  rest: (index: number) => ({\n    opacity: 1,\n    scale: 1,\n    y: 0,\n    filter: \"blur(0px)\",\n    transition: {\n      ...motionTokens.spring.snappy,\n      delay: index * motionTokens.stagger.item,\n      opacity: { duration: 0.14, delay: index * motionTokens.stagger.item },\n      filter: { duration: 0.16, delay: index * motionTokens.stagger.item },\n    },\n  }),\n  exit: { opacity: 0, scale: 0.8, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: 0.1 } },\n};\nconst actionButton =\n  \"grid size-8 cursor-pointer place-items-center rounded-button border-0 bg-transparent p-0 text-ink-3 [-webkit-tap-highlight-color:transparent] [transition:background-color_var(--duration-fast)_var(--ease-standard),color_var(--duration-fast)_var(--ease-standard)] hover:bg-hover hover:text-ink focus-visible:bg-hover focus-visible:text-ink focus-visible:outline-none active:bg-hover active:text-ink\";\nconst linkButton =\n  \"cursor-pointer border-0 bg-none p-0 font-medium text-ink underline decoration-line-strong underline-offset-3 [transition:text-decoration-color_var(--duration-fast)_var(--ease-standard),color_var(--duration-fast)_var(--ease-standard)] hover:decoration-current focus-visible:decoration-current focus-visible:outline-none\";\nconst chipFade: Variants = {\n  enter: { opacity: 0 },\n  rest: { opacity: 1, scale: 1, y: 0, filter: \"none\", transition: { duration: 0.1 } },\n  exit: { opacity: 0, transition: { duration: 0.06 } },\n};\n\n/**\n * A field that records a keyboard shortcut. Press it, then press the combination: held modifiers light up as caps while you\n * build the chord, the finished shortcut settles into place, and a combination that is already taken asks before it is used.\n * Escape cancels, Backspace clears, and the reset button restores the default. Caps follow the platform: ⌘ on a Mac, Ctrl elsewhere.\n */\nexport function ShortcutRecorder({\n  label,\n  hideLabel = false,\n  value,\n  defaultValue = null,\n  onValueChange,\n  resetValue,\n  bindings = [],\n  warnReserved = true,\n  requireModifier = true,\n  platform: platformProp,\n  placeholder = \"Record shortcut\",\n  description,\n  disabled = false,\n  id,\n  className,\n}: ShortcutRecorderProps) {\n  const reduced = useReducedFlag();\n  const platform = usePlatform(platformProp);\n  const uid = useId();\n  const buttonId = id ?? `${uid}-button`;\n  const labelId = `${uid}-label`,\n    messageId = `${uid}-message`;\n\n  const [inner, setInner] = useState<string | null>(defaultValue);\n  const current = value !== undefined ? value : inner;\n  const restoreTo = resetValue !== undefined ? resetValue : defaultValue;\n\n  const [recording, setRecording] = useState(false);\n  const [live, setLive] = useState<string[]>([]);\n  const [pending, setPending] = useState<Pending | null>(null);\n  const [nudge, setNudge] = useState<string | null>(null);\n  const [settled, setSettled] = useState(0);\n  const [announcement, setAnnouncement] = useState(\"\");\n  const buttonRef = useRef<HTMLButtonElement>(null);\n  /* The message slot animates to the measured height of its content, so a swap to a message that wraps grows instead of snapping. */\n  const [messageHeight, setMessageHeight] = useState<number | null>(null);\n  const measureMessage = useCallback((node: HTMLDivElement | null) => {\n    if (!node) return;\n    const observer = new ResizeObserver(() => setMessageHeight(node.getBoundingClientRect().height));\n    observer.observe(node);\n    return () => {\n      observer.disconnect();\n      setMessageHeight(null);\n    };\n  }, []);\n\n  const spoken = (shortcut: string | null) => (shortcut ? formatShortcut(shortcut, platform).spoken : \"none\");\n\n  const commit = useCallback(\n    (next: string | null, replaced?: ShortcutBinding) => {\n      setPending(null);\n      setNudge(null);\n      if (value === undefined) setInner(next);\n      onValueChange?.(next, { replaced });\n      setSettled((count) => count + 1);\n      setAnnouncement(next ? `Shortcut set to ${formatShortcut(next, platform).spoken}` : \"Shortcut cleared\");\n    },\n    [onValueChange, platform, value],\n  );\n\n  const start = () => {\n    if (disabled) return;\n    setPending(null);\n    setNudge(null);\n    setLive([]);\n    setRecording(true);\n    setAnnouncement(\"Recording. Press the new shortcut, or Escape to cancel.\");\n  };\n  const stop = () => {\n    setRecording(false);\n    setLive([]);\n  };\n\n  const conflictFor = (shortcut: string): Pending | null => {\n    const normal = normalizeShortcut(shortcut, platform);\n    if (current && normalizeShortcut(current, platform) === normal) return null;\n    const taken = bindings.find((binding) => normalizeShortcut(binding.shortcut, platform) === normal);\n    if (taken) return { shortcut, conflict: taken, reserved: false };\n    const reserved = warnReserved\n      ? RESERVED.find((binding) => normalizeShortcut(binding.shortcut, platform) === normal)\n      : undefined;\n    return reserved ? { shortcut, conflict: reserved, reserved: true } : null;\n  };\n\n  const liveMods = (event: ReactKeyboardEvent) => {\n    const mods: string[] = [];\n    if (event.ctrlKey && platform === \"mac\") mods.push(\"ctrl\");\n    if (event.altKey) mods.push(\"alt\");\n    if (event.shiftKey) mods.push(\"shift\");\n    if (platform === \"mac\" ? event.metaKey : event.ctrlKey) mods.push(\"mod\");\n    if (platform === \"other\" && event.metaKey) mods.push(\"meta\");\n    return mods;\n  };\n\n  function onKeyDown(event: ReactKeyboardEvent<HTMLButtonElement>) {\n    if (!recording) {\n      if ((event.key === \"Backspace\" || event.key === \"Delete\") && current) {\n        event.preventDefault();\n        commit(null);\n      }\n      return;\n    }\n    if (event.key === \"Tab\" && !event.shiftKey && !event.metaKey && !event.ctrlKey && !event.altKey) {\n      stop();\n      return;\n    }\n    // The chord belongs to the recorder: keep it from the page's own shortcuts, such as a ⌘K command menu.\n    event.preventDefault();\n    event.stopPropagation();\n    const bare = !event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey;\n    if (bare && event.key === \"Escape\") {\n      stop();\n      setAnnouncement(\"Recording canceled\");\n      return;\n    }\n    if (bare && (event.key === \"Backspace\" || event.key === \"Delete\")) {\n      stop();\n      commit(null);\n      return;\n    }\n    const shortcut = shortcutFromEvent(event.nativeEvent, platform);\n    if (!shortcut) {\n      setLive(liveMods(event));\n      return;\n    }\n    const { mods, key } = splitShortcut(shortcut);\n    const strong = mods.has(\"mod\") || mods.has(\"ctrl\") || mods.has(\"alt\") || mods.has(\"meta\");\n    if (requireModifier && !strong && !/^f\\d+$/.test(key)) {\n      const hint = platform === \"mac\" ? \"Include ⌘, ⌃, or ⌥\" : \"Include Ctrl or Alt\";\n      setNudge(hint);\n      setAnnouncement(hint);\n      setLive(liveMods(event));\n      return;\n    }\n    stop();\n    const conflict = conflictFor(shortcut);\n    if (conflict) {\n      setPending(conflict);\n      setAnnouncement(\n        `${formatShortcut(shortcut, platform).spoken} is ${conflict.reserved ? \"reserved for\" : \"used by\"} ${conflict.conflict.label}. Use anyway, or record another.`,\n      );\n      return;\n    }\n    commit(shortcut);\n  }\n  function onKeyUp(event: ReactKeyboardEvent<HTMLButtonElement>) {\n    if (recording) setLive(liveMods(event));\n  }\n\n  const shown = pending?.shortcut ?? current;\n  const tokens = recording ? shortcutTokens(live.join(\"+\"), platform) : shown ? shortcutTokens(shown, platform) : [];\n  const state = recording ? \"recording\" : pending ? \"conflict\" : \"idle\";\n  const canReset = !recording && (pending !== null || (current ?? null) !== (restoreTo ?? null));\n  const canClear = !recording && !!current && !pending;\n  const message = pending\n    ? {\n        tone: \"warning\" as const,\n        key: `c-${pending.shortcut}`,\n        body: (\n          <>\n            {pending.reserved ? \"Reserved for\" : \"Already used by\"}{\" \"}\n            <strong className=\"font-medium text-ink\">{pending.conflict.label}</strong>\n          </>\n        ),\n      }\n    : nudge\n      ? { tone: \"hint\" as const, key: `n-${nudge}`, body: <>{nudge}</> }\n      : recording\n        ? { tone: \"hint\" as const, key: \"rec\", body: <>Esc to cancel, Backspace to clear</> }\n        : description\n          ? { tone: \"hint\" as const, key: \"d\", body: <>{description}</> }\n          : null;\n\n  useEffect(() => {\n    if (!settled) return;\n    const timer = window.setTimeout(() => setSettled(0), 700);\n    return () => window.clearTimeout(timer);\n  }, [settled]);\n\n  return (\n    <div className={cn(\"grid min-w-0 font-body tracking-body\", className)} data-disabled={disabled || undefined}>\n      <span\n        id={labelId}\n        className={hideLabel ? \"sr-only\" : \"mb-2 justify-self-start text-sm leading-body font-medium text-ink\"}\n      >\n        {label}\n      </span>\n      <div\n        className={cn(\n          \"relative box-border flex h-control-md min-w-0 items-center rounded-control border border-line-strong bg-surface [transition:border-color_var(--duration-fast)_var(--ease-standard),background-color_var(--duration-fast)_var(--ease-standard)] motion-reduce:duration-0\",\n          state === \"idle\" && \"hover:border-ink\",\n          state === \"recording\" && \"border-accent bg-[color-mix(in_oklab,var(--accent)_4%,var(--surface))]\",\n          state === \"conflict\" && \"border-orange\",\n          \"has-[>button:focus-visible]:border-ink\",\n          disabled && \"bg-hover opacity-50\",\n        )}\n        data-state={state}\n      >\n        <button\n          ref={buttonRef}\n          id={buttonId}\n          type=\"button\"\n          className=\"flex h-full min-w-0 flex-1 cursor-pointer touch-manipulation items-center rounded-control border-0 bg-transparent pr-1.5 pl-[9px] text-left text-ink outline-none [-webkit-tap-highlight-color:transparent] disabled:cursor-not-allowed\"\n          disabled={disabled}\n          aria-labelledby={`${labelId} ${buttonId}`}\n          aria-describedby={message ? messageId : undefined}\n          aria-pressed={recording}\n          onClick={(event) => {\n            if (recording) {\n              if (event.detail !== 0) {\n                stop();\n                setAnnouncement(\"Recording canceled\");\n              }\n              return;\n            }\n            start();\n          }}\n          onKeyDown={onKeyDown}\n          onKeyUp={onKeyUp}\n          onBlur={() => {\n            if (recording) {\n              stop();\n              setNudge(null);\n            }\n          }}\n        >\n          <span className=\"sr-only\">{recording ? \"Recording\" : shown ? spoken(shown) : placeholder}</span>\n          <span className=\"relative flex min-w-0 items-center gap-1\" aria-hidden=\"true\">\n            {/* A recording light: steady in reduced motion, a slow breath otherwise. */}\n            {recording && (\n              <span\n                className={cn(\n                  \"mr-1.5 ml-[3px] size-[7px] flex-none rounded-full bg-accent\",\n                  reduced ? \"animate-none\" : \"animate-shortcut-recorder-breathe motion-reduce:animate-none\",\n                )}\n                data-reduced={reduced || undefined}\n              />\n            )}\n            <AnimatePresence initial={false} mode=\"popLayout\">\n              {tokens.map((token, index) => (\n                <motion.span\n                  key={`${token.id}`}\n                  layout={reduced ? false : \"position\"}\n                  custom={recording ? 0 : index}\n                  variants={reduced ? chipFade : chip}\n                  initial=\"enter\"\n                  animate=\"rest\"\n                  exit=\"exit\"\n                  transition={reduced ? { duration: 0 } : { layout: motionTokens.spring.snappy }}\n                  className=\"inline-flex\"\n                >\n                  <ShortcutKbd\n                    pressed={recording || (settled > 0 && !pending)}\n                    data-tone={pending ? \"warning\" : undefined}\n                    className={pending ? kbdWarning : undefined}\n                  >\n                    {token.label}\n                  </ShortcutKbd>\n                </motion.span>\n              ))}\n              {tokens.length === 0 && (\n                <motion.span\n                  key={recording ? \"prompt\" : \"placeholder\"}\n                  className={cn(\n                    \"pl-[3px] text-sm/[inherit] whitespace-nowrap\",\n                    recording ? \"text-ink-2\" : \"text-ink-3\",\n                  )}\n                  data-recording={recording || undefined}\n                  initial={\n                    reduced ? { opacity: 0 } : { opacity: 0, y: 4, filter: `blur(${motionTokens.blur.subtle}px)` }\n                  }\n                  animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n                  exit={{ opacity: 0, transition: { duration: 0.08 } }}\n                  transition={\n                    reduced\n                      ? { duration: 0.1 }\n                      : { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] }\n                  }\n                >\n                  {recording ? \"Press keys\" : placeholder}\n                </motion.span>\n              )}\n            </AnimatePresence>\n          </span>\n        </button>\n\n        <span className=\"flex flex-none items-center gap-0.5 pr-1\">\n          <AnimatePresence initial={false}>\n            {canReset && (\n              <motion.button\n                key=\"reset\"\n                type=\"button\"\n                className={actionButton}\n                aria-label={`Reset to ${restoreTo ? formatShortcut(restoreTo, platform).spoken : \"none\"}`}\n                onClick={() => {\n                  commit(restoreTo ?? null);\n                  buttonRef.current?.focus();\n                }}\n                initial={{ opacity: 0, scale: 0.6 }}\n                animate={{ opacity: 1, scale: 1 }}\n                exit={{ opacity: 0, scale: 0.6, transition: { duration: 0.1 } }}\n                transition={reduced ? { duration: 0 } : motionTokens.spring.snappy}\n              >\n                <RotateCcw size={16} strokeWidth={1.75} aria-hidden=\"true\" />\n              </motion.button>\n            )}\n            {canClear && (\n              <motion.button\n                key=\"clear\"\n                type=\"button\"\n                className={actionButton}\n                aria-label=\"Clear shortcut\"\n                onClick={() => {\n                  commit(null);\n                  buttonRef.current?.focus();\n                }}\n                initial={{ opacity: 0, scale: 0.6 }}\n                animate={{ opacity: 1, scale: 1 }}\n                exit={{ opacity: 0, scale: 0.6, transition: { duration: 0.1 } }}\n                transition={reduced ? { duration: 0 } : motionTokens.spring.snappy}\n              >\n                <X size={16} strokeWidth={1.75} aria-hidden=\"true\" />\n              </motion.button>\n            )}\n          </AnimatePresence>\n        </span>\n      </div>\n\n      <AnimatePresence initial={false}>\n        {message && (\n          <motion.div\n            key=\"slot\"\n            className=\"relative overflow-hidden\"\n            initial={reduced ? false : { height: 0, opacity: 0 }}\n            animate={{ height: messageHeight ?? \"auto\", opacity: 1 }}\n            exit={{\n              height: 0,\n              opacity: 0,\n              transition: reduced\n                ? { duration: 0 }\n                : { height: motionTokens.spring.smooth, opacity: { duration: 0.1 } },\n            }}\n            transition={\n              reduced\n                ? { duration: 0 }\n                : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.fast } }\n            }\n          >\n            <div ref={measureMessage}>\n              <AnimatePresence initial={false} mode=\"popLayout\">\n                <motion.div\n                  key={message.key}\n                  id={messageId}\n                  className={cn(\n                    \"flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5 pt-2 text-xs leading-body\",\n                    message.tone === \"warning\" ? \"text-ink-2\" : \"text-ink-3\",\n                  )}\n                  data-tone={message.tone}\n                  initial={\n                    reduced ? { opacity: 0 } : { opacity: 0, y: \"0.35em\", filter: `blur(${motionTokens.blur.soft}px)` }\n                  }\n                  animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n                  exit={{ opacity: 0, transition: { duration: 0.08 } }}\n                  transition={{\n                    duration: reduced ? 0.1 : motionTokens.duration.standard,\n                    ease: [...motionTokens.ease.enter],\n                  }}\n                >\n                  <span\n                    className={cn(\n                      message.tone === \"warning\" &&\n                        \"before:mt-0 before:mr-[7px] before:mb-px before:ml-px before:inline-block before:size-1.5 before:rounded-full before:bg-orange before:align-middle before:content-['']\",\n                    )}\n                  >\n                    {message.body}\n                  </span>\n                  {pending && (\n                    <span className=\"inline-flex gap-3\">\n                      <button\n                        type=\"button\"\n                        className={linkButton}\n                        onClick={() => {\n                          const taken = pending;\n                          commit(taken.shortcut, taken.reserved ? undefined : taken.conflict);\n                          buttonRef.current?.focus();\n                        }}\n                      >\n                        Use anyway\n                      </button>\n                      <button\n                        type=\"button\"\n                        className={cn(linkButton, \"font-normal text-ink-3 no-underline hover:text-ink\")}\n                        data-quiet=\"\"\n                        onClick={() => {\n                          setPending(null);\n                          setAnnouncement(\"Kept the previous shortcut\");\n                          buttonRef.current?.focus();\n                        }}\n                      >\n                        Cancel\n                      </button>\n                    </span>\n                  )}\n                </motion.div>\n              </AnimatePresence>\n            </div>\n          </motion.div>\n        )}\n      </AnimatePresence>\n      <output className=\"sr-only\" aria-live=\"polite\">\n        {announcement}\n      </output>\n    </div>\n  );\n}\n\n/* ================================================================================================\n * ShortcutList\n * ============================================================================================== */\n\nexport interface ShortcutListItem {\n  label: string;\n  shortcut: string;\n  /** Extra words that match when searching. */ keywords?: string;\n}\nexport interface ShortcutListGroup {\n  label: string;\n  items: ShortcutListItem[];\n}\n\nexport interface ShortcutListProps {\n  groups: ShortcutListGroup[];\n  /** Accessible name of the list. */\n  label?: string;\n  searchable?: boolean;\n  searchPlaceholder?: string;\n  /** Light up caps as keys are held, and fade rows that do not use the held modifiers. On by default. */\n  highlightPressed?: boolean;\n  platform?: Platform;\n  className?: string;\n}\n\n/**\n * A searchable, grouped cheatsheet. Holding a modifier lights that cap in every row and fades rows that do not use it, so people\n * 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\".\n */\nexport function ShortcutList({\n  groups,\n  label = \"Keyboard shortcuts\",\n  searchable = true,\n  searchPlaceholder = \"Search shortcuts\",\n  highlightPressed = true,\n  platform: platformProp,\n  className,\n}: ShortcutListProps) {\n  const reduced = useReducedFlag();\n  const platform = usePlatform(platformProp);\n  const held = usePressedKeys(highlightPressed);\n  const [query, setQuery] = useState(\"\");\n  const searchRef = useRef<HTMLInputElement>(null);\n\n  const heldMods = useMemo(() => [...held].filter((key) => [\"meta\", \"ctrl\", \"alt\", \"shift\"].includes(key)), [held]);\n  const words = query\n    .trim()\n    .toLowerCase()\n    .replace(/[⌘]/g, \" cmd \")\n    .replace(/[⇧]/g, \" shift \")\n    .replace(/[⌥]/g, \" alt \")\n    .replace(/[⌃]/g, \" ctrl \")\n    .split(/[\\s+]+/)\n    .filter(Boolean);\n  const aliases: Record<string, string> = {\n    cmd: \"command\",\n    command: \"command\",\n    ctrl: \"control\",\n    control: \"control\",\n    opt: \"option\",\n    option: platform === \"mac\" ? \"option\" : \"alt\",\n    alt: platform === \"mac\" ? \"option\" : \"alt\",\n    win: \"windows\",\n    esc: \"escape\",\n    return: platform === \"mac\" ? \"return\" : \"enter\",\n  };\n\n  const filtered = groups\n    .map((group) => ({\n      ...group,\n      items: group.items.filter((item) => {\n        if (!words.length) return true;\n        const tokens = shortcutTokens(item.shortcut, platform);\n        const haystack =\n          `${item.label} ${group.label} ${item.keywords ?? \"\"} ${tokens.map((token) => `${token.spoken} ${token.label}`).join(\" \")}`.toLowerCase();\n        return words.every((word) => haystack.includes(aliases[word] ?? word) || haystack.includes(word));\n      }),\n    }))\n    .filter((group) => group.items.length);\n\n  const total = filtered.reduce((sum, group) => sum + group.items.length, 0);\n  const rowTransition = reduced ? { duration: 0 } : motionTokens.spring.smooth;\n\n  return (\n    <section className={cn(\"@container grid min-w-0 gap-3 font-body tracking-body\", className)} aria-label={label}>\n      {searchable && (\n        <div className=\"box-border flex h-control-sm items-center gap-2 rounded-control border border-line bg-surface pr-1.5 pl-3 transition-[border-color] duration-fast ease-standard focus-within:border-ink\">\n          <Search className=\"flex-none text-ink-3\" size={16} strokeWidth={1.75} aria-hidden=\"true\" />\n          <input\n            ref={searchRef}\n            className=\"h-full min-w-0 flex-1 appearance-none border-0 bg-transparent p-0 text-sm/[inherit] text-ink outline-none placeholder:text-ink-3 [&::-webkit-search-cancel-button]:hidden\"\n            type=\"search\"\n            placeholder={searchPlaceholder}\n            value={query}\n            aria-label={searchPlaceholder}\n            onChange={(event) => setQuery(event.target.value)}\n            onKeyDown={(event) => {\n              if (event.key === \"Escape\" && query) {\n                event.preventDefault();\n                setQuery(\"\");\n              }\n            }}\n            autoComplete=\"off\"\n            spellCheck={false}\n          />\n          <span className=\"flex-none text-xs/[inherit] text-ink-3 tabular-nums\" aria-live=\"polite\">\n            {query ? `${total} ${total === 1 ? \"match\" : \"matches\"}` : \"\"}\n          </span>\n          <AnimatePresence initial={false}>\n            {query && (\n              <motion.button\n                key=\"clear\"\n                type=\"button\"\n                className=\"grid size-6 flex-none cursor-pointer place-items-center rounded-button border-0 bg-[color-mix(in_oklab,var(--ink)_8%,transparent)] p-0 text-ink-2 focus-visible:bg-[color-mix(in_oklab,var(--ink)_14%,transparent)] focus-visible:text-ink focus-visible:outline-none\"\n                aria-label=\"Clear search\"\n                onClick={() => {\n                  setQuery(\"\");\n                  searchRef.current?.focus();\n                }}\n                initial={{ opacity: 0, scale: 0.6 }}\n                animate={{ opacity: 1, scale: 1 }}\n                exit={{ opacity: 0, scale: 0.6, transition: { duration: 0.1 } }}\n                transition={reduced ? { duration: 0 } : motionTokens.spring.snappy}\n              >\n                <X size={14} strokeWidth={1.75} aria-hidden=\"true\" />\n              </motion.button>\n            )}\n          </AnimatePresence>\n        </div>\n      )}\n\n      <div className=\"grid items-start gap-x-6 gap-y-0 @min-[520px]:grid-cols-2\">\n        <AnimatePresence initial={false}>\n          {filtered.map((group) => (\n            <motion.section\n              key={group.label}\n              className=\"-mx-2 overflow-hidden px-2\"\n              aria-label={group.label}\n              initial={reduced ? { opacity: 0 } : { opacity: 0, height: 0 }}\n              animate={{ opacity: 1, height: \"auto\" }}\n              exit={reduced ? { opacity: 0 } : { opacity: 0, height: 0 }}\n              transition={rowTransition}\n            >\n              <h4 className=\"m-0 pt-3 pb-1 text-xs/[inherit] font-normal text-ink-3\">{group.label}</h4>\n              <ul className=\"m-0 grid list-none p-0\">\n                <AnimatePresence initial={false}>\n                  {group.items.map((item) => {\n                    const tokens = shortcutTokens(item.shortcut, platform);\n                    const ids = tokens.map((token) => token.id);\n                    const match = held.size > 0 && ids.every((key) => held.has(key));\n                    const dim = heldMods.length > 0 && !match && !heldMods.every((mod) => ids.includes(mod));\n                    return (\n                      <motion.li\n                        key={item.label}\n                        className=\"-mx-2 overflow-hidden\"\n                        data-match={match || undefined}\n                        data-dim={dim || undefined}\n                        initial={reduced ? { opacity: 0 } : { opacity: 0, height: 0 }}\n                        animate={{ opacity: 1, height: \"auto\" }}\n                        exit={reduced ? { opacity: 0 } : { opacity: 0, height: 0 }}\n                        transition={rowTransition}\n                      >\n                        {/* Rows fade when a held modifier is not in them, and light when their whole combination is down. */}\n                        <span\n                          className={cn(\n                            \"flex min-h-9 items-center justify-between gap-3 rounded-card px-2 [transition:opacity_var(--duration-standard)_var(--ease-standard),background-color_var(--duration-fast)_var(--ease-standard)] motion-reduce:duration-0 [li+li>&]:shadow-[inset_0_1px_0_var(--line-soft)] [li[data-match]+li>&]:shadow-none [li[data-match]>&]:shadow-none\",\n                            dim && \"opacity-[.38]\",\n                            match && \"bg-accent-tint\",\n                          )}\n                        >\n                          <span\n                            className={cn(\n                              \"min-w-0 truncate text-sm/[inherit] transition-[color] duration-fast ease-standard\",\n                              match ? \"text-ink\" : \"text-ink-2\",\n                            )}\n                          >\n                            {item.label}\n                          </span>\n                          <ShortcutKeys shortcut={item.shortcut} platform={platform} pressed={held} size=\"sm\" />\n                        </span>\n                      </motion.li>\n                    );\n                  })}\n                </AnimatePresence>\n              </ul>\n            </motion.section>\n          ))}\n        </AnimatePresence>\n        {total === 0 && <p className=\"m-0 py-4 text-sm/[inherit] text-ink-3\">No shortcuts match “{query.trim()}”</p>}\n      </div>\n    </section>\n  );\n}\n\nexport default ShortcutRecorder;\n",
      "type": "registry:component",
      "target": "@components/iris/shortcut-recorder/shortcut-recorder.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/shortcut-recorder",
  "meta": {
    "kind": "component",
    "group": "Inputs",
    "export": "ShortcutRecorder",
    "tags": [
      "inputs",
      "new"
    ],
    "docs": "https://irisui.dev/components/shortcut-recorder"
  }
}
