{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"keyboard","type":"registry:ui","title":"Keyboard","description":"A realistic compact keyboard whose keycaps sink and glow with backlight when you press the matching physical keys or click them, with a live typing strip and caps-lock LED.","dependencies":[],"registryDependencies":["utils"],"files":[{"path":"registry/ui/keyboard.tsx","type":"registry:ui","content":"\"use client\";\n\nimport { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\ntype KeyDef = {\n  code: string;\n  label: string;\n  /** Shifted legend shown above the label. */\n  top?: string;\n  /** Width in key units. */\n  w?: number;\n  /** Character typed (unshifted / shifted). Omit for modifiers. */\n  ch?: string;\n  sh?: string;\n  align?: \"left\" | \"right\";\n};\n\nconst k = (code: string, label: string, ch?: string, sh?: string, top?: string): KeyDef => ({ code, label, ch, sh, top });\nconst letters = (codes: string) => codes.split(\"\").map((c) => k(`Key${c}`, c, c.toLowerCase(), c));\nconst sym = (code: string, ch: string, sh: string): KeyDef => k(code, ch, ch, sh, sh);\n\nconst ROWS: KeyDef[][] = [\n  [\n    sym(\"Backquote\", \"`\", \"~\"),\n    ...[...\"1234567890\"].map((d, i) => sym(`Digit${d}`, d, \"!@#$%^&*()\"[i])),\n    sym(\"Minus\", \"-\", \"_\"),\n    sym(\"Equal\", \"=\", \"+\"),\n    { code: \"Backspace\", label: \"delete\", w: 2, align: \"right\" },\n  ],\n  [\n    { code: \"Tab\", label: \"tab\", w: 1.5, align: \"left\" },\n    ...letters(\"QWERTYUIOP\"),\n    sym(\"BracketLeft\", \"[\", \"{\"),\n    sym(\"BracketRight\", \"]\", \"}\"),\n    { ...sym(\"Backslash\", \"\\\\\", \"|\"), w: 1.5 },\n  ],\n  [\n    { code: \"CapsLock\", label: \"caps lock\", w: 1.75, align: \"left\" },\n    ...letters(\"ASDFGHJKL\"),\n    sym(\"Semicolon\", \";\", \":\"),\n    sym(\"Quote\", \"'\", '\"'),\n    { code: \"Enter\", label: \"return\", w: 2.25, align: \"right\" },\n  ],\n  [\n    { code: \"ShiftLeft\", label: \"shift\", w: 2.25, align: \"left\" },\n    ...letters(\"ZXCVBNM\"),\n    sym(\"Comma\", \",\", \"<\"),\n    sym(\"Period\", \".\", \">\"),\n    sym(\"Slash\", \"/\", \"?\"),\n    { code: \"ShiftRight\", label: \"shift\", w: 2.75, align: \"right\" },\n  ],\n  [\n    { code: \"Fn\", label: \"fn\" },\n    { code: \"ControlLeft\", label: \"control\" },\n    { code: \"AltLeft\", label: \"option\" },\n    { code: \"MetaLeft\", label: \"⌘\", w: 1.25 },\n    { code: \"Space\", label: \"\", w: 5.5, ch: \" \", sh: \" \" },\n    { code: \"MetaRight\", label: \"⌘\", w: 1.25 },\n    { code: \"AltRight\", label: \"option\" },\n  ],\n];\n\nconst ALL = new Map(ROWS.flat().map((d) => [d.code, d]));\nfor (const [code, label] of [[\"ArrowLeft\", \"◀\"], [\"ArrowRight\", \"▶\"], [\"ArrowUp\", \"▲\"], [\"ArrowDown\", \"▼\"]])\n  ALL.set(code, { code, label });\n\nexport interface KeyboardProps {\n  /** Key unit in px; the board is 15 units wide. */\n  size?: number;\n  /** Backlight color of pressed keys. */\n  accent?: string;\n  /** Keycap finish. */\n  variant?: \"graphite\" | \"silver\";\n  /** Show the typed-text strip above the keys. */\n  showDisplay?: boolean;\n  /**\n   * Listen to the whole window instead of only while the keyboard has focus.\n   * Off by default so the widget never swallows typing elsewhere on the page.\n   */\n  global?: boolean;\n  /** Fired for every key press (physical or clicked) with its KeyboardEvent.code. */\n  onKey?: (code: string) => void;\n  className?: string;\n}\n\nexport function Keyboard({\n  size = 40,\n  accent = \"#c6ff3d\",\n  variant = \"graphite\",\n  showDisplay = true,\n  global = false,\n  onKey,\n  className,\n}: KeyboardProps) {\n  const [down, setDown] = useState<Set<string>>(() => new Set());\n  const [caps, setCaps] = useState(false);\n  const [text, setText] = useState(\"\");\n  const [focused, setFocused] = useState(false);\n  const shiftRef = useRef(false);\n  const capsRef = useRef(caps);\n  useEffect(() => {\n    capsRef.current = caps;\n  }, [caps]);\n  const onKeyRef = useRef(onKey);\n  useEffect(() => {\n    onKeyRef.current = onKey;\n  });\n\n  const press = (code: string) => {\n    if (!ALL.has(code)) return;\n    setDown((s) => (s.has(code) ? s : new Set(s).add(code)));\n    onKeyRef.current?.(code);\n    if (code === \"ShiftLeft\" || code === \"ShiftRight\") shiftRef.current = true;\n    if (code === \"CapsLock\") setCaps((c) => !c);\n    if (code === \"Backspace\") setText((t) => t.slice(0, -1));\n    if (code === \"Enter\") setText(\"\");\n    const def = ALL.get(code)!;\n    if (def.ch) {\n      const letter = /^Key/.test(code);\n      const shifted = shiftRef.current !== (letter && capsRef.current);\n      const c = shifted ? def.sh! : def.ch;\n      setText((t) => (t + c).slice(-48));\n    }\n  };\n  const release = (code: string) => {\n    if (code === \"ShiftLeft\" || code === \"ShiftRight\") shiftRef.current = false;\n    setDown((s) => {\n      if (!s.has(code) && !code.startsWith(\"Meta\")) return s;\n      // macOS drops keyup for keys held with ⌘, so releasing ⌘ clears everything.\n      if (code.startsWith(\"Meta\")) return new Set();\n      const n = new Set(s);\n      n.delete(code);\n      return n;\n    });\n  };\n  const handlers = useRef({ press, release });\n  useEffect(() => {\n    handlers.current = { press, release };\n  });\n\n  useEffect(() => {\n    if (!global) return;\n    const kd = (e: KeyboardEvent) => !e.repeat && handlers.current.press(e.code);\n    const ku = (e: KeyboardEvent) => handlers.current.release(e.code);\n    const clear = () => setDown(new Set());\n    window.addEventListener(\"keydown\", kd);\n    window.addEventListener(\"keyup\", ku);\n    window.addEventListener(\"blur\", clear);\n    return () => {\n      window.removeEventListener(\"keydown\", kd);\n      window.removeEventListener(\"keyup\", ku);\n      window.removeEventListener(\"blur\", clear);\n    };\n  }, [global]);\n\n  const onKeyDown = (e: ReactKeyboardEvent) => {\n    if (global || e.metaKey || e.ctrlKey) return;\n    if (e.code !== \"Tab\" && ALL.has(e.code)) e.preventDefault(); // keep Tab so focus can leave\n    if (!e.repeat) press(e.code);\n  };\n  const onKeyUp = (e: ReactKeyboardEvent) => !global && release(e.code);\n\n  const gap = Math.round(size * 0.14);\n  const silver = variant === \"silver\";\n  const vars = {\n    \"--u\": `${size}px`,\n    \"--g\": `${gap}px`,\n    \"--accent\": accent,\n    \"--cap\": silver ? \"linear-gradient(#fbfbfa,#e6e6e3)\" : \"linear-gradient(#34353a,#26272b)\",\n    \"--cap-edge\": silver ? \"#b9b9b4\" : \"#101113\",\n    \"--legend\": silver ? \"#3b3c40\" : \"#c9cbd1\",\n  } as CSSProperties;\n\n  const cap = (def: KeyDef, extra?: CSSProperties, compact = false) => {\n    const on = down.has(def.code);\n    const w = def.w ?? 1;\n    const isLetter = /^Key/.test(def.code);\n    return (\n      <span\n        key={def.code}\n        data-on={on || undefined}\n        onPointerDown={(e) => {\n          e.preventDefault();\n          (e.currentTarget.closest(\"[data-kb]\") as HTMLElement | null)?.focus({ preventScroll: true });\n          press(def.code);\n        }}\n        onPointerUp={() => release(def.code)}\n        onPointerLeave={() => down.has(def.code) && release(def.code)}\n        className={cn(\n          \"kb-key relative flex cursor-pointer flex-col rounded-[calc(var(--u)*0.16)] px-[calc(var(--u)*0.16)] py-[calc(var(--u)*0.1)] leading-none select-none\",\n          def.align === \"right\" ? \"items-end justify-end\" : def.align === \"left\" ? \"items-start justify-end\" : \"items-center justify-center\",\n        )}\n        style={{\n          width: `calc(var(--u) * ${w} + var(--g) * ${w - 1})`,\n          height: compact ? \"calc((var(--u) - var(--g) / 2) / 2)\" : \"var(--u)\",\n          ...extra,\n        }}\n      >\n        {def.top && <span className=\"kb-legend text-[calc(var(--u)*0.26)] opacity-60\">{def.top}</span>}\n        <span\n          className={cn(\n            \"kb-legend\",\n            isLetter ? \"text-[calc(var(--u)*0.34)] font-medium\" : def.top ? \"text-[calc(var(--u)*0.3)]\" : \"text-[calc(var(--u)*0.25)]\",\n          )}\n        >\n          {def.label}\n        </span>\n        {def.code === \"CapsLock\" && (\n          <span\n            className=\"absolute top-[calc(var(--u)*0.16)] left-[calc(var(--u)*0.16)] size-[calc(var(--u)*0.1)] rounded-full transition-[background-color,box-shadow] duration-200\"\n            style={{ background: caps ? accent : \"rgba(128,128,128,.35)\", boxShadow: caps ? `0 0 6px ${accent}` : undefined }}\n          />\n        )}\n      </span>\n    );\n  };\n\n  const arrows = (\n    <span className=\"flex items-end gap-(--g)\" key=\"arrows\">\n      {cap(ALL.get(\"ArrowLeft\")!, undefined, true)}\n      <span className=\"flex flex-col gap-[calc(var(--g)/2)]\">\n        {cap(ALL.get(\"ArrowUp\")!, undefined, true)}\n        {cap(ALL.get(\"ArrowDown\")!, undefined, true)}\n      </span>\n      {cap(ALL.get(\"ArrowRight\")!, undefined, true)}\n    </span>\n  );\n\n  return (\n    <div\n      data-kb\n      tabIndex={0}\n      role=\"group\"\n      aria-label=\"Interactive keyboard. Focus it and type, or click the keys.\"\n      onKeyDown={onKeyDown}\n      onKeyUp={onKeyUp}\n      onFocus={() => setFocused(true)}\n      onBlur={() => {\n        setFocused(false);\n        if (!global) setDown(new Set());\n      }}\n      className={cn(\"relative w-fit rounded-[calc(var(--u)*0.45)] outline-none\", className)}\n      style={vars}\n    >\n      <style href=\"glint-keyboard\" precedence=\"default\">{`\n.kb-key{background:var(--cap);color:var(--legend);box-shadow:0 calc(var(--u)*.06) 0 var(--cap-edge),0 calc(var(--u)*.09) calc(var(--u)*.14) rgba(0,0,0,.45),inset 0 1px 0 rgba(255,255,255,.14);transition:translate .12s cubic-bezier(.3,.7,.4,1.4),box-shadow .5s ease,color .5s ease}\n.kb-key .kb-legend{transition:text-shadow .5s ease}\n.kb-key:hover{filter:brightness(1.08)}\n.kb-key[data-on]{translate:0 calc(var(--u)*.05);color:var(--accent);box-shadow:0 calc(var(--u)*.01) 0 var(--cap-edge),0 0 calc(var(--u)*.45) color-mix(in oklab,var(--accent) 45%,transparent),inset 0 0 0 1px color-mix(in oklab,var(--accent) 70%,transparent),inset 0 0 calc(var(--u)*.3) color-mix(in oklab,var(--accent) 25%,transparent);transition-duration:.05s}\n.kb-key[data-on] .kb-legend{text-shadow:0 0 calc(var(--u)*.2) var(--accent);transition-duration:.05s}\n@keyframes kb-caret{50%{opacity:0}}\n[data-kb]:focus-visible .kb-plate{outline:2px solid color-mix(in oklab,var(--accent) 45%,transparent);outline-offset:4px}\n@media (prefers-reduced-motion:reduce){.kb-key,.kb-key .kb-legend{transition:none}}\n`}</style>\n\n      {/* aluminium plate */}\n      <div\n        className={cn(\n          \"relative flex flex-col gap-(--g) rounded-[inherit] p-[calc(var(--u)*0.32)] transition-shadow duration-300\",\n          silver\n            ? \"bg-[linear-gradient(180deg,#e9e9e6,#c9c9c4)] shadow-[0_24px_60px_-18px_rgba(0,0,0,.55),inset_0_1px_0_rgba(255,255,255,.9),inset_0_-2px_4px_rgba(0,0,0,.12)]\"\n            : \"bg-[linear-gradient(180deg,#1f2023,#131416)] shadow-[0_24px_60px_-18px_rgba(0,0,0,.8),inset_0_1px_0_rgba(255,255,255,.08),inset_0_-2px_4px_rgba(0,0,0,.5)]\",\n          \"kb-plate\",\n        )}\n      >\n        {showDisplay && (\n          <div\n            className={cn(\n              \"mb-[calc(var(--g)*0.6)] flex h-[calc(var(--u)*1.05)] items-center gap-2 overflow-hidden rounded-[calc(var(--u)*0.22)] px-[calc(var(--u)*0.3)] font-mono text-[calc(var(--u)*0.36)]\",\n              silver ? \"bg-[#d9d9d5] text-[#2a2b2f] shadow-[inset_0_2px_6px_rgba(0,0,0,.18)]\" : \"bg-black/50 text-[#e6e7ea] shadow-[inset_0_2px_8px_rgba(0,0,0,.7)]\",\n            )}\n            aria-live=\"off\"\n          >\n            <span\n              aria-hidden\n              className=\"size-[calc(var(--u)*0.14)] shrink-0 rounded-full transition-colors\"\n              style={{ background: focused || global ? accent : \"rgba(128,128,128,.4)\", boxShadow: focused || global ? `0 0 8px ${accent}` : undefined }}\n            />\n            <span className=\"min-w-0 flex-1 truncate whitespace-pre\" dir=\"ltr\">\n              {text || <span className=\"opacity-40\">{focused || global ? \"Start typing…\" : \"Click here, then type\"}</span>}\n              {(focused || global) && (\n                <span\n                  aria-hidden\n                  className=\"ml-px inline-block h-[1.05em] w-[0.1em] translate-y-[0.15em] animate-[kb-caret_1s_steps(1)_infinite]\"\n                  style={{ background: accent }}\n                />\n              )}\n            </span>\n          </div>\n        )}\n        {ROWS.map((row, i) => (\n          <div key={i} className=\"flex gap-(--g)\">\n            {row.map((d) => cap(d))}\n            {i === 4 && arrows}\n          </div>\n        ))}\n      </div>\n    </div>\n  );\n}\n"}]}