{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "api-key-field",
  "title": "API Key Field",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "Displays a secret such as an API key, masked by default (sk-live-••••••••a1b2), with a reveal toggle, a copy button that copies the full key, and an optional regenerate action.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/api-key-field.tsx",
      "content": "// Ballmac UI: API Key Field. https://ui.ballmac.com/components/api-key-field\n\"use client\"\n\nimport * as React from \"react\"\nimport { Check, Copy, Eye, EyeOff, RefreshCw } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useMessages } from \"@/lib/ballmac/i18n\"\n\nconst MASK = \"••••••••\"\n\n/** Masks a secret, keeping `prefix` leading and `suffix` trailing characters. The mask length is fixed so it doesn't leak the key's length. */\nfunction maskSecret(value: string, prefix: number, suffix: number) {\n  if (value.length <= prefix + suffix + 4) return MASK\n  return `${value.slice(0, prefix)}${MASK}${suffix > 0 ? value.slice(-suffix) : \"\"}`\n}\n\nconst iconButton =\n  \"flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground outline-none transition-colors duration-150 hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-pressed:text-foreground\"\n\ntype ApiKeyFieldProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** The secret. It is only rendered in full while revealed and is never logged. */\n  value: string\n  /** Visible label, also the accessible name of the group. */\n  label?: React.ReactNode\n  /** Helper text under the field, e.g. when the key was created. */\n  description?: React.ReactNode\n  /** Leading characters left visible while masked, e.g. 8 keeps \"sk-live-\". */\n  visiblePrefix?: number\n  /** Trailing characters left visible while masked. */\n  visibleSuffix?: number\n  /** Controlled reveal state. */\n  revealed?: boolean\n  /** Initial reveal state when uncontrolled. */\n  defaultRevealed?: boolean\n  /** Called when the reveal toggle is pressed. */\n  onRevealedChange?: (revealed: boolean) => void\n  /** Called after the full key was copied to the clipboard. */\n  onCopy?: () => void\n  /** Shows a regenerate button. Asking for confirmation is up to you. */\n  onRegenerate?: () => void\n  /** Disables the regenerate button and spins its icon while a new key is created. */\n  regenerating?: boolean\n}\n\nfunction ApiKeyField({\n  value,\n  label,\n  description,\n  visiblePrefix = 8,\n  visibleSuffix = 4,\n  revealed: revealedProp,\n  defaultRevealed = false,\n  onRevealedChange,\n  onCopy,\n  onRegenerate,\n  regenerating = false,\n  className,\n  ...props\n}: ApiKeyFieldProps) {\n  const msg = useMessages()\n  label ??= msg(\"api-key-field.label\", \"API key\")\n  const id = React.useId()\n  const labelId = `${id}-label`\n  const descriptionId = `${id}-description`\n  const [internalRevealed, setInternalRevealed] = React.useState(defaultRevealed)\n  const revealed = revealedProp ?? internalRevealed\n  const [copied, setCopied] = React.useState(false)\n  const timer = React.useRef<ReturnType<typeof setTimeout>>(undefined)\n  React.useEffect(() => () => clearTimeout(timer.current), [])\n\n  const masked = maskSecret(value, visiblePrefix, visibleSuffix)\n  const partial = value.length > visiblePrefix + visibleSuffix + 4\n  const head = partial ? value.slice(0, visiblePrefix) : \"\"\n  const tail = partial && visibleSuffix > 0 ? value.slice(-visibleSuffix) : \"\"\n\n  async function copy() {\n    try {\n      await navigator.clipboard.writeText(value)\n    } catch {\n      return\n    }\n    setCopied(true)\n    onCopy?.()\n    clearTimeout(timer.current)\n    timer.current = setTimeout(() => setCopied(false), 1800)\n  }\n\n  function toggle() {\n    const next = !revealed\n    if (revealedProp === undefined) setInternalRevealed(next)\n    onRevealedChange?.(next)\n  }\n\n  return (\n    <div\n      data-slot=\"api-key-field\"\n      data-revealed={revealed || undefined}\n      role=\"group\"\n      aria-labelledby={labelId}\n      aria-describedby={description ? descriptionId : undefined}\n      className={cn(\"grid w-full min-w-0 gap-2\", className)}\n      {...props}\n    >\n      <div id={labelId} className=\"text-sm font-medium\">\n        {label}\n      </div>\n      <div className=\"flex h-10 min-w-0 items-center gap-1 rounded-md border border-input bg-card pe-1 ps-3 shadow-xs\">\n        <code\n          data-slot=\"api-key-field-value\"\n          translate=\"no\"\n          dir=\"ltr\"\n          // Focusable so keyboard users can scroll a long key in a narrow field.\n          tabIndex={0}\n          className={cn(\n            \"min-w-0 flex-1 overflow-x-auto rounded-sm font-mono text-[13px] whitespace-nowrap outline-none [scrollbar-width:none] focus-visible:ring-[3px] focus-visible:ring-ring/50\",\n            revealed ? \"select-all\" : \"select-none\"\n          )}\n        >\n          {revealed ? (\n            value\n          ) : (\n            <>\n              <span aria-hidden=\"true\">{masked}</span>\n              <span className=\"sr-only\">\n                {head ? `${head}, rest hidden` : \"Hidden\"}\n                {tail ? `, ends in ${tail}` : \"\"}\n              </span>\n            </>\n          )}\n        </code>\n        <button type=\"button\" aria-label={msg(\"api-key-field.showKey\", \"Show key\")} aria-pressed={revealed} title={revealed ? msg(\"api-key-field.hideKey\", \"Hide key\") : msg(\"api-key-field.showKey\", \"Show key\")} onClick={toggle} className={iconButton}>\n          {revealed ? <EyeOff aria-hidden=\"true\" className=\"size-4\" /> : <Eye aria-hidden=\"true\" className=\"size-4\" />}\n        </button>\n        <button type=\"button\" aria-label={copied ? msg(\"api-key-field.copied\", \"Copied\") : msg(\"api-key-field.copyKey\", \"Copy key\")} title={msg(\"api-key-field.copyKey\", \"Copy key\")} onClick={copy} className={iconButton}>\n          {copied ? <Check aria-hidden=\"true\" className=\"size-4\" /> : <Copy aria-hidden=\"true\" className=\"size-4\" />}\n        </button>\n        {onRegenerate ? (\n          <button\n            type=\"button\"\n            aria-label={msg(\"api-key-field.regenerateKey\", \"Regenerate key\")}\n            title={msg(\"api-key-field.regenerateKey\", \"Regenerate key\")}\n            disabled={regenerating}\n            aria-busy={regenerating || undefined}\n            onClick={onRegenerate}\n            className={iconButton}\n          >\n            <RefreshCw aria-hidden=\"true\" className={cn(\"size-4\", regenerating && \"animate-spin motion-reduce:animate-none\")} />\n          </button>\n        ) : null}\n      </div>\n      {description ? (\n        <p id={descriptionId} className=\"text-xs text-muted-foreground\">\n          {description}\n        </p>\n      ) : null}\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {copied ? \"Key copied to clipboard\" : \"\"}\n      </span>\n    </div>\n  )\n}\n\nexport { ApiKeyField, maskSecret, type ApiKeyFieldProps }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/api-key-field.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "api-key",
      "secret",
      "token",
      "copy",
      "mask",
      "settings",
      "developer"
    ],
    "version": "1.0.1",
    "updated": "2026-09-29",
    "ai": {
      "summary": "Show an existing secret: <ApiKeyField value={key} label='Secret key' onRegenerate={confirmThenRotate} />. It is read-only; copy always copies the full value, even while masked.",
      "whenToUse": [
        "API keys, webhook signing secrets and access tokens on settings or developer pages",
        "Showing a newly created key once so the user can copy it"
      ],
      "whenNotToUse": [
        "Password entry (use an input with type=password)",
        "Secrets you should not send to the browser at all (show only the last four characters from the server)"
      ],
      "composesWith": [
        "button",
        "dialog"
      ],
      "a11y": [
        {
          "keys": "Tab",
          "action": "Moves between reveal, copy and regenerate buttons"
        },
        {
          "keys": "Enter / Space",
          "action": "Reveal is a toggle button (aria-pressed); copy announces 'Key copied to clipboard'"
        },
        {
          "keys": "—",
          "action": "While masked, screen readers hear the prefix and last characters, not bullet characters"
        }
      ],
      "customization": [
        "visiblePrefix (default 8) and visibleSuffix (default 4) characters stay visible; the mask length is fixed so it doesn't reveal the key length",
        "revealed/defaultRevealed/onRevealedChange",
        "onRegenerate shows a regenerate button; confirmation is the caller's job (e.g. a dialog); regenerating spins it",
        "onCopy runs after a successful copy; the component never logs the value"
      ]
    },
    "examples": [
      "api-key-field-demo"
    ],
    "url": "https://ui.ballmac.com/components/api-key-field"
  },
  "categories": [
    "developer"
  ],
  "type": "registry:ui"
}