{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "keyboard-shortcuts",
  "title": "Keyboard Shortcuts",
  "description": "The help sheet that opens when the user presses ? — a modal listing every keyboard shortcut in the app, grouped by area, with the key caps drawn per platform. Use it as soon as an app has shortcuts worth discovering: an editor, inbox or mail client, issue tracker, admin dashboard, IDE-like tool, dev tool, chat or any keyboard-first product where power users expect ? to explain itself. Common asks it answers: \"keyboard shortcuts dialog\", \"keyboard shortcuts modal\", \"shortcuts help sheet\", \"press ? to see shortcuts\", \"shortcut cheat sheet\", \"hotkey list\", \"keymap overlay\", \"GitHub/Gmail/Linear-style shortcuts help\", \"show all hotkeys\", \"⌘K help screen\". shadcn/ui ships nothing for this and its kbd is a bare key cap, so the sheet, the grouping, the ?-to-open wiring and the cross-platform key rendering are hand-rolled every time. Pass a `shortcuts` array of `{ keys, description, group? }`; groups render in the order they first occur, so the array is the outline. Write `\"Mod\"` in keys and it renders ⌘ on Apple platforms and Ctrl everywhere else — one source of truth instead of a Mac branch through your docs — and the literal token `\"then\"` renders as text rather than a cap so chords read as G then P. It documents shortcuts rather than binding them: your app already owns the handlers, and a component that registered them too would fight whatever hotkey library you use. The only key it owns is the one that opens it, and that listener ignores presses while focus is in an input, textarea, select or contenteditable, so typing \"?\" in a message box does not throw a modal over the composer. Platform detection runs in an effect, not during render — `navigator` does not exist on the server, so an inline branch would crash SSR or hydrate to different markup than it sent. Accessibility is the part that is easy to get wrong: the key caps are aria-hidden and each row carries an sr-only spoken form, because a screen reader meeting ⌘ announces \"place of interest sign\" or nothing at all, so the row reads \"Open search, Command K\"; opening moves focus into the dialog, which is what announces it, instead of a live region that would read the whole sheet twice; Tab is trapped, Escape closes, focus returns to whatever was focused before, and the scrolling list is itself focusable so a long list can be scrolled from the keyboard. Composes the kbd component for the caps. Styled with shadcn tokens (popover, muted-foreground, ring, border) for light and dark themes; lucide-react is the only dependency. Distinct from command-palette, which is a ⌘K launcher for running commands: this one is the reference card that tells users the shortcuts exist.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "https://pulld.pages.dev/r/kbd.json"
  ],
  "files": [
    {
      "path": "registry/ui/keyboard-shortcuts.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { X } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Kbd } from \"@/registry/ui/kbd\"\n\nexport interface Shortcut {\n  /**\n   * The keys in press order, e.g. `[\"Mod\", \"K\"]`. `\"Mod\"` renders as ⌘ on Apple\n   * platforms and Ctrl everywhere else. The literal token `\"then\"` renders as\n   * plain text rather than a key cap, so chords read as `G then P`.\n   */\n  keys: string[]\n  /** What the shortcut does, in the user's words (\"Open search\"). */\n  description: string\n  /** Section heading. Groups appear in the order they first occur. */\n  group?: string\n}\n\ninterface KeyboardShortcutsProps {\n  /** Every shortcut to document. An empty list means the sheet never opens. */\n  shortcuts: Shortcut[]\n  /**\n   * The character that toggles the sheet, compared against `event.key`.\n   * Default `\"?\"`. Pass `null` to drive it only through `open`.\n   */\n  hotkey?: string | null\n  /** Controlled open state. Omit to let the component own it. */\n  open?: boolean\n  onOpenChange?: (open: boolean) => void\n  title?: string\n  closeLabel?: string\n  className?: string\n}\n\n/** display + spoken form of a key, per platform. */\ntype KeyForm = { apple: readonly [string, string]; other: readonly [string, string] }\n\nconst KEY_TABLE: Record<string, KeyForm> = {\n  mod: { apple: [\"⌘\", \"Command\"], other: [\"Ctrl\", \"Control\"] },\n  cmd: { apple: [\"⌘\", \"Command\"], other: [\"⌘\", \"Command\"] },\n  command: { apple: [\"⌘\", \"Command\"], other: [\"⌘\", \"Command\"] },\n  meta: { apple: [\"⌘\", \"Command\"], other: [\"Win\", \"Windows key\"] },\n  ctrl: { apple: [\"⌃\", \"Control\"], other: [\"Ctrl\", \"Control\"] },\n  control: { apple: [\"⌃\", \"Control\"], other: [\"Ctrl\", \"Control\"] },\n  alt: { apple: [\"⌥\", \"Option\"], other: [\"Alt\", \"Alt\"] },\n  option: { apple: [\"⌥\", \"Option\"], other: [\"⌥\", \"Option\"] },\n  shift: { apple: [\"⇧\", \"Shift\"], other: [\"Shift\", \"Shift\"] },\n  enter: { apple: [\"↩\", \"Enter\"], other: [\"Enter\", \"Enter\"] },\n  return: { apple: [\"↩\", \"Return\"], other: [\"Enter\", \"Enter\"] },\n  esc: { apple: [\"Esc\", \"Escape\"], other: [\"Esc\", \"Escape\"] },\n  escape: { 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\nfunction keyForm(raw: string, apple: boolean): { display: string; spoken: string } {\n  const entry = KEY_TABLE[raw.trim().toLowerCase()]\n  if (entry) {\n    const [display, spoken] = apple ? entry.apple : entry.other\n    return { display, spoken }\n  }\n  // Single letters read better as caps; anything longer passes through as authored.\n  const display = raw.length === 1 ? raw.toUpperCase() : raw\n  return { display, spoken: display }\n}\n\n/** Text entry swallows the hotkey — \"?\" belongs in the message, not in the help sheet. */\nfunction isTypingTarget(target: EventTarget | null): boolean {\n  if (!(target instanceof HTMLElement)) return false\n  if (target.isContentEditable) return true\n  return /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)\n}\n\n/**\n * The \"press ? to see every shortcut\" help sheet — a modal listing your\n * keyboard shortcuts, grouped, with the key caps rendered per platform.\n *\n * It documents shortcuts; it does not bind them. Your app already owns the\n * handlers, and a component that also registered them would fight whatever\n * hotkey library you use. The one key it does own is the one that opens it.\n *\n * Platform detection runs in an effect rather than during render: `navigator`\n * does not exist on the server, so branching on it inline would either crash\n * or hydrate to different markup than the server sent. The first paint uses\n * the Ctrl form and swaps to ⌘ once mounted, which is invisible in practice\n * and keeps hydration clean.\n *\n * Key caps are `aria-hidden` and each row carries an `sr-only` spoken form,\n * because a screen reader meeting \"⌘\" announces \"place of interest sign\" or\n * nothing at all. The row reads \"Open search, Command K\" instead.\n *\n * Opening moves focus into the dialog, which is what announces it — no live\n * region is involved, and adding one would read the whole sheet twice.\n */\nexport function KeyboardShortcuts({\n  shortcuts,\n  hotkey = \"?\",\n  open: openProp,\n  onOpenChange,\n  title = \"Keyboard shortcuts\",\n  closeLabel = \"Close\",\n  className,\n}: KeyboardShortcutsProps) {\n  const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false)\n  const isControlled = openProp !== undefined\n  const open = isControlled ? openProp : uncontrolledOpen\n\n  const [isApple, setIsApple] = React.useState(false)\n  React.useEffect(() => {\n    setIsApple(/mac|iphone|ipad|ipod/i.test(navigator.platform || navigator.userAgent))\n  }, [])\n\n  // Read through refs so the hotkey listener is registered once, not on every\n  // render an inline `onOpenChange` arrow would cause.\n  const onOpenChangeRef = React.useRef(onOpenChange)\n  onOpenChangeRef.current = onOpenChange\n  const openRef = React.useRef(open)\n  openRef.current = open\n  const isControlledRef = React.useRef(isControlled)\n  isControlledRef.current = isControlled\n\n  const setOpen = React.useCallback((next: boolean) => {\n    if (!isControlledRef.current) setUncontrolledOpen(next)\n    onOpenChangeRef.current?.(next)\n  }, [])\n\n  const enabled = hotkey != null && shortcuts.length > 0\n  React.useEffect(() => {\n    if (!enabled) return\n    const onKeyDown = (e: KeyboardEvent) => {\n      if (e.key !== hotkey) return\n      // A modified press is somebody else's shortcut.\n      if (e.ctrlKey || e.metaKey || e.altKey) return\n      if (isTypingTarget(e.target)) return\n      e.preventDefault()\n      setOpen(!openRef.current)\n    }\n    document.addEventListener(\"keydown\", onKeyDown)\n    return () => document.removeEventListener(\"keydown\", onKeyDown)\n  }, [enabled, hotkey, setOpen])\n\n  const dialogRef = React.useRef<HTMLDivElement>(null)\n  React.useEffect(() => {\n    if (!open) return\n    const restore = document.activeElement\n    dialogRef.current?.focus()\n    return () => {\n      if (restore instanceof HTMLElement) restore.focus()\n    }\n  }, [open])\n\n  const headingId = React.useId()\n\n  const onDialogKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    if (e.key === \"Escape\") {\n      e.preventDefault()\n      setOpen(false)\n      return\n    }\n    if (e.key !== \"Tab\") return\n    // Modal: keep Tab inside the sheet.\n    const focusables = Array.from(\n      dialogRef.current?.querySelectorAll<HTMLElement>(\n        'button:not([disabled]), [tabindex]:not([tabindex=\"-1\"])'\n      ) ?? []\n    )\n    if (focusables.length === 0) {\n      e.preventDefault()\n      return\n    }\n    const first = focusables[0]\n    const last = focusables[focusables.length - 1]\n    const active = document.activeElement\n    if (e.shiftKey && (active === first || active === dialogRef.current)) {\n      e.preventDefault()\n      last.focus()\n    } else if (!e.shiftKey && active === last) {\n      e.preventDefault()\n      first.focus()\n    }\n  }\n\n  // An empty sheet is a misconfiguration, not a state worth rendering.\n  if (!open || shortcuts.length === 0) return null\n\n  const groups = new Map<string, Shortcut[]>()\n  for (const s of shortcuts) {\n    const g = s.group ?? \"\"\n    const arr = groups.get(g)\n    if (arr) arr.push(s)\n    else groups.set(g, [s])\n  }\n\n  return (\n    <div\n      role=\"presentation\"\n      onClick={(e) => {\n        if (e.target === e.currentTarget) setOpen(false)\n      }}\n      className=\"fixed inset-0 z-50 flex items-start justify-center bg-black/40 p-4 pt-[12vh]\"\n    >\n      {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}\n      <div\n        ref={dialogRef}\n        role=\"dialog\"\n        aria-modal=\"true\"\n        aria-labelledby={headingId}\n        tabIndex={-1}\n        onKeyDown={onDialogKeyDown}\n        className={cn(\n          \"w-full max-w-lg overflow-hidden rounded-xl border bg-popover text-popover-foreground shadow-2xl outline-none\",\n          className\n        )}\n      >\n        <div className=\"flex items-center justify-between gap-4 border-b px-4 py-3\">\n          <h2 id={headingId} className=\"text-sm font-medium\">\n            {title}\n          </h2>\n          <button\n            type=\"button\"\n            onClick={() => setOpen(false)}\n            aria-label={closeLabel}\n            className=\"rounded-sm text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n          >\n            <X className=\"h-4 w-4\" aria-hidden=\"true\" />\n          </button>\n        </div>\n\n        {/* Focusable so a keyboard user can scroll a long list (WCAG 2.1.1). */}\n        <div\n          tabIndex={0}\n          role=\"group\"\n          aria-label={title}\n          className=\"max-h-[60vh] overflow-y-auto p-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring\"\n        >\n          {[...groups.entries()].map(([group, rows]) => (\n            <section key={group || \"_\"} className=\"mb-4 last:mb-0\">\n              {group ? (\n                <h3 className=\"mb-2 text-xs font-medium text-muted-foreground\">\n                  {group}\n                </h3>\n              ) : null}\n              <dl className=\"divide-y\">\n                {rows.map((s, i) => {\n                  const forms = s.keys.map((k) =>\n                    k.trim().toLowerCase() === \"then\"\n                      ? { display: \"then\", spoken: \"then\", literal: true }\n                      : { ...keyForm(k, isApple), literal: false }\n                  )\n                  return (\n                    <div\n                      key={`${s.description}-${i}`}\n                      className=\"flex items-center justify-between gap-6 py-2\"\n                    >\n                      <dt className=\"text-sm\">{s.description}</dt>\n                      <dd className=\"shrink-0\">\n                        <span className=\"sr-only\">\n                          {forms.map((f) => f.spoken).join(\" \")}\n                        </span>\n                        <span aria-hidden=\"true\" className=\"flex items-center gap-1\">\n                          {forms.map((f, j) =>\n                            f.literal ? (\n                              <span key={j} className=\"text-xs text-muted-foreground\">\n                                {f.display}\n                              </span>\n                            ) : (\n                              <Kbd key={j}>{f.display}</Kbd>\n                            )\n                          )}\n                        </span>\n                      </dd>\n                    </div>\n                  )\n                })}\n              </dl>\n            </section>\n          ))}\n        </div>\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}
