{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "env-editor",
  "title": "Env Editor",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A .env editor with masked values, show and hide, name validation and duplicate detection, paste-to-import for whole .env files, and copy as .env.",
  "dependencies": [
    "motion@^12",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/copy-button.json",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/env-editor.tsx",
      "content": "// Ballmac UI: Env Editor. https://ui.ballmac.com/components/env-editor\n\"use client\"\n\nimport * as React from \"react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\"\nimport { AlertCircle, Eye, EyeOff, Plus, Trash2, ClipboardPaste } from \"lucide-react\"\n\nimport { CopyButton } from \"@/components/ballmac/copy-button\"\nimport { cn } from \"@/lib/utils\"\nimport { useMessages } from \"@/lib/ballmac/i18n\"\n\ntype EnvVariable = {\n  /** Stable id for the row. */\n  id: string\n  /** Variable name, such as DATABASE_URL. */\n  key: string\n  /** Its value. */\n  value: string\n}\n\nconst KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/\n\n/** Reads `KEY=value` lines, skipping blanks and `#` comments, handling `export`, quotes and inline comments after quoted values. */\nfunction parseEnv(text: string): { key: string; value: string }[] {\n  const out: { key: string; value: string }[] = []\n  for (const raw of text.split(/\\r?\\n/)) {\n    const line = raw.trim()\n    if (!line || line.startsWith(\"#\")) continue\n    const m = line.match(/^(?:export\\s+)?([A-Za-z_][A-Za-z0-9_.-]*)\\s*=\\s*(.*)$/)\n    if (!m) continue\n    let value = m[2]!.trim()\n    const quote = value[0]\n    if ((quote === '\"' || quote === \"'\") && value.lastIndexOf(quote) > 0) {\n      value = value.slice(1, value.lastIndexOf(quote))\n      if (quote === '\"') value = value.replace(/\\\\n/g, \"\\n\").replace(/\\\\\"/g, '\"')\n    } else {\n      value = value.replace(/\\s+#.*$/, \"\")\n    }\n    out.push({ key: m[1]!, value })\n  }\n  return out\n}\n\n/** Writes variables as a .env file, quoting values that need it. */\nfunction formatEnv(variables: { key: string; value: string }[]) {\n  return variables\n    .filter((v) => v.key.trim())\n    .map((v) => {\n      const needsQuotes = /[\\s#\"'\\\\]/.test(v.value) || v.value === \"\"\n      const value = needsQuotes ? `\"${v.value.replace(/\\\\/g, \"\\\\\\\\\").replace(/\"/g, '\\\\\"').replace(/\\n/g, \"\\\\n\")}\"` : v.value\n      return `${v.key}=${value}`\n    })\n    .join(\"\\n\")\n}\n\nfunction problemFor(variable: EnvVariable, all: EnvVariable[]) {\n  if (!variable.key && !variable.value) return null\n  if (!variable.key) return \"Add a name for this value.\"\n  if (!KEY_PATTERN.test(variable.key)) return \"Use letters, numbers and underscores, and don't start with a number.\"\n  if (all.filter((v) => v.key === variable.key).length > 1) return \"This name is used more than once.\"\n  return null\n}\n\ntype EnvEditorProps = Omit<React.ComponentProps<\"div\">, \"defaultValue\" | \"onChange\"> & {\n  /** Variables (controlled). */\n  value?: EnvVariable[]\n  /** Initial variables when uncontrolled. */\n  defaultValue?: Omit<EnvVariable, \"id\">[]\n  /** Called with the new list after every change. */\n  onChange?: (variables: EnvVariable[]) => void\n  /** Names whose values are shown masked until revealed. Defaults to every row. */\n  maskAll?: boolean\n  /** Title shown above the rows. */\n  title?: string\n  /** Helper text under the title. */\n  description?: string\n  /** Disables editing, for read-only views. */\n  disabled?: boolean\n}\n\nfunction EnvEditor({\n  value: valueProp,\n  defaultValue = [],\n  onChange,\n  maskAll = true,\n  title,\n  description,\n  disabled = false,\n  className,\n  ...props\n}: EnvEditorProps) {\n  const msg = useMessages()\n  title ??= msg(\"env-editor.title\", \"Environment variables\")\n  const reduce = useReducedMotion()\n  const uid = React.useId()\n  const counter = React.useRef(0)\n  const nextId = React.useCallback(() => `${uid}-${counter.current++}`, [uid])\n  const [internal, setInternal] = React.useState<EnvVariable[]>(() => defaultValue.map((v) => ({ ...v, id: `${uid}-${counter.current++}` })))\n  const variables = valueProp ?? internal\n  const [revealed, setRevealed] = React.useState<Set<string>>(new Set())\n  const [pasteOpen, setPasteOpen] = React.useState(false)\n  const [pasteText, setPasteText] = React.useState(\"\")\n  const [announce, setAnnounce] = React.useState(\"\")\n  const focusId = React.useRef<string | null>(null)\n  const addRef = React.useRef<HTMLButtonElement>(null)\n\n  function commit(next: EnvVariable[]) {\n    if (valueProp === undefined) setInternal(next)\n    onChange?.(next)\n  }\n\n  React.useEffect(() => {\n    if (!focusId.current) return\n    document.getElementById(`${focusId.current}-key`)?.focus()\n    focusId.current = null\n  })\n\n  function update(id: string, patch: Partial<EnvVariable>) {\n    commit(variables.map((v) => (v.id === id ? { ...v, ...patch } : v)))\n  }\n\n  function add() {\n    const id = nextId()\n    focusId.current = id\n    commit([...variables, { id, key: \"\", value: \"\" }])\n    setAnnounce(`Added variable ${variables.length + 1}`)\n  }\n\n  function remove(variable: EnvVariable, index: number) {\n    commit(variables.filter((v) => v.id !== variable.id))\n    setAnnounce(`Removed ${variable.key || `variable ${index + 1}`}`)\n    const prev = variables[index - 1]\n    if (prev) focusId.current = prev.id\n    else addRef.current?.focus()\n  }\n\n  function importText(text: string, base: EnvVariable[] = variables) {\n    const parsed = parseEnv(text)\n    if (parsed.length === 0) return 0\n    const merged = [...base.filter((v) => v.key || v.value)]\n    for (const p of parsed) {\n      const at = merged.findIndex((v) => v.key === p.key)\n      if (at >= 0) merged[at] = { ...merged[at]!, value: p.value }\n      else merged.push({ id: nextId(), ...p })\n    }\n    commit(merged)\n    setAnnounce(`Imported ${parsed.length} ${parsed.length === 1 ? \"variable\" : \"variables\"}`)\n    return parsed.length\n  }\n\n  const allRevealed = variables.length > 0 && variables.every((v) => revealed.has(v.id))\n  const problems = variables.map((v) => problemFor(v, variables))\n  const bad = problems.filter(Boolean).length\n\n  return (\n    <div\n      data-slot=\"env-editor\"\n      dir=\"ltr\"\n      className={cn(\"@container w-full overflow-hidden rounded-xl border bg-card text-card-foreground\", className)}\n      {...props}\n    >\n      <div className=\"flex flex-wrap items-center gap-2 border-b px-4 py-3\">\n        <div className=\"min-w-0 flex-1\">\n          <h3 className=\"text-sm font-semibold text-foreground\">{title}</h3>\n          <p className=\"text-xs text-muted-foreground\">\n            {description ?? msg(\"env-editor.variableCount\", { one: \"{count} variable\", other: \"{count} variables\" }, { count: variables.length })}\n            {bad > 0 && <span className=\"text-foreground\"> · {msg(\"env-editor.toFix\", \"{count} to fix\", { count: bad })}</span>}\n          </p>\n        </div>\n        <button\n          type=\"button\"\n          disabled={variables.length === 0}\n          onClick={() => setRevealed(allRevealed ? new Set() : new Set(variables.map((v) => v.id)))}\n          className=\"inline-flex h-8 items-center gap-1.5 rounded-md px-2.5 text-[13px] font-medium text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50\"\n        >\n          {allRevealed ? <EyeOff aria-hidden=\"true\" className=\"size-4\" /> : <Eye aria-hidden=\"true\" className=\"size-4\" />}\n          {allRevealed ? \"Hide all\" : \"Show all\"}\n        </button>\n        <CopyButton\n          variant=\"outline\"\n          label=\"Copy .env\"\n          ariaLabel=\"Copy as .env file\"\n          copiedLabel=\"Copied\"\n          getValue={() => formatEnv(variables)}\n          disabled={variables.length === 0}\n        />\n      </div>\n\n      {variables.length === 0 ? (\n        <div className=\"px-4 py-10 text-center\">\n          <p className=\"text-sm font-medium text-foreground\">{msg(\"env-editor.noVariablesYet\", \"No variables yet\")}</p>\n          <p className=\"mx-auto mt-1 max-w-xs text-[13px] text-muted-foreground\">\n            Add one by hand, or paste the contents of a .env file to import them all at once.\n          </p>\n        </div>\n      ) : (\n        <ul>\n          <AnimatePresence initial={false}>\n            {variables.map((v, i) => {\n              const problem = problems[i]\n              const shown = !maskAll || revealed.has(v.id)\n              const errId = `${v.id}-err`\n              return (\n                <motion.li\n                  key={v.id}\n                  layout={reduce ? false : \"position\"}\n                  initial={reduce ? false : { opacity: 0, height: 0 }}\n                  animate={{ opacity: 1, height: \"auto\" }}\n                  exit={reduce ? { opacity: 0 } : { opacity: 0, height: 0 }}\n                  transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}\n                  className=\"overflow-hidden border-b last:border-b-0\"\n                >\n                  <div className=\"grid gap-2 px-4 py-2.5 @lg:grid-cols-[minmax(0,13rem)_minmax(0,1fr)_auto] @lg:items-start\">\n                    <input\n                      id={`${v.id}-key`}\n                      value={v.key}\n                      disabled={disabled}\n                      spellCheck={false}\n                      autoCapitalize=\"characters\"\n                      autoComplete=\"off\"\n                      placeholder={msg(\"env-editor.key\", \"KEY\")}\n                      aria-label={msg(\"env-editor.nameOfVariable\", \"Name of variable {n}\", { n: i + 1 })}\n                      aria-invalid={problem ? true : undefined}\n                      aria-describedby={problem ? errId : undefined}\n                      onChange={(e) => update(v.id, { key: e.target.value })}\n                      onPaste={(e) => {\n                        const text = e.clipboardData.getData(\"text\")\n                        if (!/[=\\n]/.test(text)) return\n                        if (parseEnv(text).length === 0) return\n                        e.preventDefault()\n                        importText(text, !v.key && !v.value ? variables.filter((x) => x.id !== v.id) : variables)\n                      }}\n                      className={cn(\n                        \"h-9 w-full rounded-md border bg-background px-2.5 font-mono text-[13px] outline-none transition-[border-color,box-shadow] placeholder:text-muted-foreground/70 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-60\",\n                        problem && \"border-destructive focus-visible:ring-destructive/25\"\n                      )}\n                    />\n                    <div className=\"relative\">\n                      <input\n                        value={v.value}\n                        disabled={disabled}\n                        type={shown ? \"text\" : \"password\"}\n                        spellCheck={false}\n                        autoComplete=\"off\"\n                        data-1p-ignore\n                        data-lpignore=\"true\"\n                        placeholder={msg(\"env-editor.value\", \"value\")}\n                        aria-label={msg(\"env-editor.valueOf\", \"Value of {name}\", { name: v.key || msg(\"env-editor.variableN\", \"variable {n}\", { n: i + 1 }) })}\n                        onChange={(e) => update(v.id, { value: e.target.value })}\n                        className=\"h-9 w-full rounded-md border bg-background pe-9 ps-2.5 font-mono text-[13px] outline-none transition-[border-color,box-shadow] placeholder:text-muted-foreground/70 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-60\"\n                      />\n                      {maskAll && (\n                        <button\n                          type=\"button\"\n                          aria-pressed={shown}\n                          aria-label={shown ? msg(\"env-editor.hideValueOf\", \"Hide value of {name}\", { name: v.key || msg(\"env-editor.variableN\", \"variable {n}\", { n: i + 1 }) }) : msg(\"env-editor.showValueOf\", \"Show value of {name}\", { name: v.key || msg(\"env-editor.variableN\", \"variable {n}\", { n: i + 1 }) })}\n                          onClick={() =>\n                            setRevealed((set) => {\n                              const next = new Set(set)\n                              if (next.has(v.id)) next.delete(v.id)\n                              else next.add(v.id)\n                              return next\n                            })\n                          }\n                          className=\"absolute top-1 end-1 inline-flex size-7 items-center justify-center rounded-md text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50\"\n                        >\n                          {shown ? <EyeOff aria-hidden=\"true\" className=\"size-4\" /> : <Eye aria-hidden=\"true\" className=\"size-4\" />}\n                        </button>\n                      )}\n                    </div>\n                    <button\n                      type=\"button\"\n                      disabled={disabled}\n                      aria-label={msg(\"env-editor.remove\", \"Remove {name}\", { name: v.key || msg(\"env-editor.variableN\", \"variable {n}\", { n: i + 1 }) })}\n                      onClick={() => remove(v, i)}\n                      className=\"inline-flex size-9 items-center justify-center justify-self-end rounded-md text-muted-foreground outline-none transition-colors hover:bg-destructive/10 hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 @lg:justify-self-auto\"\n                    >\n                      <Trash2 aria-hidden=\"true\" className=\"size-4\" />\n                    </button>\n                  </div>\n                  {problem && (\n                    <p id={errId} className=\"flex items-center gap-1.5 px-4 pb-2.5 text-xs text-foreground\">\n                      <AlertCircle aria-hidden=\"true\" className=\"size-3.5 shrink-0 text-destructive\" />\n                      {problem}\n                    </p>\n                  )}\n                </motion.li>\n              )\n            })}\n          </AnimatePresence>\n        </ul>\n      )}\n\n      {pasteOpen && (\n        <div className=\"border-t bg-muted/30 p-4\">\n          <label htmlFor={`${uid}-paste`} className=\"mb-1.5 block text-[13px] font-medium text-foreground\">\n            {msg(\"env-editor.pasteAEnvFile\", \"Paste a .env file\")}\n          </label>\n          <textarea\n            id={`${uid}-paste`}\n            value={pasteText}\n            onChange={(e) => setPasteText(e.target.value)}\n            rows={5}\n            spellCheck={false}\n            placeholder={\"DATABASE_URL=postgres://…\\nAPI_KEY=…\"} // i18n-ignore: sample .env text\n            className=\"w-full resize-y rounded-md border bg-background p-2.5 font-mono text-[13px] outline-none placeholder:text-muted-foreground/70 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50\"\n          />\n          <div className=\"mt-2 flex justify-end gap-2\">\n            <button\n              type=\"button\"\n              onClick={() => {\n                setPasteOpen(false)\n                setPasteText(\"\")\n              }}\n              className=\"h-8 rounded-md px-3 text-[13px] font-medium text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50\"\n            >\n              {msg(\"env-editor.cancel\", \"Cancel\")}\n            </button>\n            <button\n              type=\"button\"\n              disabled={parseEnv(pasteText).length === 0}\n              onClick={() => {\n                importText(pasteText)\n                setPasteOpen(false)\n                setPasteText(\"\")\n              }}\n              className=\"h-8 rounded-md bg-primary px-3 text-[13px] font-medium text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50\"\n            >\n              {msg(\"env-editor.import\", \"Import\")} {parseEnv(pasteText).length || \"\"} {parseEnv(pasteText).length === 1 ? \"variable\" : \"variables\"}\n            </button>\n          </div>\n        </div>\n      )}\n\n      <div className=\"flex flex-wrap items-center gap-2 border-t bg-muted/30 px-4 py-3\">\n        <button\n          ref={addRef}\n          type=\"button\"\n          disabled={disabled}\n          onClick={add}\n          className=\"inline-flex h-8 items-center gap-1.5 rounded-md border bg-background px-3 text-[13px] font-medium shadow-xs outline-none transition-colors hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50\"\n        >\n          <Plus aria-hidden=\"true\" className=\"size-4\" />\n          {msg(\"env-editor.addVariable\", \"Add variable\")}\n        </button>\n        <button\n          type=\"button\"\n          disabled={disabled}\n          aria-expanded={pasteOpen}\n          onClick={() => setPasteOpen((o) => !o)}\n          className=\"inline-flex h-8 items-center gap-1.5 rounded-md px-3 text-[13px] font-medium text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50\"\n        >\n          <ClipboardPaste aria-hidden=\"true\" className=\"size-4\" />\n          {msg(\"env-editor.pasteEnv\", \"Paste .env\")}\n        </button>\n        <span className=\"ms-auto hidden text-xs text-muted-foreground @md:block\">{msg(\"env-editor.tipPasteSeveralLinesInto\", \"Tip: paste several lines into any name field to import them.\")}</span>\n      </div>\n      <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n        {announce}\n      </span>\n    </div>\n  )\n\n}\n\nexport { EnvEditor, parseEnv, formatEnv, type EnvEditorProps, type EnvVariable }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/env-editor.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "env",
      "environment",
      "variables",
      "secrets",
      "config"
    ],
    "version": "1.0.0",
    "updated": "2026-10-01",
    "ai": {
      "summary": "Uncontrolled with defaultValue [{ key, value }] or controlled with value [{ id, key, value }] and onChange. Pasting KEY=value lines into a name field, or into the Paste .env box, imports them. parseEnv and formatEnv are exported.",
      "whenToUse": [
        "Project or deployment settings pages",
        "Anywhere people manage secrets as key and value pairs"
      ],
      "whenNotToUse": [
        "Structured settings with types (settings-panel)",
        "A single secret (api-key-field)"
      ],
      "composesWith": [
        "api-key-field",
        "settings-panel",
        "copy-button"
      ],
      "a11y": [
        {
          "keys": "Tab",
          "action": "Name, value, show/hide, remove for every row, in order"
        },
        {
          "keys": "Screen readers",
          "action": "Inputs are named 'Name of variable 2' and 'Value of API_KEY'; problems are linked with aria-describedby; add and remove are announced"
        },
        {
          "keys": "Focus",
          "action": "A new row focuses its name; removing focuses the row above"
        }
      ],
      "customization": [
        "maskAll",
        "disabled",
        "title and description",
        "onChange"
      ]
    },
    "examples": [
      "env-editor-demo",
      "env-editor-controlled"
    ],
    "url": "https://ui.ballmac.com/components/env-editor"
  },
  "categories": [
    "developer"
  ],
  "type": "registry:ui"
}