Environment Variables
Masked key/value rows for the agent's environment (UC-CODE-01 AC-4: masked by default, reveal a single value on demand). Port-at-parity per the PRD verdict. Upstream part set at parity (Header/Title/Toggle/Content/Variable/Name/Value/Required/CopyButton/Group; showValues/defaultShowValues=false/onShowValuesChange) — the Toggle is RNR's vendored Switch, matching the KB's documented composition. ONE declared addition: upstream's reveal is global-only, so each row also carries a per-value eye toggle to satisfy AC-4's single-value wording; the clipboard always receives the REAL value (the mask is a display guard, documented in the logic). The copy emits the KB's documented export KEY="value" line with quotes escaped. Composed on the table atom per the build plan: one KEY/VALUE table, fixed column sizes, horizontal scroll for overflowing keys, groups as label rows, mono keys via lib/mono. No input ships — the upstream part set has no editable field; editing the environment is the caller's app.
- v0.2.1
- component
- nativewind
- uniwind
- runs in Expo Go
Install
npx @react-native-reusables/cli@latest add https://rnraielements.com/r/nativewind/environment-variables.jsonnpx @react-native-reusables/cli@latest add https://rnraielements.com/r/uniwind/environment-variables.jsonDependencies
expo-clipboard
https://reactnativereusables.com/r/nativewind/button.json |
https://reactnativereusables.com/r/nativewind/icon.json |
https://reactnativereusables.com/r/nativewind/switch.json |
https://reactnativereusables.com/r/nativewind/text.json |
https://rnraielements.com/r/nativewind/mono.json |
https://rnraielements.com/r/nativewind/table.json |
https://reactnativereusables.com/r/nativewind/badge.json |
https://reactnativereusables.com/r/uniwind/button.json |
https://reactnativereusables.com/r/uniwind/icon.json |
https://reactnativereusables.com/r/uniwind/switch.json |
https://reactnativereusables.com/r/uniwind/text.json |
https://rnraielements.com/r/uniwind/mono.json |
https://rnraielements.com/r/uniwind/table.json |
https://reactnativereusables.com/r/uniwind/badge.json |
AI SDK peers:ai@^7.0.89@ai-sdk/react@^4.0.92
Source
import { Badge } from '@/registry/nativewind/components/ui/badge';
import { Button } from '@/registry/nativewind/components/ui/button';
import { Icon } from '@/registry/nativewind/components/ui/icon';
import { Switch } from '@/registry/nativewind/components/ui/switch';
import { Text, TextClassContext } from '@/registry/nativewind/components/ui/text';
import {
Table,
TableBody,
TableCell,
TableRow,
} from '@/registry/nativewind/components/ui/table';
import { monoStyle } from '@/registry/nativewind/lib/mono';
import { cn } from '@/registry/nativewind/lib/utils';
import * as Clipboard from 'expo-clipboard';
import { CheckIcon, CopyIcon, EyeIcon, EyeOffIcon } from 'lucide-react-native';
import * as React from 'react';
import { View, type ViewProps } from 'react-native';
import { formatEnvLine, maskedValue } from './environment-variables.logic';
/**
* EnvironmentVariables — masked key/value rows for the agent's environment
* (UC-CODE-01 AC-4: "view environment variables with values masked by default and
* reveal a single value on demand").
*
* THE PRD VERDICT IS PORT-AT-PARITY. The upstream part set, from the KB, ported part
* for part: EnvironmentVariables root (showValues / defaultShowValues=false /
* onShowValuesChange), EnvironmentVariablesHeader (Title + Toggle — the KB's "...props
* Switch", so the toggle is RNR's vendored Switch, matching upstream exactly),
* EnvironmentVariablesContent, EnvironmentVariable (name + value, both required),
* EnvironmentVariableName, EnvironmentVariableValue, EnvironmentVariableRequired
* (Badge), EnvironmentVariableCopyButton, and EnvironmentVariableGroup.
*
* THE ONE DECLARED ADDITION: upstream's reveal is a single global Switch, but AC-4
* says "reveal a SINGLE value on demand" — so each row also carries a per-value eye
* toggle (EnvironmentVariableRevealButton), wired from the Variable's local state.
* Visible = global toggle OR the row's own reveal. The clipboard always receives the
* REAL value either way (environment-variables.logic documents why).
*
* COMPOSITION: the build plan names `table` as the backing primitive for this surface,
* so the content is ONE Table — headerless (the web content is a bare divide-y div and
* its rows put the name left, value right — environment-variables.tsx:137,227; the
* port's added KEY/VALUE header rows are gone, remediation row 12), one row per
* variable, groups as label rows inside the same body. Rows carry the table atom's own
* border-b, the divide-y equivalent, and the table scrolls horizontally when keys
* overflow (the table atom's own contract). Keys and values ride the house mono
* family (lib/mono).
*
* ON THE VERDICT'S WORD "input": the upstream part set contains no editable field —
* these are display rows, not a form — so no input ships. Masking/reveal is display
* state; editing the environment is the caller's app, not this registry's.
*/
type EnvVarsContextValue = {
showValues: boolean;
setShowValues: (next: boolean) => void;
};
const EnvVarsContext = React.createContext<EnvVarsContextValue | null>(null);
function useEnvVars() {
const ctx = React.useContext(EnvVarsContext);
if (!ctx) throw new Error('EnvironmentVariables sub-components must be used within <EnvironmentVariables>');
return ctx;
}
type VariableContextValue = {
name: string;
value: string;
required: boolean;
/** Global toggle OR the row's own reveal. */
visible: boolean;
toggleRevealed: () => void;
};
const VariableContext = React.createContext<VariableContextValue | null>(null);
function useVariable() {
const ctx = React.useContext(VariableContext);
if (!ctx) throw new Error('EnvironmentVariable sub-components must be used within <EnvironmentVariable>');
return ctx;
}
export type EnvironmentVariablesProps = Omit<ViewProps, 'children'> & {
/** Controlled reveal-all. Present → the component never holds state. */
showValues?: boolean;
/** Uncontrolled seed. The web default: false — masked by default IS the surface. */
defaultShowValues?: boolean;
onShowValuesChange?: (showValues: boolean) => void;
children?: React.ReactNode;
};
function EnvironmentVariables({
showValues: controlledShowValues,
defaultShowValues = false,
onShowValuesChange,
className,
children,
...props
}: EnvironmentVariablesProps) {
const [internal, setInternal] = React.useState(defaultShowValues);
const showValues = controlledShowValues ?? internal;
const contextValue = React.useMemo<EnvVarsContextValue>(
() => ({
showValues,
setShowValues: (next) => {
if (controlledShowValues === undefined) setInternal(next);
onShowValuesChange?.(next);
},
}),
[showValues, controlledShowValues, onShowValuesChange],
);
return (
<EnvVarsContext.Provider value={contextValue}>
<View
className={cn('overflow-hidden rounded-lg border border-border bg-card', className)}
{...props}
>
{children}
</View>
</EnvVarsContext.Provider>
);
}
function EnvironmentVariablesHeader({ className, ...props }: ViewProps & { children?: React.ReactNode }) {
return (
<View
className={cn('flex-row items-center justify-between gap-3 border-b border-border px-3 py-2', className)}
{...props}
/>
);
}
/** Tolerates a missing title like every house header — "Environment Variables" renders. */
function EnvironmentVariablesTitle({ children, className }: { children?: string; className?: string }) {
return (
<Text numberOfLines={1} className={cn('min-w-0 flex-1 text-sm font-medium text-foreground', className)}>
{children ?? 'Environment Variables'}
</Text>
);
}
/** The upstream Toggle, as the KB documents it: a Switch bound to the reveal-all
* state, with the web's leading eye glyph — EyeOff while values are hidden (the state
* the switch resolves), Eye once revealed. One affordance, never a silent switch. */
function EnvironmentVariablesToggle({ className }: { className?: string }) {
const { showValues, setShowValues } = useEnvVars();
return (
<View className={cn('flex-row items-center gap-2', className)}>
<Icon as={showValues ? EyeIcon : EyeOffIcon} size={16} className="text-muted-foreground" />
<Switch
checked={showValues}
onCheckedChange={setShowValues}
accessibilityLabel={showValues ? 'Hide values' : 'Reveal values'}
// RNR's Switch is ~18×32pt; hitSlop brings the target to the 44pt floor with no
// pixel change.
hitSlop={{ top: 13, bottom: 13, left: 6, right: 6 }}
/>
</View>
);
}
/**
* The table scaffold, headerless per the web (environment-variables.tsx:137): one
* body, groups and rows composed inside. The table atom's row border-b stands in for
* the web's divide-y.
*/
function EnvironmentVariablesContent({ className, children }: { className?: string; children?: React.ReactNode }) {
return (
<Table scrollable className={className}>
<TableBody>{children}</TableBody>
</Table>
);
}
export type EnvironmentVariableProps = {
/** Required upstream, and kept required — a keyless row is not an environment. */
name: string;
value: string;
/** Renders the required badge beside the key. */
required?: boolean;
className?: string;
/**
* Override the default value cell (value text + reveal + copy). The name cell keeps
* its default; compose the fine-grained parts for fully custom rows.
*/
children?: React.ReactNode;
};
function EnvironmentVariable({ name, value, required = false, className, children }: EnvironmentVariableProps) {
const { showValues } = useEnvVars();
const [revealed, setRevealed] = React.useState(false);
const contextValue = React.useMemo<VariableContextValue>(
() => ({
name,
value,
required,
visible: showValues || revealed,
toggleRevealed: () => setRevealed((current) => !current),
}),
[name, value, required, showValues, revealed],
);
return (
<VariableContext.Provider value={contextValue}>
<TableRow className={className}>
<TableCell className="w-40 shrink-0">
<View className="flex-row items-center gap-1.5">
<Text numberOfLines={1} style={monoStyle} className="shrink-0 text-xs text-foreground">
{name}
</Text>
{required ? <EnvironmentVariableRequired /> : null}
</View>
</TableCell>
<TableCell className="w-64 shrink-0">
{children ?? (
<View className="flex-row items-center gap-1">
<EnvironmentVariableValue />
<EnvironmentVariableRevealButton />
<EnvironmentVariableCopyButton />
</View>
)}
</TableCell>
</TableRow>
</VariableContext.Provider>
);
}
/** The key, from context — the part the KB describes as "defaults to name". */
function EnvironmentVariableName({ className }: { className?: string }) {
const { name } = useVariable();
return (
<Text numberOfLines={1} style={monoStyle} className={cn('text-xs text-foreground', className)}>
{name}
</Text>
);
}
/**
* The value cell's text: masked by default (AC-4), real when visible. The mask renders
* in the muted pole so a masked row reads as "deliberately hidden", not broken.
*/
function EnvironmentVariableValue({ className }: { className?: string }) {
const { name, value, visible } = useVariable();
return (
<Text
numberOfLines={1}
style={monoStyle}
accessibilityLabel={visible ? `${name} is ${value}` : `${name} is masked`}
className={cn('min-w-0 flex-1 text-xs', visible ? 'text-foreground' : 'text-muted-foreground', className)}
>
{visible ? value : maskedValue(value)}
</Text>
);
}
/** The upstream badge — the web's filled muted chip reading "Required" beside the key.
* Filled (secondary), not outline: the chip is a state marker, and the web paints it
* solid gray against the row. */
function EnvironmentVariableRequired({ className }: { className?: string }) {
return (
<Badge variant="secondary" className={cn('shrink-0', className)}>
<Text>Required</Text>
</Badge>
);
}
/** THE DECLARED ADDITION: the per-row half of AC-4's "reveal a single value on demand". */
function EnvironmentVariableRevealButton({ className }: { className?: string }) {
const { visible, toggleRevealed } = useVariable();
return (
<Button
variant="ghost"
size="icon"
onPress={toggleRevealed}
accessibilityLabel={visible ? 'Hide value' : 'Reveal value'}
accessibilityState={{ selected: visible }}
// House formula: RNR's h-10 (40pt) + hitSlop 2/side = the 44pt platform minimum.
hitSlop={{ top: 2, bottom: 2, left: 2, right: 2 }}
className={cn('shrink-0', className)}
>
<Icon as={visible ? EyeOffIcon : EyeIcon} size={14} className="text-muted-foreground" />
</Button>
);
}
type EnvironmentVariableCopyButtonProps = {
timeout?: number;
onCopy?: () => void;
onError?: (error: unknown) => void;
className?: string;
};
/**
* Copies the KB's documented format — `export KEY="value"` — with the REAL value even
* while masked (the mask is a display guard; a copied mask is worse than useless).
*/
function EnvironmentVariableCopyButton({ timeout = 2000, onCopy, onError, className }: EnvironmentVariableCopyButtonProps) {
const { name, value } = useVariable();
const [copied, setCopied] = React.useState(false);
const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
React.useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []);
async function copy() {
try {
await Clipboard.setStringAsync(formatEnvLine(name, value));
setCopied(true);
onCopy?.();
timer.current = setTimeout(() => setCopied(false), timeout);
} catch (error) {
// Never silent — the web original calls onError and so do we.
onError?.(error);
}
}
return (
<Button
variant="ghost"
size="icon"
onPress={copy}
accessibilityLabel={copied ? 'Copied' : `Copy ${name}`}
// House formula: RNR's h-10 (40pt) + hitSlop 2/side = the 44pt platform minimum.
hitSlop={{ top: 2, bottom: 2, left: 2, right: 2 }}
className={cn('shrink-0', className)}
>
<Icon as={copied ? CheckIcon : CopyIcon} size={14} className="text-muted-foreground" />
</Button>
);
}
/**
* A labelled band of rows inside the one shared table — a muted label row, then the
* group's rows. (Upstream's group is an unstyled div; the label row is the smallest
* RN-honest shape that keeps one table, one scroll.)
*/
function EnvironmentVariableGroup({
label,
className,
children,
}: {
label: string;
className?: string;
children?: React.ReactNode;
}) {
return (
<React.Fragment>
<TableRow className={cn('bg-muted/50', className)}>
<TableCell className="w-40 shrink-0">
<TextClassContext.Provider value="text-xs font-medium text-muted-foreground">
<Text>{label}</Text>
</TextClassContext.Provider>
</TableCell>
</TableRow>
{children}
</React.Fragment>
);
}
export {
EnvironmentVariable,
EnvironmentVariableCopyButton,
EnvironmentVariableGroup,
EnvironmentVariableName,
EnvironmentVariableRequired,
EnvironmentVariableRevealButton,
EnvironmentVariableValue,
EnvironmentVariables,
EnvironmentVariablesContent,
EnvironmentVariablesHeader,
EnvironmentVariablesTitle,
EnvironmentVariablesToggle,
useEnvVars,
useVariable,
};
/**
* EnvironmentVariables — pure logic. Zero react-native imports, so the Vitest tier owns
* it (see the header of lib/reasoning-lifecycle.ts for why component files themselves
* cannot load under Node).
*
* Two pure behaviors from the web original:
* - MASKING: upstream masks values while `showValues` is false. The mask length is
* deterministic from the real value's length (clamped to 8–24 bullets) so rows
* visually differ without ever leaking the secret through geometry — an empty value
* still shows a full mask, never zero-width.
* - THE COPY FORMAT: the KB documents the copy feature as the `export KEY="value"`
* line. Double quotes inside the value are escaped (`\"`) so the pasted line parses
* in a POSIX shell; no other escaping is invented.
*
* The reveal toggle is a DISPLAY guard (shoulder-surfing), not an encryption boundary:
* the clipboard always receives the real value. Declared here because a masked value
* that silently copies its mask would be worse than useless.
*/
/** The mask never renders shorter than this, whatever the secret's length. */
export const ENV_MASK_MIN = 8;
/** The mask never renders longer than this — a 200-char secret must not widen the row. */
export const ENV_MASK_MAX = 24;
/** The masked presentation of a value: one bullet per character, clamped to 8–24. */
export function maskedValue(value: string): string {
const length = Math.min(Math.max(value.length, ENV_MASK_MIN), ENV_MASK_MAX);
return '•'.repeat(length);
}
/** The line the copy button puts on the clipboard: `export KEY="value"`. */
export function formatEnvLine(name: string, value: string): string {
return `export ${name.trim()}="${value.replace(/"/g, '\\"')}"`;
}
Props
| Name | Type | Default | Description |
|---|---|---|---|
| showValues | boolean | undefined | — | Controlled reveal-all. Present → the component never holds state. |
| defaultShowValues | boolean | undefined | false | Uncontrolled seed. The web default: false — masked by default IS the surface. |
| onShowValuesChange | ((showValues: boolean) => void) | undefined | — | — |
| children | React.ReactNode | — | — |
Theming
Themed via border-border, bg-card, text-foreground, text-muted-foreground, bg-muted.
Usage
() => (
<EnvironmentVariables showValues={false}>
<EnvironmentVariable name="GITHUB_TOKEN" value="ghp_7f3a…9c2e" required>
<EnvironmentVariableName>
<EnvironmentVariableRequired />
</EnvironmentVariableName>
</EnvironmentVariable>
<EnvironmentVariable name="EXPO_PUBLIC_REGISTRY_TAG" value="v0.2.0" />
</EnvironmentVariables>
)