{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "kbd",
  "type": "registry:ui",
  "title": "Kbd",
  "description": "Keys written for the reader's platform that press down with the real ones.",
  "categories": [
    "command"
  ],
  "dependencies": [],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json"
  ],
  "files": [
    {
      "path": "components/ui/kbd.tsx",
      "content": "\"use client\";\nimport { useEffect, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\n\n/* ------------------------------------------------------------------ */\n/* Platform                                                            */\n/* ------------------------------------------------------------------ */\n\nexport type Platform = \"mac\" | \"other\";\n\nfunction detectPlatform(): Platform {\n  const nav = navigator as Navigator & { userAgentData?: { platform?: string } };\n  const hint = nav.userAgentData?.platform || nav.platform || nav.userAgent;\n  return /mac|iphone|ipad|ipod/i.test(hint) ? \"mac\" : \"other\";\n}\n\nconst noop = () => () => {};\n\n/**\n * The platform the shortcuts should be written for. The server has no way to\n * know, so it renders the Mac glyphs and the client corrects them right after\n * hydration, without a mismatch warning.\n */\nexport function usePlatform(override?: Platform): Platform {\n  const detected = useSyncExternalStore(noop, detectPlatform, () => \"mac\" as Platform);\n  return override ?? detected;\n}\n\n/* ------------------------------------------------------------------ */\n/* Parsing and formatting                                              */\n/* ------------------------------------------------------------------ */\n\nconst alias: Record<string, string> = {\n  cmd: \"meta\", command: \"meta\", meta: \"meta\", \"⌘\": \"meta\", win: \"meta\", super: \"meta\",\n  ctrl: \"ctrl\", control: \"ctrl\", \"⌃\": \"ctrl\",\n  alt: \"alt\", option: \"alt\", opt: \"alt\", \"⌥\": \"alt\",\n  shift: \"shift\", \"⇧\": \"shift\",\n  enter: \"enter\", return: \"enter\", \"↵\": \"enter\", \"↩\": \"enter\",\n  esc: \"esc\", escape: \"esc\",\n  backspace: \"backspace\", \"⌫\": \"backspace\",\n  del: \"delete\", delete: \"delete\", \"⌦\": \"delete\",\n  space: \"space\", spacebar: \"space\",\n  tab: \"tab\", \"⇥\": \"tab\",\n  up: \"up\", arrowup: \"up\", \"↑\": \"up\",\n  down: \"down\", arrowdown: \"down\", \"↓\": \"down\",\n  left: \"left\", arrowleft: \"left\", \"←\": \"left\",\n  right: \"right\", arrowright: \"right\", \"→\": \"right\",\n  pageup: \"pageup\", pgup: \"pageup\", pagedown: \"pagedown\", pgdn: \"pagedown\",\n  home: \"home\", end: \"end\", capslock: \"capslock\", caps: \"capslock\",\n  plus: \"+\", comma: \",\", period: \".\", slash: \"/\",\n};\n\nconst MODIFIERS = [\"mod\", \"ctrl\", \"alt\", \"shift\", \"meta\"] as const;\nconst isModifier = (k: string) => (MODIFIERS as readonly string[]).includes(k);\n\n/** \"mod+shift+p\" → [[\"mod\",\"shift\",\"p\"]]; \"g i\" → [[\"g\"],[\"i\"]]. Steps are pressed one after another. */\nexport function parseShortcut(keys: string | string[]): string[][] {\n  if (Array.isArray(keys)) return [keys.map(normalizeKey)];\n  return keys\n    .trim()\n    .split(/\\s+/)\n    .filter(Boolean)\n    .map((step) => {\n      // \"mod++\" means mod and the plus key.\n      const parts = step === \"+\" ? [\"+\"] : step.endsWith(\"++\") ? [...step.slice(0, -2).split(\"+\"), \"+\"] : step.split(\"+\");\n      return parts.filter(Boolean).map(normalizeKey);\n    });\n}\n\nexport function normalizeKey(key: string) {\n  const k = key.trim();\n  const lower = k.toLowerCase();\n  return alias[lower] ?? (k.length === 1 ? lower : lower);\n}\n\n/** Resolves \"mod\" and sorts modifiers in the order each platform prints them (⌃⌥⇧⌘ on a Mac, Ctrl+Alt+Shift elsewhere). */\nexport function resolveStep(step: string[], platform: Platform) {\n  const resolved = step.map((k) => (k === \"mod\" ? (platform === \"mac\" ? \"meta\" : \"ctrl\") : k));\n  const order = platform === \"mac\" ? [\"ctrl\", \"alt\", \"shift\", \"meta\"] : [\"ctrl\", \"meta\", \"alt\", \"shift\"];\n  const mods = order.filter((m) => resolved.includes(m));\n  const rest = resolved.filter((k) => !isModifier(k));\n  return [...mods, ...rest];\n}\n\nconst macGlyph: Record<string, string> = {\n  meta: \"⌘\", ctrl: \"⌃\", alt: \"⌥\", shift: \"⇧\", enter: \"↵\", backspace: \"⌫\", delete: \"⌦\", esc: \"esc\", tab: \"⇥\",\n  space: \"Space\", up: \"↑\", down: \"↓\", left: \"←\", right: \"→\", capslock: \"⇪\", pageup: \"PgUp\", pagedown: \"PgDn\", home: \"Home\", end: \"End\",\n};\nconst otherGlyph: Record<string, string> = {\n  meta: \"Win\", ctrl: \"Ctrl\", alt: \"Alt\", shift: \"Shift\", enter: \"Enter\", backspace: \"Backspace\", delete: \"Del\", esc: \"Esc\", tab: \"Tab\",\n  space: \"Space\", up: \"↑\", down: \"↓\", left: \"←\", right: \"→\", capslock: \"Caps\", pageup: \"PgUp\", pagedown: \"PgDn\", home: \"Home\", end: \"End\",\n};\nconst spoken: Record<string, [mac: string, other: string]> = {\n  meta: [\"Command\", \"Windows\"], ctrl: [\"Control\", \"Control\"], alt: [\"Option\", \"Alt\"], shift: [\"Shift\", \"Shift\"],\n  enter: [\"Return\", \"Enter\"], backspace: [\"Delete\", \"Backspace\"], delete: [\"Forward delete\", \"Delete\"], esc: [\"Escape\", \"Escape\"],\n  tab: [\"Tab\", \"Tab\"], space: [\"Space\", \"Space\"], up: [\"Up arrow\", \"Up arrow\"], down: [\"Down arrow\", \"Down arrow\"],\n  left: [\"Left arrow\", \"Left arrow\"], right: [\"Right arrow\", \"Right arrow\"], capslock: [\"Caps lock\", \"Caps lock\"],\n  pageup: [\"Page up\", \"Page up\"], pagedown: [\"Page down\", \"Page down\"], home: [\"Home\", \"Home\"], end: [\"End\", \"End\"],\n  \"+\": [\"Plus\", \"Plus\"], \"?\": [\"Question mark\", \"Question mark\"], \"/\": [\"Slash\", \"Slash\"], \".\": [\"Period\", \"Period\"], \",\": [\"Comma\", \"Comma\"],\n};\n\nexport function keyLabel(key: string, platform: Platform) {\n  const table = platform === \"mac\" ? macGlyph : otherGlyph;\n  if (table[key]) return table[key];\n  return key.length === 1 ? key.toUpperCase() : key.charAt(0).toUpperCase() + key.slice(1);\n}\n\nexport function keyName(key: string, platform: Platform) {\n  const s = spoken[key];\n  if (s) return platform === \"mac\" ? s[0] : s[1];\n  return key.length === 1 ? key.toUpperCase() : key;\n}\n\n/** A plain-text rendering for titles, tooltips and screen readers: \"⌘⇧P\" / \"Ctrl+Shift+P\", or spoken: \"Command Shift P\". */\nexport function formatShortcut(keys: string | string[], platform: Platform, { spoken: speak = false } = {}) {\n  return parseShortcut(keys)\n    .map((step) => {\n      const r = resolveStep(step, platform);\n      if (speak) return r.map((k) => keyName(k, platform)).join(\" \");\n      return r.map((k) => keyLabel(k, platform)).join(platform === \"mac\" ? \"\" : \"+\");\n    })\n    .join(speak ? \", then \" : \" then \");\n}\n\n/* ------------------------------------------------------------------ */\n/* Events                                                              */\n/* ------------------------------------------------------------------ */\n\n/** The key an event is about, in the same names parseShortcut uses. Letters and digits come from the physical key so ⌥K on a Mac is still \"k\". */\nexport function eventKey(e: KeyboardEvent): string {\n  if (/^Key[A-Z]$/.test(e.code)) return e.code.slice(3).toLowerCase();\n  if (/^Digit\\d$/.test(e.code)) return e.code.slice(5);\n  const named: Record<string, string> = { Meta: \"meta\", OS: \"meta\", Control: \"ctrl\", Alt: \"alt\", Shift: \"shift\", \" \": \"space\" };\n  return named[e.key] ?? normalizeKey(e.key);\n}\n\n/** Whether a keydown is exactly this single-step shortcut: every modifier matches, none extra. */\nexport function matchShortcut(e: KeyboardEvent, keys: string | string[], platform: Platform) {\n  const steps = parseShortcut(keys);\n  if (steps.length !== 1) return false;\n  const step = resolveStep(steps[0], platform);\n  const main = step.find((k) => !isModifier(k));\n  if (!main || eventKey(e) !== main) return false;\n  // Symbols that need Shift to type (\"?\", \"!\") match whether or not Shift is listed.\n  const shiftFree = main.length === 1 && !/[a-z0-9]/.test(main);\n  return (\n    e.metaKey === step.includes(\"meta\") &&\n    e.ctrlKey === step.includes(\"ctrl\") &&\n    e.altKey === step.includes(\"alt\") &&\n    (shiftFree || e.shiftKey === step.includes(\"shift\"))\n  );\n}\n\n/** Whether focus is somewhere that typing matters (fields, editors). Single-key shortcuts should stand down there. */\nexport function isTypingTarget(target: EventTarget | null) {\n  const el = target as HTMLElement | null;\n  return !!el?.closest?.(\"input, textarea, select, [contenteditable=''], [contenteditable='true']\");\n}\n\n/**\n * The keys held down right now, by name. macOS never sends keyup for a key\n * pressed while ⌘ is held, so those release themselves shortly after the last\n * keydown (auto-repeat keeps a held key held), and everything lets go when ⌘ does.\n */\nexport function useHeldKeys(enabled = true, { ignoreWhileTyping = false } = {}) {\n  const [held, setHeld] = useState<ReadonlySet<string>>(() => new Set());\n  useEffect(() => {\n    if (!enabled) return;\n    const down = new Map<string, string>();\n    const timers = new Map<string, number>();\n    const commit = () => setHeld(new Set(down.values()));\n    const release = (code: string) => {\n      window.clearTimeout(timers.get(code));\n      timers.delete(code);\n      if (down.delete(code)) commit();\n    };\n    const clear = () => {\n      timers.forEach((t) => window.clearTimeout(t));\n      timers.clear();\n      if (down.size) {\n        down.clear();\n        commit();\n      }\n    };\n    const onDown = (e: KeyboardEvent) => {\n      const name = eventKey(e);\n      // Plain typing in a field isn't a shortcut; holding a modifier makes it one.\n      if (ignoreWhileTyping && isTypingTarget(e.target) && !e.metaKey && !e.ctrlKey && !e.altKey) return;\n      const code = e.code || name;\n      if (down.get(code) !== name) {\n        down.set(code, name);\n        commit();\n      }\n      if (e.metaKey && !isModifier(name)) {\n        window.clearTimeout(timers.get(code));\n        timers.set(code, window.setTimeout(() => release(code), 350));\n      }\n    };\n    const onUp = (e: KeyboardEvent) => {\n      const name = eventKey(e);\n      if (name === \"meta\") {\n        // Everything pressed under ⌘ is released with it.\n        for (const [code, n] of down) if (!isModifier(n)) release(code);\n      }\n      release(e.code || name);\n    };\n    const onVisibility = () => document.hidden && clear();\n    // Capture, so a component that stops a shortcut from propagating still shows it pressed.\n    window.addEventListener(\"keydown\", onDown, true);\n    window.addEventListener(\"keyup\", onUp, true);\n    window.addEventListener(\"blur\", clear);\n    document.addEventListener(\"visibilitychange\", onVisibility);\n    return () => {\n      window.removeEventListener(\"keydown\", onDown, true);\n      window.removeEventListener(\"keyup\", onUp, true);\n      window.removeEventListener(\"blur\", clear);\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n      timers.forEach((t) => window.clearTimeout(t));\n    };\n  }, [enabled, ignoreWhileTyping]);\n  return enabled ? held : EMPTY;\n}\n\nconst EMPTY: ReadonlySet<string> = new Set();\n\n/* ------------------------------------------------------------------ */\n/* Kbd                                                                 */\n/* ------------------------------------------------------------------ */\n\nexport type KbdSize = \"sm\" | \"md\" | \"lg\";\nexport type KbdVariant = \"keycap\" | \"ghost\";\n\nexport type KbdProps = Omit<React.ComponentProps<\"kbd\">, \"children\"> & {\n  /** \"mod+k\", \"shift+?\", \"g i\" (a sequence), or an array of keys for one combo. \"mod\" is ⌘ on a Mac and Ctrl elsewhere. */\n  keys?: string | string[];\n  /** A single literal key, when you'd rather write it yourself. Ignored when keys is set. */\n  children?: React.ReactNode;\n  size?: KbdSize;\n  /** A raised keycap, or quiet glyphs for menus and highlighted rows. */\n  variant?: KbdVariant;\n  /** Draw a combo in one cap (⌘⇧P) instead of one cap per key. */\n  joined?: boolean;\n  /** Write the keys for this platform instead of the one detected. */\n  platform?: Platform;\n  /** Press the caps down while the real keys are held. */\n  listen?: boolean;\n  /** Called when the whole shortcut (every step of a sequence) is pressed while listening. */\n  onMatch?: (event: KeyboardEvent) => void;\n  /** Force the pressed look: true for every cap, or the set of key names held (from useHeldKeys). */\n  pressed?: boolean | ReadonlySet<string>;\n};\n\nconst capSize: Record<KbdSize, string> = {\n  sm: \"h-[18px] min-w-[18px] rounded-[5px] px-1 text-[10.5px]\",\n  md: \"h-5 min-w-5 rounded-[5px] px-1.5 text-[11px]\",\n  lg: \"h-7 min-w-7 rounded-[7px] px-2 text-[13px]\",\n};\nconst gapSize: Record<KbdSize, string> = { sm: \"gap-0.5\", md: \"gap-1\", lg: \"gap-1.5\" };\nconst GLYPH = /^[⌘⇧⌥⌃↵⌫⌦⇥↑↓←→⇪]$/;\n\nexport function Kbd({\n  keys,\n  children,\n  size = \"md\",\n  variant = \"keycap\",\n  joined = false,\n  platform: platformProp,\n  listen = false,\n  onMatch,\n  pressed,\n  className,\n  ...rest\n}: KbdProps) {\n  const platform = usePlatform(platformProp);\n  const own = useHeldKeys(listen);\n  const steps = keys != null ? parseShortcut(keys).map((s) => resolveStep(s, platform)) : [];\n  const held = pressed instanceof Set ? pressed : own;\n  const isDown = (k: string) => pressed === true || held.has(k);\n\n  // Sequences and full combos report once per press, only while listening.\n  const progress = useRef({ step: 0, at: 0 });\n  const onMatchRef = useRef(onMatch);\n  useEffect(() => {\n    onMatchRef.current = onMatch;\n  });\n  const signature = steps.map((s) => s.join(\"+\")).join(\" \");\n  useEffect(() => {\n    if (!listen || !signature) return;\n    const parsed = signature.split(\" \").map((s) => s.split(\"+\"));\n    const onDown = (e: KeyboardEvent) => {\n      if (e.repeat) return;\n      const p = progress.current;\n      if (performance.now() - p.at > 1200) p.step = 0;\n      const want = parsed[p.step];\n      const combo = want.filter(isModifier);\n      const main = want.find((k) => !isModifier(k));\n      const ok =\n        eventKey(e) === main &&\n        e.metaKey === combo.includes(\"meta\") &&\n        e.ctrlKey === combo.includes(\"ctrl\") &&\n        e.altKey === combo.includes(\"alt\") &&\n        (e.shiftKey === combo.includes(\"shift\") || (main.length === 1 && !/[a-z0-9]/.test(main)));\n      if (!ok) {\n        p.step = 0;\n        return;\n      }\n      p.at = performance.now();\n      p.step += 1;\n      if (p.step === parsed.length) {\n        p.step = 0;\n        onMatchRef.current?.(e);\n      }\n    };\n    window.addEventListener(\"keydown\", onDown);\n    return () => window.removeEventListener(\"keydown\", onDown);\n  }, [listen, signature]);\n\n  const label = keys != null ? formatShortcut(keys, platform, { spoken: true }) : undefined;\n\n  // Symbol keys (⌘ ⇧ ↵ ↑) sit small in a mono face; draw them from the sans at a size that matches the letters' cap height.\n  const face = (k: string) => {\n    const text = keyLabel(k, platform);\n    return GLYPH.test(text) ? (\n      <span className=\"font-sans text-[1.2em] leading-none\">{text}</span>\n    ) : (\n      text\n    );\n  };\n\n  const cap = (content: React.ReactNode, down: boolean, key: string | number, single = false) => (\n    <kbd\n      key={key}\n      data-pressed={down ? \"\" : undefined}\n      data-single={single ? \"\" : undefined}\n      className={cn(\n        \"inline-flex select-none items-center justify-center whitespace-nowrap font-mono font-medium leading-none tabular\",\n        // Press is a 50ms drop, release a 150ms lift: the key feels sprung, not animated.\n        \"transition-[translate,box-shadow,background-color,border-color,color] duration-150 ease-out-quart data-pressed:duration-50\",\n        variant === \"keycap\"\n          ? cn(\n              capSize[size],\n              // One-character caps are squares; words get padding.\n              \"data-single:px-0\",\n              // A face a step lighter than whatever it sits on, and a 2px lower edge.\n              \"border border-line-2 bg-fg/[0.035] text-fg-2\",\n              size === \"lg\" ? \"shadow-[inset_0_-2px_0_var(--line-2)]\" : \"shadow-[inset_0_-1px_0_var(--line-2)]\",\n              \"data-pressed:translate-y-px data-pressed:border-fg-4 data-pressed:bg-fg/[0.09] data-pressed:text-fg data-pressed:shadow-none\",\n              \"motion-reduce:data-pressed:translate-y-0\",\n            )\n          : cn(\"text-fg-3 data-pressed:text-fg\", size === \"lg\" ? \"text-[13px]\" : size === \"md\" ? \"text-[11.5px]\" : \"text-[11px]\"),\n      )}\n    >\n      {content}\n    </kbd>\n  );\n\n  let body: React.ReactNode;\n  if (keys == null) {\n    body = cap(children, pressed === true, 0, typeof children === \"string\" && [...children].length === 1);\n  } else {\n    body = steps.map((step, i) => (\n      <span key={i} className={cn(\"inline-flex items-center\", gapSize[size])}>\n        {i > 0 && <span className={cn(\"font-sans font-normal text-fg-4\", size === \"lg\" ? \"mx-1 text-[12px]\" : \"mx-0.5 text-[11px]\")}>then</span>}\n        {joined || variant === \"ghost\"\n          ? cap(\n              <span className={cn(\"inline-flex items-center\", platform === \"mac\" ? \"gap-px\" : \"gap-0\")}>\n                {step.map((k, j) => (\n                  <span key={j} className=\"inline-flex items-center\">\n                    {platform !== \"mac\" && j > 0 && <span className=\"mx-px text-fg-4\">+</span>}\n                    {face(k)}\n                  </span>\n                ))}\n              </span>,\n              step.every(isDown),\n              i,\n            )\n          : step.map((k, j) => cap(face(k), isDown(k), `${i}-${j}`, [...keyLabel(k, platform)].length === 1))}\n      </span>\n    ));\n  }\n\n  return (\n    <kbd\n      data-size={size}\n      data-variant={variant}\n      data-listening={listen ? \"\" : undefined}\n      className={cn(\"inline-flex items-center align-middle font-normal\", gapSize[size], className)}\n      {...rest}\n    >\n      {label ? (\n        <>\n          <span aria-hidden className={cn(\"inline-flex items-center\", gapSize[size])}>\n            {body}\n          </span>\n          <span className=\"sr-only\">{label}</span>\n        </>\n      ) : (\n        body\n      )}\n    </kbd>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/kbd"
}
