Developerui
Env var editor
Edit environment variables; paste a .env file to import.
KeyValue
Tip: paste a .env file into any key field to import it.
Installation
npx shadcn@latest add @particular/env-var-editornpm dependencies
- cn@^0.4.0
- lucide-react@^1.51.0
Installs these registry items
Source
"use client"
import * as React from "react"
import { PlusIcon, Trash2Icon } from "lucide-react"
import { cn } from "cn"
import {
isValidEnvKey,
mergeEnvVars,
parseDotenv,
type EnvVar,
} from "@/lib/env-parser"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { SecretInput } from "@/components/ui/secret-input"
type EnvVarEditorProps = Omit<React.ComponentProps<"div">, "onChange"> & {
value: EnvVar[]
onChange: (value: EnvVar[]) => void
/** Mask values until revealed. */
sensitive?: boolean
disabled?: boolean
}
function looksLikeDotenv(text: string) {
return text.includes("\n") || /^\s*(export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=/.test(text)
}
function EnvVarEditor({
value,
onChange,
sensitive = true,
disabled = false,
className,
...props
}: EnvVarEditorProps) {
const [importMessage, setImportMessage] = React.useState<string | null>(null)
const keyCounts = new Map<string, number>()
for (const entry of value) keyCounts.set(entry.key, (keyCounts.get(entry.key) ?? 0) + 1)
const update = (index: number, patch: Partial<EnvVar>) =>
onChange(value.map((entry, i) => (i === index ? { ...entry, ...patch } : entry)))
const handlePaste = (index: number, event: React.ClipboardEvent<HTMLInputElement>) => {
const text = event.clipboardData.getData("text")
if (!looksLikeDotenv(text)) return
event.preventDefault()
const result = parseDotenv(text)
const withoutBlankRow = value.filter((entry, i) => i !== index || entry.key || entry.value)
onChange(mergeEnvVars(withoutBlankRow, result.entries))
const parts = [`Imported ${result.entries.length} variable${result.entries.length === 1 ? "" : "s"}`]
if (result.errors.length) {
parts.push(
`skipped ${result.errors.length}: ${result.errors.map((e) => `line ${e.line} ${e.message}`).join("; ")}`
)
}
setImportMessage(parts.join(", "))
}
return (
<div data-slot="env-var-editor" className={cn("flex flex-col gap-3", className)} {...props}>
<div className="hidden grid-cols-[minmax(0,2fr)_minmax(0,3fr)_2rem] gap-2 px-0.5 text-xs font-medium text-muted-foreground sm:grid">
<span>Key</span>
<span>Value</span>
</div>
<ul className="flex flex-col gap-2">
{value.map((entry, index) => {
const invalid = entry.key !== "" && !isValidEnvKey(entry.key)
const duplicate = entry.key !== "" && (keyCounts.get(entry.key) ?? 0) > 1
const errorId = `env-var-${index}-error`
return (
<li
key={index}
className="grid grid-cols-[minmax(0,1fr)_2rem] gap-2 sm:grid-cols-[minmax(0,2fr)_minmax(0,3fr)_2rem]"
>
<Input
aria-label={`Key ${index + 1}`}
aria-invalid={invalid || duplicate || undefined}
aria-describedby={invalid || duplicate ? errorId : undefined}
placeholder="EXAMPLE_KEY"
value={entry.key}
disabled={disabled}
spellCheck={false}
autoComplete="off"
className="font-mono text-[0.8125rem]"
onChange={(event) => update(index, { key: event.target.value })}
onPaste={(event) => handlePaste(index, event)}
/>
<div className="col-start-1 row-start-2 sm:col-start-auto sm:row-start-auto">
{sensitive ? (
<SecretInput
aria-label={`Value ${index + 1}`}
placeholder="value"
value={entry.value}
disabled={disabled}
copyable={false}
onChange={(event) => update(index, { value: event.target.value })}
/>
) : (
<Input
aria-label={`Value ${index + 1}`}
placeholder="value"
value={entry.value}
disabled={disabled}
className="font-mono text-[0.8125rem]"
onChange={(event) => update(index, { value: event.target.value })}
/>
)}
</div>
<Button
variant="ghost"
size="icon"
aria-label={`Remove ${entry.key || `row ${index + 1}`}`}
disabled={disabled}
className="row-span-2 text-muted-foreground hover:text-destructive sm:row-span-1"
onClick={() => onChange(value.filter((_, i) => i !== index))}
>
<Trash2Icon />
</Button>
{(invalid || duplicate) && (
<p id={errorId} className="col-span-full -mt-1 text-xs text-destructive">
{invalid
? "Keys start with a letter or underscore and contain only letters, digits, and underscores."
: `${entry.key} is defined more than once.`}
</p>
)}
</li>
)
})}
</ul>
<div className="flex flex-wrap items-center justify-between gap-2">
<Button
variant="outline"
size="sm"
disabled={disabled}
onClick={() => onChange([...value, { key: "", value: "" }])}
>
<PlusIcon data-icon="inline-start" />
Add variable
</Button>
<p aria-live="polite" className="text-xs text-muted-foreground">
{importMessage ?? "Tip: paste a .env file into any key field to import it."}
</p>
</div>
</div>
)
}
export { EnvVarEditor, type EnvVarEditorProps }