{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"shortcut-recorder","type":"registry:component","title":"Shortcut recorder","description":"Press keys to set a shortcut: keycaps appear as you hold them, and it tells you when a combo is taken.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/shortcut-recorder.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useRef, useState, useSyncExternalStore } from \"react\";\nimport { AnimatePresence, animate, motion } from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { usePreviewPlay } from \"@/lib/preview-play\";\nimport { cn } from \"@/lib/utils\";\n\n// A shortcut is a list of tokens: modifiers first, then one key. \"Mod\" is\n// ⌘ on a Mac and Ctrl elsewhere, so one saved shortcut works on both.\nexport type Shortcut = string[];\n\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\nconst noop = () => () => {};\nconst isMac = () => /Mac|iPhone|iPad/.test(navigator.platform);\n\nconst MODIFIER_KEYS = new Set([\"Meta\", \"Control\", \"Alt\", \"Shift\", \"OS\"]);\nconst ARROWS: Record<string, string> = {\n  ArrowUp: \"↑\",\n  ArrowDown: \"↓\",\n  ArrowLeft: \"←\",\n  ArrowRight: \"→\",\n};\n\n// Held modifiers in the order each platform prints them.\nfunction modifiers(e: KeyboardEvent, mac: boolean): string[] {\n  if (mac) {\n    return [\n      e.ctrlKey && \"Ctrl\",\n      e.altKey && \"Alt\",\n      e.shiftKey && \"Shift\",\n      e.metaKey && \"Mod\",\n    ].filter(Boolean) as string[];\n  }\n  return [e.ctrlKey && \"Mod\", e.altKey && \"Alt\", e.shiftKey && \"Shift\"].filter(\n    Boolean,\n  ) as string[];\n}\n\n// Reads the physical key, so Option+K on a Mac records K, not \"˚\".\nfunction keyName(e: KeyboardEvent) {\n  if (e.code.startsWith(\"Key\")) return e.code.slice(3);\n  if (e.code.startsWith(\"Digit\")) return e.code.slice(5);\n  if (ARROWS[e.key]) return ARROWS[e.key];\n  if (e.key === \" \") return \"Space\";\n  if (e.key === \"Enter\") return \"↵\";\n  if (e.key.length === 1) return e.key.toUpperCase();\n  return e.key;\n}\n\nfunction label(token: string, mac: boolean) {\n  if (token === \"Mod\") return mac ? \"⌘\" : \"Ctrl\";\n  if (token === \"Ctrl\") return \"⌃\";\n  if (token === \"Alt\") return mac ? \"⌥\" : \"Alt\";\n  if (token === \"Shift\") return mac ? \"⇧\" : \"Shift\";\n  return token;\n}\n\nconst same = (a: Shortcut, b: Shortcut) =>\n  a.length === b.length && a.every((t, i) => t === b[i]);\n\nexport function ShortcutRecorder({\n  name,\n  value,\n  onChange,\n  taken,\n  demo,\n  className,\n}: {\n  // What the shortcut does, e.g. \"Open command menu\".\n  name: string;\n  value: Shortcut;\n  onChange: (next: Shortcut) => void;\n  // Returns the action already using a combination, if any.\n  taken?: (combo: Shortcut) => string | null;\n  // Drives the field from outside (the card preview) without listening.\n  demo?: { recording: boolean; held: Shortcut } | null;\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion();\n  const mac = useSyncExternalStore(noop, isMac, () => false);\n  const [recording, setRecording] = useState(false);\n  const [held, setHeld] = useState<Shortcut>([]);\n  const [error, setError] = useState<{ text: string } | null>(null);\n  const [settled, setSettled] = useState(0);\n  const field = useRef<HTMLButtonElement>(null);\n  const errorTimer = useRef<ReturnType<typeof setTimeout>>(undefined);\n\n  const isRecording = demo ? demo.recording : recording;\n  const shownHeld = demo ? demo.held : held;\n\n  const fail = (text: string) => {\n    clearTimeout(errorTimer.current);\n    setError({ text });\n    errorTimer.current = setTimeout(() => setError(null), 2200);\n    // A small shake when a combination isn't accepted.\n    if (field.current && !reduceMotion) {\n      animate(field.current, { x: [0, -4, 4, -3, 3, 0] }, { duration: 0.32, ease: \"easeOut\" });\n    }\n  };\n\n  // While recording, every key belongs to the field: it's taken before the\n  // page (or the lab's own ⌘K) can act on it.\n  const latest = useRef({ mac, value, onChange, taken, fail });\n  useEffect(() => {\n    latest.current = { mac, value, onChange, taken, fail };\n  });\n  useEffect(() => {\n    if (!recording) return;\n    const stop = () => {\n      setRecording(false);\n      setHeld([]);\n    };\n    const onDown = (e: KeyboardEvent) => {\n      e.preventDefault();\n      e.stopImmediatePropagation();\n      const { mac, value, onChange, taken, fail } = latest.current;\n      const mods = modifiers(e, mac);\n      if (MODIFIER_KEYS.has(e.key)) return setHeld(mods);\n      if (!mods.length && e.key === \"Escape\") return stop();\n      if (!mods.length && (e.key === \"Backspace\" || e.key === \"Delete\")) {\n        onChange([]);\n        return stop();\n      }\n      const key = keyName(e);\n      if (!mods.length && !/^F\\d{1,2}$/.test(key)) {\n        return fail(`Add ${mac ? \"⌘, ⌥ or ⇧\" : \"Ctrl, Alt or Shift\"}`);\n      }\n      const combo = [...mods, key];\n      const owner = same(combo, value) ? null : taken?.(combo);\n      if (owner) return fail(`Used by ${owner}`);\n      onChange(combo);\n      setSettled((s) => s + 1);\n      stop();\n    };\n    const onUp = (e: KeyboardEvent) => {\n      e.stopImmediatePropagation();\n      setHeld(modifiers(e, latest.current.mac));\n    };\n    const onAway = (e: Event) => {\n      if (e.type === \"blur\" || !field.current?.contains(e.target as Node)) {\n        stop();\n      }\n    };\n    window.addEventListener(\"keydown\", onDown, true);\n    window.addEventListener(\"keyup\", onUp, true);\n    window.addEventListener(\"blur\", onAway);\n    document.addEventListener(\"pointerdown\", onAway);\n    return () => {\n      window.removeEventListener(\"keydown\", onDown, true);\n      window.removeEventListener(\"keyup\", onUp, true);\n      window.removeEventListener(\"blur\", onAway);\n      document.removeEventListener(\"pointerdown\", onAway);\n    };\n  }, [recording]);\n\n  useEffect(() => () => clearTimeout(errorTimer.current), []);\n\n  const caps = isRecording ? shownHeld : value;\n  const fade = reduceMotion ? { duration: 0 } : { duration: 0.18, ease: EASE_OUT };\n\n  return (\n    <div className={cn(\"flex items-center justify-between gap-4\", className)}>\n      <div className=\"relative min-w-0\">\n        <p className=\"text-[14px] text-foreground\">{name}</p>\n        {/* Hangs below the name without taking room, so names stay\n            centred against their fields. */}\n        <div className=\"absolute inset-x-0 top-full h-4\">\n          <AnimatePresence initial={false}>\n            {error && (\n              <motion.p\n                key={error.text}\n                role=\"status\"\n                initial={{ opacity: 0, y: -2, filter: \"blur(3px)\" }}\n                animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n                exit={{ opacity: 0, transition: { duration: 0.12 } }}\n                transition={fade}\n                className=\"absolute inset-x-0 top-0.5 truncate text-[12px] text-destructive\"\n              >\n                {error.text}\n              </motion.p>\n            )}\n          </AnimatePresence>\n        </div>\n      </div>\n\n      <motion.button\n        ref={field}\n        type=\"button\"\n        aria-label={`${name} shortcut: ${value.length ? value.map((t) => label(t, mac)).join(\" \") : \"none\"}. ${isRecording ? \"Recording, press the new keys, Escape to cancel, Backspace to clear.\" : \"Press to change.\"}`}\n        aria-pressed={isRecording}\n        onClick={() => {\n          if (demo) return;\n          setError(null);\n          setRecording((r) => !r);\n          setHeld([]);\n        }}\n        className={cn(\n          \"flex h-9 min-w-24 shrink-0 items-center justify-end gap-1 rounded-[10px] px-1.5 outline-hidden transition-[background-color,box-shadow] duration-150 ease-out\",\n          // Recording already draws a dark edge; a focus ring on top of it\n          // would double up.\n          isRecording\n            ? \"bg-background shadow-[inset_0_0_0_1.5px_var(--foreground)]\"\n            : \"bg-muted shadow-[inset_0_0_0_1px_var(--border)] hover:bg-background focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-foreground\",\n        )}\n      >\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {caps.length ? (\n            caps.map((t, i) => (\n              <motion.kbd\n                key={`${t}-${settled}`}\n                layout=\"position\"\n                initial={\n                  isRecording\n                    ? { opacity: 0, scale: 0.8, filter: \"blur(3px)\" }\n                    : { opacity: 0.6, y: -3, scale: 1.08 }\n                }\n                animate={{ opacity: 1, y: 0, scale: 1, filter: \"blur(0px)\" }}\n                exit={{ opacity: 0, scale: 0.8, transition: { duration: 0.1 } }}\n                transition={\n                  reduceMotion\n                    ? { duration: 0 }\n                    : { type: \"spring\", duration: 0.3, bounce: 0.3, delay: isRecording ? 0 : i * 0.03 }\n                }\n                className=\"grid h-6 min-w-6 place-items-center rounded-md bg-background px-1.5 font-sans text-[12px] font-medium text-foreground shadow-[inset_0_0_0_1px_var(--border),0_1px_0_var(--border)]\"\n              >\n                {label(t, mac)}\n              </motion.kbd>\n            ))\n          ) : (\n            <motion.span\n              key={isRecording ? \"listening\" : \"none\"}\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1 }}\n              exit={{ opacity: 0, transition: { duration: 0.08 } }}\n              transition={fade}\n              className=\"flex items-center gap-1.5 px-1.5 text-[13px] text-muted-foreground\"\n            >\n              {isRecording ? (\n                <>\n                  <span\n                    aria-hidden\n                    className=\"size-1.5 rounded-full bg-destructive\"\n                    style={{\n                      animation: reduceMotion ? \"none\" : \"sr-blink 1.1s ease-in-out infinite\",\n                    }}\n                  />\n                  Press keys\n                </>\n              ) : (\n                \"None\"\n              )}\n            </motion.span>\n          )}\n        </AnimatePresence>\n      </motion.button>\n      <style>{`@keyframes sr-blink { 0%, 100% { opacity: 1 } 50% { opacity: .25 } }`}</style>\n    </div>\n  );\n}\n\nconst ROWS = [\n  { id: \"palette\", name: \"Open command menu\", keys: [\"Mod\", \"K\"] },\n  { id: \"note\", name: \"New note\", keys: [\"Mod\", \"N\"] },\n  { id: \"sidebar\", name: \"Toggle sidebar\", keys: [\"Mod\", \"B\"] },\n];\n\nexport default function ShortcutRecorderDemo() {\n  const play = usePreviewPlay();\n  const [keys, setKeys] = useState<Record<string, Shortcut>>(() =>\n    Object.fromEntries(ROWS.map((r) => [r.id, r.keys])),\n  );\n  const [demo, setDemo] = useState<{ recording: boolean; held: Shortcut } | null>(\n    null,\n  );\n\n  // The card's hover show: the first shortcut is re-recorded as ⇧⌘P, key\n  // by key, then set back.\n  useEffect(() => {\n    if (play !== true) return;\n    const timers: ReturnType<typeof setTimeout>[] = [];\n    const at = (ms: number, fn: () => void) => timers.push(setTimeout(fn, ms));\n    const record = (base: number, held: Shortcut[], final: Shortcut) => {\n      at(base, () => setDemo({ recording: true, held: [] }));\n      held.forEach((h, i) => at(base + 500 + i * 380, () => setDemo({ recording: true, held: h })));\n      at(base + 500 + held.length * 380, () => {\n        setKeys((k) => ({ ...k, palette: final }));\n        setDemo({ recording: false, held: [] });\n      });\n    };\n    const run = () => {\n      record(300, [[\"Mod\"], [\"Shift\", \"Mod\"]], [\"Shift\", \"Mod\", \"P\"]);\n      record(3000, [[\"Mod\"]], [\"Mod\", \"K\"]);\n      at(5600, run);\n    };\n    run();\n    return () => {\n      timers.forEach(clearTimeout);\n      setDemo(null);\n      setKeys(Object.fromEntries(ROWS.map((r) => [r.id, r.keys])));\n    };\n  }, [play]);\n\n  return (\n    <div className=\"w-[min(420px,100%)] rounded-2xl bg-background p-2 shadow-raised\">\n      <p className=\"px-4 pt-3 pb-1 text-[12px] font-medium text-muted-foreground\">\n        Keyboard shortcuts\n      </p>\n      <div className=\"divide-y divide-border\">\n        {ROWS.map((row, i) => (\n          <ShortcutRecorder\n            key={row.id}\n            className=\"px-4 py-3\"\n            name={row.name}\n            value={keys[row.id]}\n            onChange={(next) => setKeys((k) => ({ ...k, [row.id]: next }))}\n            taken={(combo) =>\n              ROWS.find((r) => r.id !== row.id && same(keys[r.id], combo))\n                ?.name ?? null\n            }\n            demo={play === true && i === 0 ? demo ?? { recording: false, held: [] } : null}\n          />\n        ))}\n      </div>\n    </div>\n  );\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"},{"path":"lib/preview-play.ts","type":"registry:lib","target":"lib/preview-play.ts","content":"\"use client\";\n\nimport { createContext, useContext } from \"react\";\n\n// Lets a demo know whether it's showing in an index card, and whether that\n// card is hovered or focused. Demos use it to act themselves out on hover:\n// a star button stars itself, a checkbox scribbles, and so on.\n//\n// null: not in a preview (the component's own page, or real use).\n// false: in a preview, at rest.\n// true: in a preview, being hovered or focused: play the show.\nexport const PreviewPlayContext = createContext<boolean | null>(null);\n\nexport function usePreviewPlay() {\n  return useContext(PreviewPlayContext);\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["inputs"],"docs":"From ui lab: https://lab.xevrion.dev/lab/shortcut-recorder"}