{
  "$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://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/shortcut-recorder/shortcut-recorder.tsx",
      "type": "registry:component",
      "target": "@components/xzero/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 styles from \"./shortcut-recorder.module.css\";\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 { /** What the key cap shows. */ label: string; /** Physical key id, matched against held keys. */ id: string; spoken: string }\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: \",\", Period: \".\", Slash: \"/\", Semicolon: \";\", Quote: \"'\", BracketLeft: \"[\", BracketRight: \"]\", Backslash: \"\\\\\", Minus: \"-\", Equal: \"=\", Backquote: \"`\",\n  Space: \"space\", Enter: \"enter\", NumpadEnter: \"enter\", Escape: \"escape\", Backspace: \"backspace\", Delete: \"delete\", Tab: \"tab\",\n  ArrowUp: \"up\", ArrowDown: \"down\", ArrowLeft: \"left\", ArrowRight: \"right\", Home: \"home\", End: \"end\", PageUp: \"pageup\", PageDown: \"pagedown\",\n};\nconst MODIFIER_KEYS: Record<string, string> = { Meta: \"meta\", OS: \"meta\", Control: \"ctrl\", Alt: \"alt\", AltGraph: \"alt\", Shift: \"shift\" };\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.toLowerCase().split(\"+\").map(part => part.trim()).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(event: Pick<KeyboardEvent, \"key\" | \"code\" | \"metaKey\" | \"ctrlKey\" | \"altKey\" | \"shiftKey\">, platform: Platform) {\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(event: Pick<KeyboardEvent, \"key\" | \"code\" | \"metaKey\" | \"ctrlKey\" | \"altKey\" | \"shiftKey\">, shortcut: string, platform: Platform) {\n  const pressed = shortcutFromEvent(event, platform);\n  return !!pressed && pressed === normalizeShortcut(shortcut, platform);\n}\n\nconst MAC_KEYS: Record<string, [string, string]> = { enter: [\"↩\", \"Return\"], backspace: [\"⌫\", \"Delete\"], delete: [\"⌦\", \"Forward delete\"], tab: [\"⇥\", \"Tab\"], escape: [\"esc\", \"Escape\"] };\nconst SHARED_KEYS: Record<string, [string, string]> = {\n  space: [\"Space\", \"Space\"], enter: [\"Enter\", \"Enter\"], escape: [\"Esc\", \"Escape\"], backspace: [\"Backspace\", \"Backspace\"], delete: [\"Del\", \"Delete\"], tab: [\"Tab\", \"Tab\"],\n  up: [\"↑\", \"Up arrow\"], down: [\"↓\", \"Down arrow\"], left: [\"←\", \"Left arrow\"], right: [\"→\", \"Right arrow\"], home: [\"Home\", \"Home\"], end: [\"End\", \"End\"], pageup: [\"PgUp\", \"Page up\"], pagedown: [\"PgDn\", \"Page down\"],\n  \",\": [\",\", \"Comma\"], \".\": [\".\", \"Period\"], \"/\": [\"/\", \"Slash\"], \";\": [\";\", \"Semicolon\"], \"'\": [\"'\", \"Quote\"], \"[\": [\"[\", \"Left bracket\"], \"]\": [\"]\", \"Right bracket\"], \"\\\\\": [\"\\\\\", \"Backslash\"], \"-\": [\"-\", \"Minus\"], \"=\": [\"=\", \"Equals\"], \"`\": [\"`\", \"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(named ? { label: named[0], id: key, spoken: named[1] } : { label: key.toUpperCase(), id: key, spoken: key.toUpperCase() });\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 { text: tokens.map(token => token.label).join(platform === \"mac\" ? \"\" : \"+\"), spoken: tokens.map(token => token.spoken).join(\" \") };\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(subscribe, () => true, () => false);\n  return !!useReducedMotion() && hydrated;\n}\nconst detectPlatform = (): Platform => {\n  if (typeof navigator === \"undefined\") return \"mac\";\n  const hint = (navigator as Navigator & { userAgentData?: { platform?: string } }).userAgentData?.platform ?? navigator.platform ?? 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) => setHeld(current => { const next = new Set(current); change(next); return next.size === current.size && [...next].every(key => current.has(key)) ? current : next; });\n    const down = (event: KeyboardEvent) => { const id = keyId(event); if (id) update(next => next.add(id)); };\n    const up = (event: KeyboardEvent) => {\n      const id = keyId(event);\n      update(next => {\n        if (id) next.delete(id);\n        if (id === \"meta\") [...next].forEach(key => { if (![\"ctrl\", \"alt\", \"shift\"].includes(key)) next.delete(key); });\n        if (!event.metaKey) next.delete(\"meta\"); if (!event.ctrlKey) next.delete(\"ctrl\"); if (!event.altKey) next.delete(\"alt\"); 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 () => { window.removeEventListener(\"keydown\", down, true); window.removeEventListener(\"keyup\", up, true); window.removeEventListener(\"blur\", clear); document.removeEventListener(\"visibilitychange\", clear); };\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. Use it inline in copy, in menus, or through `ShortcutKeys` for a whole combination. */\nexport const ShortcutKbd = forwardRef<HTMLElement, ShortcutKbdProps>(function ShortcutKbd({ children, pressed = false, size = \"md\", className, ...props }, ref) {\n  return <kbd ref={ref} {...props} className={[styles.kbd, className].filter(Boolean).join(\" \")} data-size={size} data-pressed={pressed || undefined}>{children}</kbd>;\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 <span className={[styles.keys, className].filter(Boolean).join(\" \")} role=\"img\" aria-label={tokens.map(token => token.spoken).join(\" \")}>\n    {tokens.map((token, index) => <ShortcutKbd key={`${index}-${token.id}`} size={size} pressed={pressed?.has(token.id)} aria-hidden=\"true\">{token.label}</ShortcutKbd>)}\n  </span>;\n}\n\n/* ================================================================================================\n * ShortcutRecorder\n * ============================================================================================== */\n\nexport interface ShortcutBinding { shortcut: string; label: string }\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\" }, { shortcut: \"mod+t\", label: \"New tab\" }, { shortcut: \"mod+n\", label: \"New window\" }, { shortcut: \"mod+q\", label: \"Quit\" },\n  { shortcut: \"mod+l\", label: \"Address bar\" }, { shortcut: \"mod+r\", label: \"Reload\" }, { shortcut: \"mod+shift+t\", label: \"Reopen tab\" },\n  { shortcut: \"mod+c\", label: \"Copy\" }, { shortcut: \"mod+v\", label: \"Paste\" }, { shortcut: \"mod+x\", label: \"Cut\" }, { shortcut: \"mod+z\", label: \"Undo\" }, { 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: .72, y: 4, filter: `blur(${motionTokens.blur.subtle}px)` },\n  rest: (index: number) => ({ opacity: 1, scale: 1, y: 0, filter: \"blur(0px)\", transition: { ...motionTokens.spring.snappy, delay: index * motionTokens.stagger.item, opacity: { duration: .14, delay: index * motionTokens.stagger.item }, filter: { duration: .16, delay: index * motionTokens.stagger.item } } }),\n  exit: { opacity: 0, scale: .8, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: .1 } },\n};\nconst chipFade: Variants = { enter: { opacity: 0 }, rest: { opacity: 1, scale: 1, y: 0, filter: \"none\", transition: { duration: .1 } }, exit: { opacity: 0, transition: { duration: .06 } } };\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, hideLabel = false, value, defaultValue = null, onValueChange, resetValue, bindings = [], warnReserved = true, requireModifier = true,\n  platform: platformProp, placeholder = \"Record shortcut\", description, disabled = false, id, 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`, 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 () => { observer.disconnect(); setMessageHeight(null); };\n  }, []);\n\n  const spoken = (shortcut: string | null) => shortcut ? formatShortcut(shortcut, platform).spoken : \"none\";\n\n  const commit = useCallback((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  }, [onValueChange, platform, value]);\n\n  const start = () => {\n    if (disabled) return;\n    setPending(null); setNudge(null); setLive([]);\n    setRecording(true);\n    setAnnouncement(\"Recording. Press the new shortcut, or Escape to cancel.\");\n  };\n  const stop = () => { setRecording(false); setLive([]); };\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 ? RESERVED.find(binding => normalizeShortcut(binding.shortcut, platform) === normal) : 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) { event.preventDefault(); commit(null); }\n      return;\n    }\n    if (event.key === \"Tab\" && !event.shiftKey && !event.metaKey && !event.ctrlKey && !event.altKey) { stop(); return; }\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\") { stop(); setAnnouncement(\"Recording canceled\"); return; }\n    if (bare && (event.key === \"Backspace\" || event.key === \"Delete\")) { stop(); commit(null); return; }\n    const shortcut = shortcutFromEvent(event.nativeEvent, platform);\n    if (!shortcut) { setLive(liveMods(event)); return; }\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); setAnnouncement(hint); setLive(liveMods(event));\n      return;\n    }\n    stop();\n    const conflict = conflictFor(shortcut);\n    if (conflict) {\n      setPending(conflict);\n      setAnnouncement(`${formatShortcut(shortcut, platform).spoken} is ${conflict.reserved ? \"reserved for\" : \"used by\"} ${conflict.conflict.label}. Use anyway, or record another.`);\n      return;\n    }\n    commit(shortcut);\n  }\n  function onKeyUp(event: ReactKeyboardEvent<HTMLButtonElement>) { if (recording) setLive(liveMods(event)); }\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    ? { tone: \"warning\" as const, key: `c-${pending.shortcut}`, body: <>{pending.reserved ? \"Reserved for\" : \"Already used by\"} <strong className={styles.strong}>{pending.conflict.label}</strong></> }\n    : nudge ? { tone: \"hint\" as const, key: `n-${nudge}`, body: <>{nudge}</> }\n    : recording ? { tone: \"hint\" as const, key: \"rec\", body: <>Esc to cancel, Backspace to clear</> }\n    : description ? { tone: \"hint\" as const, key: \"d\", body: <>{description}</> } : 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 <div className={[styles.field, className].filter(Boolean).join(\" \")} data-disabled={disabled || undefined}>\n    <span id={labelId} className={hideLabel ? styles.srOnly : styles.label}>{label}</span>\n    <div className={styles.control} data-state={state}>\n      <button ref={buttonRef} id={buttonId} type=\"button\" className={styles.recorder} disabled={disabled} aria-labelledby={`${labelId} ${buttonId}`} aria-describedby={message ? messageId : undefined}\n        aria-pressed={recording}\n        onClick={event => { if (recording) { if (event.detail !== 0) { stop(); setAnnouncement(\"Recording canceled\"); } return; } start(); }}\n        onKeyDown={onKeyDown} onKeyUp={onKeyUp} onBlur={() => { if (recording) { stop(); setNudge(null); } }}>\n        <span className={styles.srOnly}>{recording ? \"Recording\" : shown ? spoken(shown) : placeholder}</span>\n        <span className={styles.row} aria-hidden=\"true\">\n          {recording && <span className={styles.dot} data-reduced={reduced || undefined} />}\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            {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\"\n              transition={reduced ? { duration: 0 } : { layout: motionTokens.spring.snappy }} className={styles.chipSlot}>\n              <ShortcutKbd pressed={recording || (settled > 0 && !pending)} data-tone={pending ? \"warning\" : undefined}>{token.label}</ShortcutKbd>\n            </motion.span>)}\n            {tokens.length === 0 && <motion.span key={recording ? \"prompt\" : \"placeholder\"} className={styles.placeholder} data-recording={recording || undefined}\n              initial={reduced ? { opacity: 0 } : { opacity: 0, y: 4, filter: `blur(${motionTokens.blur.subtle}px)` }} animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n              exit={{ opacity: 0, transition: { duration: .08 } }} transition={reduced ? { duration: .1 } : { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] }}>\n              {recording ? \"Press keys\" : placeholder}\n            </motion.span>}\n          </AnimatePresence>\n        </span>\n      </button>\n\n      <span className={styles.actions}>\n        <AnimatePresence initial={false}>\n          {canReset && <motion.button key=\"reset\" type=\"button\" className={styles.action} aria-label={`Reset to ${restoreTo ? formatShortcut(restoreTo, platform).spoken : \"none\"}`}\n            onClick={() => { commit(restoreTo ?? null); buttonRef.current?.focus(); }}\n            initial={{ opacity: 0, scale: .6 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: .6, transition: { duration: .1 } }} transition={reduced ? { duration: 0 } : motionTokens.spring.snappy}>\n            <RotateCcw size={16} strokeWidth={1.75} aria-hidden=\"true\" />\n          </motion.button>}\n          {canClear && <motion.button key=\"clear\" type=\"button\" className={styles.action} aria-label=\"Clear shortcut\" onClick={() => { commit(null); buttonRef.current?.focus(); }}\n            initial={{ opacity: 0, scale: .6 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: .6, transition: { duration: .1 } }} transition={reduced ? { duration: 0 } : motionTokens.spring.snappy}>\n            <X size={16} strokeWidth={1.75} aria-hidden=\"true\" />\n          </motion.button>}\n        </AnimatePresence>\n      </span>\n    </div>\n\n    <AnimatePresence initial={false}>\n      {message && <motion.div key=\"slot\" className={styles.messageSlot} initial={reduced ? false : { height: 0, opacity: 0 }} animate={{ height: messageHeight ?? \"auto\", opacity: 1 }}\n        exit={{ height: 0, opacity: 0, transition: reduced ? { duration: 0 } : { height: motionTokens.spring.smooth, opacity: { duration: .1 } } }}\n        transition={reduced ? { duration: 0 } : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.fast } }}>\n        <div ref={measureMessage}>\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            <motion.div key={message.key} id={messageId} className={styles.message} data-tone={message.tone}\n              initial={reduced ? { opacity: 0 } : { opacity: 0, y: \"0.35em\", filter: `blur(${motionTokens.blur.soft}px)` }} animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n              exit={{ opacity: 0, transition: { duration: .08 } }} transition={{ duration: reduced ? .1 : motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }}>\n              <span className={styles.messageText}>{message.body}</span>\n              {pending && <span className={styles.messageActions}>\n                <button type=\"button\" className={styles.link} onClick={() => { const taken = pending; commit(taken.shortcut, taken.reserved ? undefined : taken.conflict); buttonRef.current?.focus(); }}>Use anyway</button>\n                <button type=\"button\" className={styles.link} data-quiet=\"\" onClick={() => { setPending(null); setAnnouncement(\"Kept the previous shortcut\"); buttonRef.current?.focus(); }}>Cancel</button>\n              </span>}\n            </motion.div>\n          </AnimatePresence>\n        </div>\n      </motion.div>}\n    </AnimatePresence>\n    <span className={styles.srOnly} role=\"status\" aria-live=\"polite\">{announcement}</span>\n  </div>;\n}\n\n/* ================================================================================================\n * ShortcutList\n * ============================================================================================== */\n\nexport interface ShortcutListItem { label: string; shortcut: string; /** Extra words that match when searching. */ keywords?: string }\nexport interface ShortcutListGroup { label: string; items: ShortcutListItem[] }\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({ groups, label = \"Keyboard shortcuts\", searchable = true, searchPlaceholder = \"Search shortcuts\", highlightPressed = true, platform: platformProp, className }: 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.trim().toLowerCase().replace(/[⌘]/g, \" cmd \").replace(/[⇧]/g, \" shift \").replace(/[⌥]/g, \" alt \").replace(/[⌃]/g, \" ctrl \").split(/[\\s+]+/).filter(Boolean);\n  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\" };\n\n  const filtered = groups.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 = `${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  })).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 <section className={[styles.list, className].filter(Boolean).join(\" \")} aria-label={label}>\n    {searchable && <div className={styles.searchRow}>\n      <Search className={styles.searchIcon} size={16} strokeWidth={1.75} aria-hidden=\"true\" />\n      <input ref={searchRef} className={styles.search} type=\"search\" placeholder={searchPlaceholder} value={query} aria-label={searchPlaceholder} onChange={event => setQuery(event.target.value)}\n        onKeyDown={event => { if (event.key === \"Escape\" && query) { event.preventDefault(); setQuery(\"\"); } }} autoComplete=\"off\" spellCheck={false} />\n      <span className={styles.count} aria-live=\"polite\">{query ? `${total} ${total === 1 ? \"match\" : \"matches\"}` : \"\"}</span>\n      <AnimatePresence initial={false}>\n        {query && <motion.button key=\"clear\" type=\"button\" className={styles.clear} aria-label=\"Clear search\" onClick={() => { setQuery(\"\"); searchRef.current?.focus(); }}\n          initial={{ opacity: 0, scale: .6 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: .6, transition: { duration: .1 } }} transition={reduced ? { duration: 0 } : motionTokens.spring.snappy}>\n          <X size={14} strokeWidth={1.75} aria-hidden=\"true\" />\n        </motion.button>}\n      </AnimatePresence>\n    </div>}\n\n    <div className={styles.groups}>\n      <AnimatePresence initial={false}>\n        {filtered.map(group => <motion.section key={group.label} className={styles.group} aria-label={group.label}\n          initial={reduced ? { opacity: 0 } : { opacity: 0, height: 0 }} animate={{ opacity: 1, height: \"auto\" }} exit={reduced ? { opacity: 0 } : { opacity: 0, height: 0 }} transition={rowTransition}>\n          <h4 className={styles.groupLabel}>{group.label}</h4>\n          <ul className={styles.rows}>\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 <motion.li key={item.label} className={styles.item} data-match={match || undefined} data-dim={dim || undefined}\n                  initial={reduced ? { opacity: 0 } : { opacity: 0, height: 0 }} animate={{ opacity: 1, height: \"auto\" }} exit={reduced ? { opacity: 0 } : { opacity: 0, height: 0 }} transition={rowTransition}>\n                  <span className={styles.itemInner}>\n                    <span className={styles.itemLabel}>{item.label}</span>\n                    <ShortcutKeys shortcut={item.shortcut} platform={platform} pressed={held} size=\"sm\" />\n                  </span>\n                </motion.li>;\n              })}\n            </AnimatePresence>\n          </ul>\n        </motion.section>)}\n      </AnimatePresence>\n      {total === 0 && <p className={styles.empty}>No shortcuts match “{query.trim()}”</p>}\n    </div>\n  </section>;\n}\n\nexport default ShortcutRecorder;\n"
    },
    {
      "path": "registry/components/shortcut-recorder/shortcut-recorder.module.css",
      "type": "registry:component",
      "target": "@components/xzero/shortcut-recorder/shortcut-recorder.module.css",
      "content": "/* ---------------------------------------------- Kbd ---------------------------------------------- */\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. */\n.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); }\n.kbd[data-size=\"sm\"] { min-width: 22px; height: 22px; padding: 0 5px 1px; border-radius: 7px; font-size: var(--text-xs); }\n.kbd[data-pressed] { transform: translateY(1px); border-color: color-mix(in oklab, var(--accent) 55%, var(--border-strong)); border-bottom-width: 1px; background: var(--accent-subtle); color: var(--accent-strong); transition-duration: 90ms; }\n.kbd[data-tone=\"warning\"] { border-color: color-mix(in oklab, var(--warning) 55%, var(--border-strong)); background: color-mix(in oklab, var(--warning) 12%, var(--surface-raised)); color: var(--foreground); }\n.keys { display: inline-flex; flex: none; align-items: center; gap: 4px; }\n\n/* ---------------------------------------------- Recorder ---------------------------------------------- */\n\n.field { display: grid; min-width: 0; font-family: var(--font-body); letter-spacing: var(--tracking-body); }\n.label { justify-self: start; margin-bottom: var(--space-2); color: var(--foreground); font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); }\n\n.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); }\n@media (hover: hover) and (pointer: fine) { .control[data-state=\"idle\"]:hover { border-color: var(--foreground); } }\n.control:has(.recorder:focus-visible) { border-color: var(--foreground); }\n.control[data-state=\"recording\"] { border-color: var(--accent); background: color-mix(in oklab, var(--accent) 4%, var(--surface)); }\n.control[data-state=\"conflict\"] { border-color: var(--warning); }\n.field[data-disabled] .control { opacity: .5; background: var(--surface-muted); }\n\n.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; }\n.recorder:disabled { cursor: not-allowed; }\n.row { position: relative; display: flex; min-width: 0; align-items: center; gap: 4px; }\n.chipSlot { display: inline-flex; }\n.placeholder { padding-left: 3px; color: var(--text-muted); font-size: var(--text-sm); white-space: nowrap; }\n.placeholder[data-recording] { color: var(--text-secondary); }\n/* A recording light: steady in reduced motion, a slow breath otherwise. */\n.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; }\n.dot[data-reduced] { animation: none; }\n@keyframes breathe { 50% { opacity: .35; } }\n\n.actions { display: flex; flex: none; align-items: center; gap: 2px; padding-right: 4px; }\n.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); }\n@media (hover: hover) and (pointer: fine) { .action:hover { background: var(--surface-muted); color: var(--foreground); } }\n.action:active, .action:focus-visible { background: var(--surface-muted); color: var(--foreground); outline: none; }\n\n.messageSlot { position: relative; overflow: hidden; }\n.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); }\n.message[data-tone=\"hint\"] { color: var(--text-muted); }\n.message[data-tone=\"warning\"] { color: var(--text-secondary); }\n.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; }\n.strong { color: var(--foreground); font-weight: 500; }\n.messageActions { display: inline-flex; gap: var(--space-3); }\n.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); }\n.link:focus-visible { text-decoration-color: currentColor; outline: none; }\n.link[data-quiet] { color: var(--text-muted); font-weight: 400; text-decoration: none; }\n@media (hover: hover) and (pointer: fine) { .link:hover { text-decoration-color: currentColor; } .link[data-quiet]:hover { color: var(--foreground); } }\n\n/* ---------------------------------------------- Cheatsheet ---------------------------------------------- */\n\n.list { container-type: inline-size; display: grid; min-width: 0; gap: var(--space-3); font-family: var(--font-body); letter-spacing: var(--tracking-body); }\n.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); border-radius: var(--radius-control); background: var(--surface); transition: border-color var(--duration-fast) var(--ease-standard); }\n.searchRow:focus-within { border-color: var(--foreground); }\n.searchIcon { flex: none; color: var(--text-muted); }\n.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; }\n.search::placeholder { color: var(--text-muted); }\n.search::-webkit-search-cancel-button { display: none; }\n.count { flex: none; color: var(--text-muted); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }\n.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; }\n.clear:focus-visible { background: color-mix(in oklab, var(--foreground) 14%, transparent); color: var(--foreground); outline: none; }\n\n.groups { display: grid; align-items: start; gap: 0 var(--space-6); }\n@container (min-width: 520px) { .groups { grid-template-columns: 1fr 1fr; } }\n.group { overflow: hidden; margin-inline: -8px; padding-inline: 8px; }\n.groupLabel { margin: 0; padding: var(--space-3) 0 var(--space-1); color: var(--text-muted); font-size: var(--text-xs); font-weight: 400; }\n.rows { display: grid; margin: 0; padding: 0; list-style: none; }\n.item { overflow: hidden; margin-inline: -8px; }\n/* Rows fade when a held modifier is not in them, and light when their whole combination is down. */\n.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); }\n.item + .item .itemInner { box-shadow: inset 0 1px 0 var(--border-subtle); }\n.item[data-dim] .itemInner { opacity: .38; }\n.item[data-match] .itemInner { background: var(--accent-subtle); box-shadow: none; }\n.item[data-match] + .item .itemInner { box-shadow: none; }\n.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); }\n.item[data-match] .itemLabel { color: var(--foreground); }\n.empty { margin: 0; padding: var(--space-4) 0; color: var(--text-muted); font-size: var(--text-sm); }\n\n.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }\n\n@media (prefers-reduced-motion: reduce) {\n  .kbd, .control, .itemInner { transition-duration: 0ms; }\n  .kbd[data-pressed] { transform: none; }\n  .dot { animation: none; }\n}\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/shortcut-recorder",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "inputs",
      "new"
    ],
    "docs": "https://ui.xzero.co.uk/components/shortcut-recorder",
    "markdown": "https://ui.xzero.co.uk/components/shortcut-recorder/markdown"
  }
}