{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "shortcut-recorder",
  "title": "Shortcut Recorder",
  "description": "The settings field that records a keyboard shortcut: the user presses the combination they want and it is captured as a value you can store. Use it on a keyboard shortcuts settings screen, a key-binding or keymap editor, a command palette's \"change shortcut\" row, an accessibility panel, an admin or dev tool where power users rebind keys, or anywhere a command's keys are configurable per user. Common asks it answers: \"record a keyboard shortcut React\", \"hotkey input component\", \"keybinding recorder\", \"let users set their own shortcut\", \"capture key combination input\", \"shortcut picker like VS Code / Linear / Notion\", \"keyboard shortcut settings UI\", \"press-any-key input field\", \"rebind hotkey component\", \"shortcut conflict with Cmd+W\", \"how to store a shortcut so it works on Mac and Windows\", \"shadcn keyboard shortcut input\", \"ショートカットキー 設定 ui\", \"キーバインド 変更 react\", \"キーボードショートカット 記録 コンポーネント\", \"ショートカット 割り当て 画面\". shadcn/ui ships nothing that receives a key press as a setting — its kbd only draws a cap — so every shortcut settings screen hand-rolls this, and the hand-rolled version is wrong in the same four ways each time. The identity is `KeyboardEvent.code`, the physical key, never the character: record `Shift+/` by its character and the setting says \"?\" while the matching press reports \"/\", so it never fires again. Alt+n on a Mac has the same problem. Modifiers are stored as roles, not keys — `mod` is the platform accelerator, so a combo recorded on a Mac as ⌘K is read back on Windows as Ctrl+K rather than as a Command key nobody has. `matchesShortcut(event, shortcut)` ships with it, so the setting you stored can be matched at run time by the same rules that recorded it, and `shortcutTokens` emits the exact token vocabulary the keyboard-shortcuts component's `keys` accepts, so a recording goes straight into your help sheet and into kbd caps. A modifier held alone is not a combination (commit on it and no shortcut containing Shift can ever be recorded), auto-repeat is ignored so leaning on a key does not record it, and a press inside an IME composition is ignored. Recording listens on `document` in the capture phase, because your own hotkey library is listening too and a bubble-phase handler would let ⌘K open the command palette while the user was trying to record ⌘K. What capture cannot do is reach above the page, and the component says so rather than pretending: ⌘W still closes the tab and ⌘Tab still switches application whatever is preventDefault'ed, so a short reserved list (DEFAULT_RESERVED, extensible by spreading it) refuses to save those combos and names whether the browser or the operating system takes them — the user never ends up with a setting that closes their tab every time they use it. Escape cancels and Tab leaves with focus moving normally, both announced in the control's accessible name, so the field is not a keyboard trap; a window blur ends recording because the keyup for the press that moved focus never arrives, and keeping that state would record ⌘K from a bare K. The caps are aria-hidden and the control is named in words (\"Keyboard shortcut: Command Shift K\") because a screen reader meeting ⌘ announces \"place of interest sign\" or nothing at all; saves and refusals land in a role=status live region rather than only in colour. Controlled or uncontrolled, every label overridable, optional requireModifier, zero npm dependencies, shadcn tokens throughout for light and dark. Composes kbd for the caps. Distinct from kbd (draws a key cap), keyboard-shortcuts (the ? help sheet that lists shortcuts) and command-palette (⌘K launcher): this is the only one that takes a key press from the user and gives you back a value to save.",
  "registryDependencies": [
    "https://pulld.pages.dev/r/kbd.json"
  ],
  "files": [
    {
      "path": "registry/ui/shortcut-recorder.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Kbd } from \"@/registry/ui/kbd\"\n\n/**\n * One recorded key combination, in a shape that survives being written to a database and read back\n * on another machine.\n *\n * `code` is `KeyboardEvent.code` — the physical key, not the character it produced. Recording the\n * character instead is the mistake that looks fine until somebody records `Shift+/`: the event\n * arrives with `key: \"?\"`, so the setting says \"?\" and never matches again, because at match time\n * the unshifted event carries `key: \"/\"`. The same goes for `Alt+n` on a Mac, which produces `\"˜\"`.\n * `code` is stable under every modifier, so the recording and the match agree.\n *\n * Modifiers are stored as *roles*, not as physical keys. `mod` is the platform's accelerator — ⌘ on\n * Apple platforms, Ctrl everywhere else — so a combo recorded on a Mac is still the right combo on\n * Windows. `ctrl` and `meta` are the literal keys, used when the accelerator is not what was\n * pressed (Ctrl+K on a Mac) or when `normalizeMod` is off.\n */\nexport interface Shortcut {\n  /** `KeyboardEvent.code`, e.g. `\"KeyK\"`, `\"Slash\"`, `\"ArrowUp\"`. */\n  code: string\n  /** The platform accelerator: ⌘ on Apple platforms, Ctrl elsewhere. */\n  mod?: boolean\n  /** Literal Control, when it is not the accelerator. */\n  ctrl?: boolean\n  /** Literal ⌘ / Windows key, when it is not the accelerator. */\n  meta?: boolean\n  alt?: boolean\n  shift?: boolean\n  /**\n   * What was printed on the key the user actually pressed, when that could be read off the event\n   * honestly — so a French keyboard shows `A` where the code says `KeyQ`. Display only: matching\n   * never looks at it.\n   */\n  label?: string\n}\n\n/** Which platform's key names to speak and draw. */\nexport interface PlatformOptions {\n  /** True on macOS, iOS and iPadOS. Defaults to false so server and client render the same markup. */\n  apple?: boolean\n}\n\n/** The modifiers a combo needs physically held, once `mod` is resolved for a platform. */\nexport interface PhysicalModifiers {\n  meta: boolean\n  ctrl: boolean\n  alt: boolean\n  shift: boolean\n}\n\n/**\n * Codes that are a modifier rather than a key to record. A press of one of these is \"not a\n * combination yet\": committing on it would make Shift alone a shortcut, which means no combination\n * involving Shift could ever be recorded — the field would close before the second key arrived.\n *\n * CapsLock is here because it latches rather than being held, and the lock keys because an event\n * from them says the lock changed, not that the user chose a key.\n */\nexport const MODIFIER_CODES: ReadonlySet<string> = new Set([\n  \"ControlLeft\",\n  \"ControlRight\",\n  \"ShiftLeft\",\n  \"ShiftRight\",\n  \"AltLeft\",\n  \"AltRight\",\n  \"MetaLeft\",\n  \"MetaRight\",\n  \"CapsLock\",\n  \"NumLock\",\n  \"ScrollLock\",\n  \"Fn\",\n  \"FnLock\",\n])\n\n/** `KeyboardEvent.key` values that mean a modifier, for events that report no usable `code`. */\nconst MODIFIER_KEYS: ReadonlySet<string> = new Set([\n  \"Control\",\n  \"Shift\",\n  \"Alt\",\n  \"Meta\",\n  \"AltGraph\",\n  \"CapsLock\",\n  \"NumLock\",\n  \"ScrollLock\",\n  \"Dead\",\n  \"Fn\",\n  \"FnLock\",\n])\n\n/** Only the fields of a keyboard event this component reads. */\nexport interface KeyPress {\n  code?: string\n  key?: string\n  metaKey?: boolean\n  ctrlKey?: boolean\n  altKey?: boolean\n  shiftKey?: boolean\n  repeat?: boolean\n  isComposing?: boolean\n}\n\n/** True when the press is a modifier being held rather than a key being chosen. */\nexport function isModifierPress(e: KeyPress): boolean {\n  if (e.code && MODIFIER_CODES.has(e.code)) return true\n  return !!e.key && MODIFIER_KEYS.has(e.key)\n}\n\n/**\n * The modifiers currently held, as roles.\n *\n * `normalizeMod` folds the platform accelerator into `mod`; with it off, the literal keys are\n * reported and a Mac recording stays a Mac recording.\n */\nexport function heldModifiers(\n  e: KeyPress,\n  { apple = false, normalizeMod = true }: PlatformOptions & { normalizeMod?: boolean } = {}\n): Pick<Shortcut, \"mod\" | \"ctrl\" | \"meta\" | \"alt\" | \"shift\"> {\n  const accelerator = apple ? !!e.metaKey : !!e.ctrlKey\n  const out: Pick<Shortcut, \"mod\" | \"ctrl\" | \"meta\" | \"alt\" | \"shift\"> = {}\n  if (normalizeMod && accelerator) out.mod = true\n  if (e.ctrlKey && !(normalizeMod && !apple && accelerator)) out.ctrl = true\n  if (e.metaKey && !(normalizeMod && apple && accelerator)) out.meta = true\n  if (e.altKey) out.alt = true\n  if (e.shiftKey) out.shift = true\n  return out\n}\n\n/** True when at least one modifier is held. */\nexport function hasModifier(mods: Pick<Shortcut, \"mod\" | \"ctrl\" | \"meta\" | \"alt\" | \"shift\">): boolean {\n  return !!(mods.mod || mods.ctrl || mods.meta || mods.alt || mods.shift)\n}\n\n/**\n * Resolves a combo's modifier roles to the keys that have to be physically down on this platform.\n *\n * Both matching and the reserved-combo check go through here, so \"⌘K recorded on a Mac\" and\n * \"Ctrl+K on Windows\" are the same combo exactly once, in one place.\n */\nexport function physicalModifiers(\n  shortcut: Pick<Shortcut, \"mod\" | \"ctrl\" | \"meta\" | \"alt\" | \"shift\">,\n  { apple = false }: PlatformOptions = {}\n): PhysicalModifiers {\n  return {\n    meta: !!shortcut.meta || (!!shortcut.mod && apple),\n    ctrl: !!shortcut.ctrl || (!!shortcut.mod && !apple),\n    alt: !!shortcut.alt,\n    shift: !!shortcut.shift,\n  }\n}\n\n/**\n * Builds the combo a press describes, or null when the press is not one.\n *\n * Null comes back for a modifier held on its own, for an auto-repeat (holding a key down would\n * otherwise commit it the moment the repeat started), for a press that is part of an IME\n * composition, and for an event with no usable `code` — a soft keyboard that reports\n * `code: \"\"` can be recorded, but the combo it produces could never match anything, so saving it\n * would hand the user a shortcut that silently does nothing.\n */\nexport function shortcutFromEvent(\n  e: KeyPress,\n  { apple = false, normalizeMod = true }: PlatformOptions & { normalizeMod?: boolean } = {}\n): Shortcut | null {\n  if (e.repeat || e.isComposing) return null\n  if (isModifierPress(e)) return null\n  if (!e.code || e.code === \"Unidentified\") return null\n\n  const shift = !!e.shiftKey\n  const alt = !!e.altKey\n  // The character on the event is only the character on the user's keyboard while nothing is\n  // reshaping it. Shift turns `/` into `?`, and Option on a Mac turns `n` into a dead key, so in\n  // those cases the code table's US-layout name is the honest answer and this is left unset.\n  const label =\n    !shift && !alt && typeof e.key === \"string\" && [...e.key].length === 1\n      ? e.key.toUpperCase()\n      : undefined\n\n  return { code: e.code, ...heldModifiers(e, { apple, normalizeMod }), ...(label ? { label } : {}) }\n}\n\n/** True when `event` is the press this combo was recorded for. */\nexport function matchesShortcut(e: KeyPress, shortcut: Shortcut, options: PlatformOptions = {}): boolean {\n  if (!e.code || e.code !== shortcut.code) return false\n  const need = physicalModifiers(shortcut, options)\n  return (\n    !!e.metaKey === need.meta &&\n    !!e.ctrlKey === need.ctrl &&\n    !!e.altKey === need.alt &&\n    !!e.shiftKey === need.shift\n  )\n}\n\n/** True when two combos are the same combination on this platform. */\nexport function sameShortcut(a: Shortcut, b: Shortcut, options: PlatformOptions = {}): boolean {\n  if (a.code !== b.code) return false\n  const left = physicalModifiers(a, options)\n  const right = physicalModifiers(b, options)\n  return (\n    left.meta === right.meta &&\n    left.ctrl === right.ctrl &&\n    left.alt === right.alt &&\n    left.shift === right.shift\n  )\n}\n\n// --- what the keys are called ---------------------------------------------------------------------\n\n/** Codes whose name is not derivable, mapped to a token `kbd` and keyboard-shortcuts both know. */\nconst CODE_LABELS: Record<string, string> = {\n  Space: \"Space\",\n  Enter: \"Enter\",\n  NumpadEnter: \"Enter\",\n  Escape: \"Esc\",\n  Tab: \"Tab\",\n  Backspace: \"Backspace\",\n  Delete: \"Delete\",\n  Insert: \"Ins\",\n  Home: \"Home\",\n  End: \"End\",\n  PageUp: \"PgUp\",\n  PageDown: \"PgDn\",\n  ArrowUp: \"Up\",\n  ArrowDown: \"Down\",\n  ArrowLeft: \"Left\",\n  ArrowRight: \"Right\",\n  Minus: \"-\",\n  Equal: \"=\",\n  BracketLeft: \"[\",\n  BracketRight: \"]\",\n  Backslash: \"\\\\\",\n  IntlBackslash: \"\\\\\",\n  IntlRo: \"\\\\\",\n  IntlYen: \"¥\",\n  Semicolon: \";\",\n  Quote: \"'\",\n  Backquote: \"`\",\n  Comma: \",\",\n  Period: \".\",\n  Slash: \"/\",\n  NumpadAdd: \"Num +\",\n  NumpadSubtract: \"Num -\",\n  NumpadMultiply: \"Num *\",\n  NumpadDivide: \"Num /\",\n  NumpadDecimal: \"Num .\",\n}\n\n/** The US-layout name of a physical key, for display when the event's own character was not usable. */\nexport function labelForCode(code: string): string {\n  const known = CODE_LABELS[code]\n  if (known) return known\n  const letter = /^Key([A-Z])$/.exec(code)\n  if (letter) return letter[1]\n  const digit = /^Digit(\\d)$/.exec(code)\n  if (digit) return digit[1]\n  const numpad = /^Numpad(\\d)$/.exec(code)\n  if (numpad) return `Num ${numpad[1]}`\n  const fn = /^F(\\d{1,2})$/.exec(code)\n  if (fn) return `F${fn[1]}`\n  return code\n}\n\n/** Drawn form and spoken form of one token, per platform. */\ntype TokenForm = { apple: readonly [string, string]; other: readonly [string, string] }\n\n/**\n * Mirrors the table in keyboard-shortcuts on purpose: the tokens this returns are meant to be\n * handed straight to that component's `keys`, so the two have to name keys the same way.\n */\nconst TOKEN_FORMS: Record<string, TokenForm> = {\n  mod: { apple: [\"⌘\", \"Command\"], other: [\"Ctrl\", \"Control\"] },\n  ctrl: { apple: [\"⌃\", \"Control\"], other: [\"Ctrl\", \"Control\"] },\n  meta: { apple: [\"⌘\", \"Command\"], other: [\"Win\", \"Windows key\"] },\n  alt: { apple: [\"⌥\", \"Option\"], other: [\"Alt\", \"Alt\"] },\n  shift: { apple: [\"⇧\", \"Shift\"], other: [\"Shift\", \"Shift\"] },\n  enter: { apple: [\"↩\", \"Enter\"], other: [\"Enter\", \"Enter\"] },\n  esc: { apple: [\"Esc\", \"Escape\"], other: [\"Esc\", \"Escape\"] },\n  tab: { apple: [\"⇥\", \"Tab\"], other: [\"Tab\", \"Tab\"] },\n  backspace: { apple: [\"⌫\", \"Backspace\"], other: [\"Backspace\", \"Backspace\"] },\n  delete: { apple: [\"⌦\", \"Delete\"], other: [\"Del\", \"Delete\"] },\n  space: { apple: [\"Space\", \"Space\"], other: [\"Space\", \"Space\"] },\n  up: { apple: [\"↑\", \"Up arrow\"], other: [\"↑\", \"Up arrow\"] },\n  down: { apple: [\"↓\", \"Down arrow\"], other: [\"↓\", \"Down arrow\"] },\n  left: { apple: [\"←\", \"Left arrow\"], other: [\"←\", \"Left arrow\"] },\n  right: { apple: [\"→\", \"Right arrow\"], other: [\"→\", \"Right arrow\"] },\n}\n\n/**\n * The combo as tokens, in the order that platform writes them.\n *\n * Apple writes ⌃⌥⇧⌘ with the command key against the letter; Windows and Linux write\n * Ctrl+Alt+Shift+Key with the accelerator first. Emitting one fixed order would be visibly wrong on\n * one of the two. The tokens themselves are the vocabulary keyboard-shortcuts accepts, so a recorded\n * combo can go straight into its `keys` array.\n */\nexport function shortcutTokens(shortcut: Shortcut, { apple = false }: PlatformOptions = {}): string[] {\n  const tokens: string[] = []\n  if (apple) {\n    if (shortcut.ctrl) tokens.push(\"Ctrl\")\n    if (shortcut.alt) tokens.push(\"Alt\")\n    if (shortcut.shift) tokens.push(\"Shift\")\n    if (shortcut.mod) tokens.push(\"Mod\")\n    if (shortcut.meta) tokens.push(\"Meta\")\n  } else {\n    if (shortcut.mod) tokens.push(\"Mod\")\n    if (shortcut.ctrl) tokens.push(\"Ctrl\")\n    if (shortcut.meta) tokens.push(\"Meta\")\n    if (shortcut.alt) tokens.push(\"Alt\")\n    if (shortcut.shift) tokens.push(\"Shift\")\n  }\n  tokens.push(shortcut.label || labelForCode(shortcut.code))\n  return tokens\n}\n\n/** Drawn and spoken form of one token. */\nexport function tokenForm(token: string, { apple = false }: PlatformOptions = {}): {\n  display: string\n  spoken: string\n} {\n  const entry = TOKEN_FORMS[token.trim().toLowerCase()]\n  if (entry) {\n    const [display, spoken] = apple ? entry.apple : entry.other\n    return { display, spoken }\n  }\n  const display = [...token].length === 1 ? token.toUpperCase() : token\n  return { display, spoken: display }\n}\n\n/**\n * The combo in words, for a screen reader and for a rejection message.\n *\n * A screen reader meeting \"⌘\" announces \"place of interest sign\" or nothing at all, so the drawn\n * caps are never the accessible name — this is.\n */\nexport function formatShortcut(shortcut: Shortcut, options: PlatformOptions = {}): string {\n  return shortcutTokens(shortcut, options)\n    .map((token) => tokenForm(token, options).spoken)\n    .join(\" \")\n}\n\n// --- combos that cannot be taken ------------------------------------------------------------------\n\n/**\n * A combination the page is not allowed to have.\n *\n * The honest framing matters here. While recording, this component takes every press away from the\n * page so the app's own hotkeys do not fire mid-recording — but `preventDefault` does not reach the\n * browser's own ⌘W or the system's ⌘Tab, and it never will. Nothing a component does can stop the\n * tab closing. What it can do is refuse to *save* such a combo, so the user is not left with a\n * setting that destroys their work every time they use it, and say which layer took it.\n */\nexport interface ReservedCombo {\n  code: string\n  mod?: boolean\n  ctrl?: boolean\n  meta?: boolean\n  alt?: boolean\n  shift?: boolean\n  /** Who takes the press: the browser's own UI, or the operating system. */\n  owner: \"browser\" | \"system\"\n  /** Set when the combo is only reserved on one platform. */\n  platform?: \"apple\" | \"other\"\n}\n\n/**\n * The combinations a browser tab cannot be given, as a starting point.\n *\n * Deliberately short: every entry is a combo that is both unpreventable in a normal tab and costly\n * when it fires — a closed tab, a quit app, a switched window. Keys that merely have a default the\n * page *can* prevent (⌘S, ⌘P, ⌘F) are not here, because taking those over is exactly what an app\n * with a shortcut settings screen is for.\n */\nexport const DEFAULT_RESERVED: readonly ReservedCombo[] = [\n  // Closes the tab or the window, under both accelerators, with the work in it.\n  { code: \"KeyW\", mod: true, owner: \"browser\" },\n  { code: \"KeyW\", mod: true, shift: true, owner: \"browser\" },\n  // Opens a tab or a window over the app.\n  { code: \"KeyT\", mod: true, owner: \"browser\" },\n  { code: \"KeyT\", mod: true, shift: true, owner: \"browser\" },\n  { code: \"KeyN\", mod: true, owner: \"browser\" },\n  { code: \"KeyN\", mod: true, shift: true, owner: \"browser\" },\n  // Moves between tabs on Windows and Linux; switches application on a Mac.\n  { code: \"Tab\", mod: true, platform: \"other\", owner: \"browser\" },\n  { code: \"Tab\", mod: true, platform: \"apple\", owner: \"system\" },\n  { code: \"Tab\", alt: true, platform: \"other\", owner: \"system\" },\n  { code: \"F4\", alt: true, platform: \"other\", owner: \"system\" },\n  // Quits, hides or minimises the browser; the page is never consulted.\n  { code: \"KeyQ\", mod: true, platform: \"apple\", owner: \"system\" },\n  { code: \"KeyQ\", mod: true, shift: true, platform: \"apple\", owner: \"system\" },\n  { code: \"KeyH\", mod: true, platform: \"apple\", owner: \"system\" },\n  { code: \"KeyM\", mod: true, platform: \"apple\", owner: \"system\" },\n  { code: \"Space\", mod: true, platform: \"apple\", owner: \"system\" },\n  // Opens the browser's own settings.\n  { code: \"Comma\", mod: true, platform: \"apple\", owner: \"browser\" },\n]\n\n/** The reserved entry a combo collides with, or null when it is free. */\nexport function reservedMatch(\n  shortcut: Shortcut,\n  { apple = false, reserved = DEFAULT_RESERVED }: PlatformOptions & { reserved?: readonly ReservedCombo[] } = {}\n): ReservedCombo | null {\n  const need = physicalModifiers(shortcut, { apple })\n  for (const combo of reserved) {\n    if (combo.platform === \"apple\" && !apple) continue\n    if (combo.platform === \"other\" && apple) continue\n    if (combo.code !== shortcut.code) continue\n    const theirs = physicalModifiers(combo, { apple })\n    if (\n      theirs.meta === need.meta &&\n      theirs.ctrl === need.ctrl &&\n      theirs.alt === need.alt &&\n      theirs.shift === need.shift\n    ) {\n      return combo\n    }\n  }\n  return null\n}\n\n/** Why a press was not saved. */\nexport interface ShortcutRejection {\n  kind: \"reserved\" | \"needs-modifier\" | \"unidentified\"\n  /** The combo that was turned down. Absent when the press produced no usable combo at all. */\n  shortcut: Shortcut | null\n  /** The sentence shown to the user and announced. */\n  reason: string\n  /** Who takes the press, for a `\"reserved\"` rejection. */\n  owner?: \"browser\" | \"system\"\n}\n\nexport interface ShortcutRecorderLabels {\n  /** Names the control for a screen reader; the recorded combo is appended to it. */\n  field: string\n  /** Stands in for the combo in that name while nothing is recorded. */\n  empty: string\n  /** Shown in place of the caps while nothing is recorded. */\n  placeholder: string\n  /** Shown in place of the caps while recording, before any modifier is held. */\n  prompt: string\n  /** The control's accessible name while recording. */\n  recording: string\n  /** The clear button's accessible name. */\n  clear: string\n  /** The clear button's visible text. Kept inside `clear`, so speaking the label matches the text. */\n  clearText: string\n  /** Announced once a combo is saved; the combo in words follows it. */\n  saved: string\n  /** Shown when the press was a bare key and `requireModifier` is on. */\n  needsModifier: string\n  /** Shown when the press carried no identifiable key. */\n  unidentified: string\n  /** Shown when the browser keeps the combo; the combo in words precedes it. */\n  reservedByBrowser: string\n  /** Shown when the operating system keeps the combo. */\n  reservedBySystem: string\n}\n\nexport const defaultShortcutRecorderLabels: ShortcutRecorderLabels = {\n  field: \"Keyboard shortcut\",\n  empty: \"none set\",\n  placeholder: \"Not set\",\n  prompt: \"Press keys…\",\n  recording: \"Recording a keyboard shortcut. Press the combination you want, Escape to cancel, Tab to leave.\",\n  clear: \"Clear this keyboard shortcut\",\n  clearText: \"Clear\",\n  saved: \"Saved\",\n  needsModifier: \"Add ⌘, Ctrl, Alt or Shift to make a shortcut.\",\n  unidentified: \"That key could not be identified. Try another one.\",\n  reservedByBrowser: \"is the browser's own shortcut and cannot be reassigned here.\",\n  reservedBySystem: \"belongs to the operating system and cannot be reassigned here.\",\n}\n\nexport interface ShortcutRecorderProps\n  extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"onChange\" | \"defaultValue\" | \"children\"> {\n  /** The recorded combo. Pass it to control the component; omit to let it own its state. */\n  value?: Shortcut | null\n  /** Starting combo when uncontrolled. */\n  defaultValue?: Shortcut | null\n  /** Called with the new combo, or null when it is cleared. */\n  onChange?: (shortcut: Shortcut | null) => void\n  /** Called when a press was turned down, with the sentence the user was shown. */\n  onReject?: (rejection: ShortcutRejection) => void\n  /** Called when recording starts and stops, for a parent that dims the rest of the form. */\n  onRecordingChange?: (recording: boolean) => void\n  /**\n   * Store the platform accelerator as `mod` rather than as the literal ⌘ or Ctrl, so the setting\n   * means the same thing on the next machine. On by default.\n   */\n  normalizeMod?: boolean\n  /** Turn down bare keys, for an app whose shortcuts all carry a modifier. Off by default. */\n  requireModifier?: boolean\n  /** Combos that cannot be saved. Spread `DEFAULT_RESERVED` to add to it rather than replace it. */\n  reserved?: readonly ReservedCombo[]\n  /** Override platform detection. Leave unset outside tests. */\n  apple?: boolean\n  disabled?: boolean\n  /** Show the button that unsets the combo. On by default. */\n  clearable?: boolean\n  labels?: Partial<ShortcutRecorderLabels>\n}\n\n/**\n * The field that records a keyboard shortcut: the user presses the combination they want and it is\n * captured as a setting. For a shortcut settings screen, a key-binding editor, an accessibility\n * panel, or anywhere a command's keys are configurable.\n *\n * Recording has to take the keyboard away from everything else for as long as it lasts, which is the\n * whole difficulty. The listener is on `document` in the capture phase, because the app's own hotkey\n * library is listening too and a bubble-phase handler on this element would let ⌘K open the command\n * palette while the user was trying to record ⌘K. Capture gets there first and the press stops\n * there. What capture cannot do is reach the layer above the page: ⌘W still closes the tab and ⌘Tab\n * still switches application, whatever this component calls `preventDefault` on. That is why there\n * is a reserved list instead of a promise — see {@link DEFAULT_RESERVED}.\n *\n * Two keys keep their meaning while recording, on purpose. Escape cancels, because a field that\n * swallows the universal escape hatch is a trap. Tab leaves and moves focus, because a recorder that\n * captured Tab would be a keyboard dead end — the press is kept from the app, but its default is\n * allowed, so focus moves exactly as it would anywhere else. Both are documented in the control's\n * accessible name, which is what a screen reader reads on entering it.\n *\n * Auto-repeat is ignored: holding a key down delivers a stream of presses, and committing on the\n * first would record whatever key the user happened to be leaning on. A press while an IME is\n * composing is ignored for the same reason.\n *\n * Window blur ends recording. This is not tidiness — the keyup for the press that moved focus away\n * is never delivered, so a component that kept its own modifier state across a blur would come back\n * believing ⌘ was still held and record ⌘K from a bare K.\n *\n * The caps are `aria-hidden` and the control is named in words (\"Keyboard shortcut: Command Shift\n * K\"), because a screen reader meeting ⌘ announces \"place of interest sign\" or nothing at all.\n * Rejections and saves land in a live region rather than only in colour.\n */\nexport const ShortcutRecorder = React.forwardRef<HTMLDivElement, ShortcutRecorderProps>(\n  function ShortcutRecorder(\n    {\n      value: valueProp,\n      defaultValue = null,\n      onChange,\n      onReject,\n      onRecordingChange,\n      normalizeMod = true,\n      requireModifier = false,\n      reserved = DEFAULT_RESERVED,\n      apple: appleProp,\n      disabled = false,\n      clearable = true,\n      labels: labelsProp,\n      className,\n      ...props\n    },\n    ref\n  ) {\n    const labels = { ...defaultShortcutRecorderLabels, ...labelsProp }\n\n    const [uncontrolled, setUncontrolled] = React.useState<Shortcut | null>(defaultValue)\n    const isControlled = valueProp !== undefined\n    const value = isControlled ? valueProp : uncontrolled\n\n    // `navigator` does not exist on the server, so platform detection runs in an effect: branching\n    // on it during render would either crash SSR or hydrate to different markup than was sent. The\n    // first paint writes the Ctrl form and swaps to ⌘ once mounted.\n    const [detectedApple, setDetectedApple] = React.useState(false)\n    React.useEffect(() => {\n      if (appleProp !== undefined) return\n      setDetectedApple(/mac|iphone|ipad|ipod/i.test(navigator.platform || navigator.userAgent))\n    }, [appleProp])\n    const apple = appleProp ?? detectedApple\n\n    const [recording, setRecording] = React.useState(false)\n    const [pending, setPending] = React.useState<string[]>([])\n    const [message, setMessage] = React.useState(\"\")\n\n    // Read through refs so the document listener is registered once per recording session rather\n    // than on every render an inline callback prop would cause.\n    const onChangeRef = React.useRef(onChange)\n    onChangeRef.current = onChange\n    const onRejectRef = React.useRef(onReject)\n    onRejectRef.current = onReject\n    const onRecordingChangeRef = React.useRef(onRecordingChange)\n    onRecordingChangeRef.current = onRecordingChange\n\n    const stopRecording = React.useCallback(() => {\n      setRecording(false)\n      setPending([])\n    }, [])\n\n    const startRecording = React.useCallback(() => {\n      setMessage(\"\")\n      setPending([])\n      setRecording(true)\n    }, [])\n\n    // Announce start and stop to the parent without making the effect below depend on the callback.\n    const wasRecording = React.useRef(recording)\n    React.useEffect(() => {\n      if (wasRecording.current === recording) return\n      wasRecording.current = recording\n      onRecordingChangeRef.current?.(recording)\n    }, [recording])\n\n    const commit = React.useCallback(\n      (next: Shortcut | null) => {\n        if (!isControlled) setUncontrolled(next)\n        onChangeRef.current?.(next)\n      },\n      [isControlled]\n    )\n\n    const reject = React.useCallback((rejection: ShortcutRejection) => {\n      setMessage(rejection.reason)\n      onRejectRef.current?.(rejection)\n    }, [])\n\n    React.useEffect(() => {\n      if (!recording) return\n\n      const onKeyDown = (e: KeyboardEvent) => {\n        if (e.isComposing) return\n\n        // Tab keeps its meaning: the app does not see it, but the browser moves focus with it, so\n        // the field is not a keyboard dead end.\n        if (e.code === \"Tab\" && !e.metaKey && !e.ctrlKey && !e.altKey) {\n          e.stopPropagation()\n          stopRecording()\n          return\n        }\n\n        e.preventDefault()\n        e.stopPropagation()\n\n        if (e.repeat) return\n\n        if (e.code === \"Escape\" && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) {\n          stopRecording()\n          return\n        }\n\n        if (isModifierPress(e)) {\n          // Not a combination yet — show what is held and wait for the key it goes with.\n          setPending(\n            shortcutTokens({ code: \"\", ...heldModifiers(e, { apple, normalizeMod }) }, { apple }).slice(0, -1)\n          )\n          return\n        }\n\n        const shortcut = shortcutFromEvent(e, { apple, normalizeMod })\n        if (!shortcut) {\n          reject({ kind: \"unidentified\", shortcut: null, reason: labels.unidentified })\n          return\n        }\n\n        if (requireModifier && !hasModifier(shortcut)) {\n          reject({ kind: \"needs-modifier\", shortcut, reason: labels.needsModifier })\n          return\n        }\n\n        const collision = reservedMatch(shortcut, { apple, reserved })\n        if (collision) {\n          const words = formatShortcut(shortcut, { apple })\n          reject({\n            kind: \"reserved\",\n            shortcut,\n            owner: collision.owner,\n            reason: `${words} ${\n              collision.owner === \"browser\" ? labels.reservedByBrowser : labels.reservedBySystem\n            }`,\n          })\n          return\n        }\n\n        commit(shortcut)\n        setMessage(`${labels.saved} ${formatShortcut(shortcut, { apple })}`)\n        stopRecording()\n      }\n\n      const onKeyUp = (e: KeyboardEvent) => {\n        if (!isModifierPress(e)) return\n        e.stopPropagation()\n        setPending(\n          shortcutTokens({ code: \"\", ...heldModifiers(e, { apple, normalizeMod }) }, { apple }).slice(0, -1)\n        )\n      }\n\n      // The press that moved focus away never delivers its keyup, so the held-modifier state would\n      // be a lie from here on. Recording ends instead of carrying it.\n      const onBlur = () => stopRecording()\n\n      document.addEventListener(\"keydown\", onKeyDown, true)\n      document.addEventListener(\"keyup\", onKeyUp, true)\n      window.addEventListener(\"blur\", onBlur)\n      return () => {\n        document.removeEventListener(\"keydown\", onKeyDown, true)\n        document.removeEventListener(\"keyup\", onKeyUp, true)\n        window.removeEventListener(\"blur\", onBlur)\n      }\n    }, [\n      recording,\n      apple,\n      normalizeMod,\n      requireModifier,\n      reserved,\n      commit,\n      reject,\n      stopRecording,\n      labels.needsModifier,\n      labels.unidentified,\n      labels.reservedByBrowser,\n      labels.reservedBySystem,\n      labels.saved,\n    ])\n\n    const words = value ? formatShortcut(value, { apple }) : labels.empty\n    const caps = recording\n      ? pending\n      : value\n        ? shortcutTokens(value, { apple })\n        : []\n\n    return (\n      <div ref={ref} className={cn(\"flex flex-col gap-1\", className)} {...props}>\n        <div className=\"flex items-center gap-2\">\n          <button\n            type=\"button\"\n            disabled={disabled}\n            aria-label={recording ? labels.recording : `${labels.field}: ${words}`}\n            aria-pressed={recording}\n            onClick={() => (recording ? stopRecording() : startRecording())}\n            onBlur={stopRecording}\n            className={cn(\n              \"inline-flex h-9 min-w-[7.5rem] items-center gap-1 rounded-md border bg-background px-3 text-sm shadow-sm transition-colors\",\n              \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n              \"disabled:cursor-not-allowed disabled:opacity-50\",\n              recording ? \"border-ring ring-2 ring-ring\" : \"hover:bg-accent hover:text-accent-foreground\"\n            )}\n          >\n            <span aria-hidden=\"true\" className=\"flex items-center gap-1\">\n              {caps.map((token, i) => (\n                <Kbd key={`${token}-${i}`}>{tokenForm(token, { apple }).display}</Kbd>\n              ))}\n              {recording ? (\n                <span className=\"text-muted-foreground\">{pending.length ? \"…\" : labels.prompt}</span>\n              ) : null}\n              {!recording && !value ? (\n                <span className=\"text-muted-foreground\">{labels.placeholder}</span>\n              ) : null}\n            </span>\n          </button>\n\n          {clearable && value && !recording ? (\n            <button\n              type=\"button\"\n              disabled={disabled}\n              aria-label={labels.clear}\n              onClick={() => {\n                commit(null)\n                setMessage(\"\")\n              }}\n              className=\"rounded-sm text-sm text-muted-foreground underline-offset-4 hover:text-foreground hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50\"\n            >\n              {labels.clearText}\n            </button>\n          ) : null}\n        </div>\n\n        {/* Present from the first render so a later message is announced rather than merely added. */}\n        <p role=\"status\" className=\"min-h-4 text-xs text-muted-foreground\">\n          {message}\n        </p>\n      </div>\n    )\n  }\n)\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui",
  "docs": "Install any pulld component by name: add \"@pulld\": \"https://pulld.pages.dev/r/{name}.json\" to the registries block in components.json, then `npx shadcn add @pulld/<name>`. All 140 components: https://pulld.pages.dev/?utm_source=cli"
}
