KeyValueEditor

Edit environment variables or HTTP headers. Keys are validated as you type (empty, duplicate and validateKey, envKeyValidator by default), secret values use a password field, and pasting .env text or Header: value lines into a key field expands into rows. Key and value stack when the editor is narrower than 440px. parseKeyValueText is exported for imports from files.

Getting Started
$pnpm add @sinups/ai-kit @mantine/core @mantine/hooks @tabler/icons-react
Examples
Environment variables
Headers
Source

The component source as it ships in the package. Useful as a reference, or as a starting point if you want to fork a piece.

primitives/KeyValueEditor/KeyValueEditor.tsx
import React, { memo, useEffect, useId, useState } from 'react';
import {
  ActionIcon,
  Box,
  Button,
  Grid,
  Group,
  PasswordInput,
  Stack,
  TextInput,
  Tooltip,
} from '@mantine/core';
import { IconLock, IconLockOpen, IconPlus, IconTrash } from '@tabler/icons-react';
import {
  createKeyValuePair,
  envKeyValidator,
  expandPastedPairs,
  isKeyValueText,
  validateKeyValuePairs,
  type KeyValidator,
  type KeyValuePair,
} from './key-value';
import classes from './KeyValueEditor.module.css';

export type KeyValueEditorLabels = {
  /** Accessible label of the remove button, `Remove` by default */
  remove: string;
  /** Accessible label of the button that masks a value, `Mark as secret` by default */
  markSecret: string;
  /** Accessible label of the button that unmasks a value, `Mark as not secret` by default */
  unmarkSecret: string;
  /** Error for a row that has a value but no key, `Key is required` by default */
  keyRequired: string;
  /** Error for a key that is already used above, `Duplicate key` by default */
  duplicateKey: string;
};

export interface KeyValueEditorProps {
  /** Rows in display order */
  value: KeyValuePair[];
  /** Called with the full list of rows after every edit */
  onChange: (pairs: KeyValuePair[]) => void;
  /** Key input placeholder and accessible label prefix, `Key` by default */
  keyPlaceholder?: string;
  /** Value input placeholder and accessible label prefix, `Value` by default */
  valuePlaceholder?: string;
  /** Add button label, `Add` by default */
  addLabel?: string;
  /** Shows a button on every row that masks or unmasks its value */
  allowSecrets?: boolean;
  /** Returns an error message for an invalid key; empty and duplicate keys are always reported, `envKeyValidator` by default */
  validateKey?: KeyValidator;
  /** Disables every input and button */
  disabled?: boolean;
  /** Maximum number of rows, the add button is disabled once reached */
  maxRows?: number;
  /** Accessible labels and built-in error messages */
  labels?: Partial<KeyValueEditorLabels>;
  /** Class name added to the root element */
  className?: string;
  /** Inline styles added to the root element */
  style?: React.CSSProperties;
}

export const DEFAULT_KEY_VALUE_EDITOR_LABELS: KeyValueEditorLabels = {
  remove: 'Remove',
  markSecret: 'Mark as secret',
  unmarkSecret: 'Mark as not secret',
  keyRequired: 'Key is required',
  duplicateKey: 'Duplicate key',
};

const GRID_BREAKPOINTS = {
  xs: '360px',
  sm: '440px',
  md: '720px',
  lg: '900px',
  xl: '1200px',
};

/** Editable key/value pairs such as environment variables or HTTP headers, with secret masking, validation and `.env` paste */
export const KeyValueEditor = memo(function KeyValueEditor({
  value,
  onChange,
  keyPlaceholder = 'Key',
  valuePlaceholder = 'Value',
  addLabel = 'Add',
  allowSecrets = false,
  validateKey = envKeyValidator,
  disabled = false,
  maxRows,
  labels: labelsOverride,
  className,
  style,
}: KeyValueEditorProps) {
  const baseId = useId();
  const [focusRowId, setFocusRowId] = useState<string | null>(null);
  const labels = { ...DEFAULT_KEY_VALUE_EDITOR_LABELS, ...labelsOverride };
  const errors = validateKeyValuePairs(value, validateKey, labels);
  const canAdd = !disabled && (maxRows === undefined || value.length < maxRows);

  useEffect(() => {
    if (focusRowId) {
      document.getElementById(`${baseId}-${focusRowId}-key`)?.focus();
      setFocusRowId(null);
    }
  }, [baseId, focusRowId]);

  const updateRow = (id: string, patch: Partial<KeyValuePair>) => {
    onChange(value.map((pair) => (pair.id === id ? { ...pair, ...patch } : pair)));
  };

  const addRow = () => {
    const pair = createKeyValuePair();
    onChange([...value, pair]);
    setFocusRowId(pair.id);
  };

  const handleKeyPaste = (id: string, event: React.ClipboardEvent<HTMLInputElement>) => {
    const text = event.clipboardData.getData('text');
    if (!isKeyValueText(text)) {
      return;
    }
    event.preventDefault();
    onChange(expandPastedPairs(value, id, text, maxRows));
  };

  return (
    <Stack gap="sm" className={className} style={style}>
      {value.map((pair, index) => {
        const valueProps = {
          size: 'sm' as const,
          'aria-label': `${valuePlaceholder} ${index + 1}`,
          placeholder: valuePlaceholder,
          value: pair.value,
          disabled,
          onChange: (event: React.ChangeEvent<HTMLInputElement>) =>
            updateRow(pair.id, { value: event.currentTarget.value }),
        };
        const secretLabel = pair.secret ? labels.unmarkSecret : labels.markSecret;

        return (
          <Group key={pair.id} gap="xs" wrap="nowrap" align="flex-start">
            <Box className={classes.fields}>
              <Grid
                type="container"
                breakpoints={GRID_BREAKPOINTS}
                gap="xs"
                rowGap="var(--ae-space-2xs)"
              >
                <Grid.Col span={{ base: 12, sm: 5 }}>
                  <TextInput
                    id={`${baseId}-${pair.id}-key`}
                    size="sm"
                    aria-label={`${keyPlaceholder} ${index + 1}`}
                    placeholder={keyPlaceholder}
                    value={pair.key}
                    error={errors[pair.id]}
                    disabled={disabled}
                    autoComplete="off"
                    spellCheck={false}
                    onChange={(event) => updateRow(pair.id, { key: event.currentTarget.value })}
                    onPaste={(event) => handleKeyPaste(pair.id, event)}
                  />
                </Grid.Col>
                <Grid.Col span={{ base: 12, sm: 7 }}>
                  {pair.secret ? (
                    <PasswordInput {...valueProps} autoComplete="new-password" />
                  ) : (
                    <TextInput {...valueProps} autoComplete="off" spellCheck={false} />
                  )}
                </Grid.Col>
              </Grid>
            </Box>
            <Group gap={4} wrap="nowrap">
              {allowSecrets && (
                <Tooltip label={secretLabel}>
                  <ActionIcon
                    size="input-sm"
                    variant={pair.secret ? 'light' : 'subtle'}
                    color="gray"
                    aria-label={secretLabel}
                    aria-pressed={Boolean(pair.secret)}
                    disabled={disabled}
                    onClick={() => updateRow(pair.id, { secret: !pair.secret })}
                  >
                    {pair.secret ? <IconLock size={16} /> : <IconLockOpen size={16} />}
                  </ActionIcon>
                </Tooltip>
              )}
              <Tooltip label={labels.remove}>
                <ActionIcon
                  size="input-sm"
                  variant="subtle"
                  color="gray"
                  aria-label={labels.remove}
                  disabled={disabled}
                  onClick={() => onChange(value.filter((item) => item.id !== pair.id))}
                >
                  <IconTrash size={16} />
                </ActionIcon>
              </Tooltip>
            </Group>
          </Group>
        );
      })}
      <Group>
        <Button
          variant="light"
          size="xs"
          leftSection={<IconPlus size={14} />}
          disabled={!canAdd}
          onClick={addRow}
        >
          {addLabel}
        </Button>
      </Group>
    </Stack>
  );
});

KeyValueEditor.displayName = 'KeyValueEditor';
API reference
Prop
Type
Required
value
KeyValuePair[]
Yes
Rows in display order
onChange
(pairs: KeyValuePair[]) => void
Yes
Called with the full list of rows after every edit
keyPlaceholder
string
No
Key input placeholder and accessible label prefix, `Key` by default
valuePlaceholder
string
No
Value input placeholder and accessible label prefix, `Value` by default
addLabel
string
No
Add button label, `Add` by default
allowSecrets
boolean
No
Shows a button on every row that masks or unmasks its value
validateKey
KeyValidator
No
Returns an error message for an invalid key; empty and duplicate keys are always reported, `envKeyValidator` by default
disabled
boolean
No
Disables every input and button
maxRows
number
No
Maximum number of rows, the add button is disabled once reached
labels
Partial<KeyValueEditorLabels>
No
Accessible labels and built-in error messages
className
string
No
Class name added to the root element
style
React.CSSProperties
No
Inline styles added to the root element