# Key Value Editor

A list of key and value pairs that the user can edit, with validation, masked values and import.

```tsx
import { Card, CardContent, CardDescription, CardHeader, CardTitle, KeyValueEditor, rowsFromPairs } from 'ferry-ui'

// `rowsFromPairs` gives each pair a stable row id.
const LABELS = rowsFromPairs([
  { key: 'team', value: 'growth' },
  { key: 'cost-center', value: 'CC-4102' },
  { key: 'owner', value: 'maya@example.com' },
])

export default function KeyValueEditorHero() {
  return (
    <Card className="mx-auto w-full max-w-xl">
      <CardHeader>
        <div>
          <CardTitle>Labels</CardTitle>
          <CardDescription>The labels of the project “Billing portal”.</CardDescription>
        </div>
      </CardHeader>
      <CardContent>
        <KeyValueEditor
          defaultValue={LABELS}
          addLabel="Add label"
          itemNoun={{ one: 'label', other: 'labels' }}
          listLabel="Project labels"
        />
      </CardContent>
    </Card>
  )
}
```

## Usage guidelines

- **For a small map with free keys.** Use it for request headers, metadata, labels or variables. Keep the list to a few dozen rows.
- **Known fields are a form.** For a fixed set of fields, use [Field](/docs/components/field) or [Form Card](/docs/components/form-card).
- **Many records are a table.** For a large list, use [Table](/docs/components/table).
- **Mount the tooltip provider.** The icon buttons have tooltips. They need a `TooltipProvider` above the editor.

## Anatomy

Import the component and the hook. The hook holds the rows for a controlled editor.

```tsx title="Anatomy"

const state = useKeyValueRows(pairs)

<KeyValueEditor value={state.rows} onValueChange={state.setRows} errors={state.errors} />
```

| Type | Shape | Use |
| --- | --- | --- |
| `KeyValuePair` | `{ key, value, secret? }` | The data that your app stores. |
| `KeyValueRow` | A pair and a stable `id`. | The data that the editor shows. |

## Examples

### Save flow

`useKeyValueRows` gives `pairs` for the request, `errors` and `valid` for the validation, and `dirty` for a [Save Bar](/docs/components/save-bar). The hook reads its argument on the first render only. After a load or a save, call `reset`.

```tsx
import * as React from 'react'
import { Card, CardContent, KeyValueEditor, SaveBar, toast, useKeyValueRows, validateIdentifierKey, type KeyValuePair } from 'ferry-ui'

const INITIAL: KeyValuePair[] = [
  { key: 'team', value: 'growth' },
  { key: 'cost-center', value: 'CC-4102' },
]

// Module level: the options of the hook stay the same between renders.
const OPTIONS = { validateKey: validateIdentifierKey }

export default function KeyValueEditorSaveBar() {
  const [saved, setSaved] = React.useState(INITIAL)
  const [saving, setSaving] = React.useState(false)
  const labels = useKeyValueRows(saved, OPTIONS)

  function save() {
    const pairs = labels.pairs
    setSaving(true)
    // Stands for a request to the server.
    window.setTimeout(() => {
      setSaved(pairs)
      // The hook reads its argument on the first render only: give it the new saved state.
      labels.reset(pairs)
      setSaving(false)
      toast.success('Labels saved')
    }, 1000)
  }

  return (
    <Card className="mx-auto w-full max-w-xl">
      <CardContent>
        {/* The same validator as the hook: pasted lines and imported lines follow the same rule. */}
        <KeyValueEditor
          value={labels.rows}
          onValueChange={labels.setRows}
          errors={labels.errors}
          validateKey={validateIdentifierKey}
          disabled={saving}
          addLabel="Add label"
          emptyMessage="No labels yet."
        />
      </CardContent>
      <SaveBar
        dirty={labels.dirty}
        invalid={!labels.valid}
        saving={saving}
        hint={`Labels: ${labels.pairs.length}`}
        onReset={() => labels.reset(saved)}
        onSave={save}
      />
    </Card>
  )
}
```

### Validation

The editor shows an error for a value with no key and for a duplicate key. `validateKey` adds a check of the key format. `validateIdentifierKey` is a validator for the names of variables.

Give the same validator to the hook and to the editor.

```tsx
import { KeyValueEditor, rowsFromPairs, validateIdentifierKey } from 'ferry-ui'

const VARIABLES = rowsFromPairs([
  { key: 'MAX_RETRIES', value: '5' },
  { key: '', value: 'eu-west' },
  { key: 'MAX_RETRIES', value: '3' },
  { key: '2FA_REQUIRED', value: 'true' },
])

export default function KeyValueEditorValidation() {
  return (
    <KeyValueEditor
      className="mx-auto w-full max-w-xl"
      defaultValue={VARIABLES}
      validateKey={validateIdentifierKey}
      keyLabel="Variable"
      keyPlaceholder="NAME"
      addLabel="Add variable"
      itemNoun={{ one: 'variable', other: 'variables' }}
    />
  )
}
```

### Secret values

A row with `secret: true` shows a mask and a reveal button. `maskValues` masks each row. `revealAll` shows each value and hides the reveal buttons.

```tsx
import * as React from 'react'
import { KeyValueEditor, Label, Switch, rowsFromPairs } from 'ferry-ui'

const HEADERS = rowsFromPairs([
  { key: 'Accept', value: 'application/json' },
  { key: 'Authorization', value: 'Bearer tok_demo_8f2a91c4', secret: true },
  { key: 'X-Signature', value: 'sig_demo_31b9e0a4c2', secret: true },
])

export default function KeyValueEditorSecret() {
  const [revealAll, setRevealAll] = React.useState(false)

  return (
    <div className="mx-auto flex w-full max-w-xl flex-col gap-4">
      <div className="flex items-center gap-2">
        <Switch id="reveal-all-values" size="sm" checked={revealAll} onCheckedChange={setRevealAll} />
        <Label htmlFor="reveal-all-values" className="font-normal">
          Reveal all values
        </Label>
      </div>
      <KeyValueEditor
        defaultValue={HEADERS}
        revealAll={revealAll}
        keyLabel="Header"
        keyPlaceholder="X-Header-Name"
        addLabel="Add header"
        itemNoun={{ one: 'header', other: 'headers' }}
      />
    </div>
  )
}
```

### Texts and empty list

`keyLabel`, `addLabel`, `importLabel` and `itemNoun` set the visible texts. `emptyMessage` shows while the list has no row. To translate the other texts, pass `labels`.

```tsx
import { KeyValueEditor } from 'ferry-ui'

export default function KeyValueEditorTexts() {
  return (
    <KeyValueEditor
      className="mx-auto w-full max-w-xl"
      keyLabel="Tag"
      keyPlaceholder="tag"
      addLabel="Add tag"
      importLabel="Import tags"
      itemNoun={{ one: 'tag', other: 'tags' }}
      listLabel="Invoice tags"
      emptyMessage="No tags yet. Add one, or import a list."
    />
  )
}
```

### Read-only and disabled

`readOnly` removes the add, remove and import buttons. `disabled` locks each field and each button but keeps the layout. Use `disabled` while a save is in progress.

```tsx
import { KeyValueEditor, rowsFromPairs } from 'ferry-ui'

const HEADERS = rowsFromPairs([
  { key: 'Accept', value: 'application/json' },
  { key: 'Authorization', value: 'Bearer tok_demo_8f2a91c4', secret: true },
  { key: 'X-Api-Version', value: '2026-01' },
])

export default function KeyValueEditorReadOnly() {
  return (
    <KeyValueEditor
      className="mx-auto w-full max-w-xl"
      readOnly
      defaultValue={HEADERS}
      keyLabel="Header"
      listLabel="Webhook headers"
    />
  )
}
```

### Paste and import

The user can paste `key=value` lines into a key field, or open the Import dialog. A key that exists gets the new value. A new key goes to the end of the list. To hide the Import button, set `allowImport` to `false`.

```text
team=growth
owner="Maya Chen"
# a comment
```

## Accessibility

- Each field has an accessible name, for example "Key 1" and "Value of team".
- A new row gets the focus on its key field.
- The error of a row has `role="alert"`.

## API reference

`KeyValueEditor` also accepts each attribute of the `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `readonly KeyValueRow[]` |  | Rows to show (controlled). Pair it with `onValueChange`; `useKeyValueRows` gives you both. |
| `defaultValue` | `readonly KeyValueRow[]` |  | Initial rows when uncontrolled. Ignored when `value` is set. |
| `onValueChange` | `((rows: KeyValueRow[]) => void)` |  | Called with the next rows on every edit, add, remove, paste and import. |
| `errors` | `ReadonlyMap<string, string>` |  | Validation messages keyed by row id (e.g. `useKeyValueRows().errors`). When omitted, the editor computes them with `validateRows` (non-empty, unique keys, plus `validateKey`). |
| `validateKey` | `KeyValidator` |  | Extra format check for keys. Typed keys only need to be non-empty and unique by default. It also decides which pasted / imported lines are accepted (default for those: any key without whitespace). Use `validateIdentifierKey` for identifier-style keys. Ignored for display when `errors` is set, so pass the same validator to `useKeyValueRows`. |
| `readOnly` | `boolean` | `false` | Show the rows without editing: no add / remove / import, inputs read-only (reveal toggles stay). |
| `disabled` | `boolean` | `false` | Lock every field and button, e.g. while the rows are being saved. Unlike `readOnly`, the layout (actions row included) stays the same. Same effect as wrapping the editor in `<fieldset disabled>`. |
| `maskValues` | `boolean` | `false` | Treat every row as secret: values show a fixed-length mask with a per-row reveal toggle. Rows with `secret: true` are masked regardless. A masked value shows in clear while its field has focus and when it is empty. |
| `revealAll` | `boolean` | `false` | Show every masked value in clear and hide the per-row reveal toggles (bind it to a "Reveal all" switch). |
| `keyLabel` | `string` | `Key` | Column caption and accessible name prefix of key fields. Default "Key". |
| `valueLabel` | `string` | `Value` | Column caption and accessible name prefix of value fields. Default "Value". |
| `keyPlaceholder` | `string` | `key` | Placeholder of key fields. Default "key". |
| `valuePlaceholder` | `string` | `value` | Placeholder of value fields. Default "value". |
| `addLabel` | `ReactNode` | `Add row` | Text of the add button. Default "Add row". |
| `allowImport` | `boolean` | `true` | Show the bulk import button and dialog (paste or load `key=value` lines). Default `true`. |
| `importLabel` | `ReactNode` | `Import` | Text of the import button. Default "Import". |
| `importAccept` | `string` | `.txt,.env,text/plain` | File types offered by the "Choose file…" picker of the import dialog: the native `accept` value (comma-separated extensions and MIME types). Default `.txt,.env,text/plain`. The file is read as plain text whatever its type. |
| `itemNoun` | `KeyValueItemNoun` | `DEFAULT_NOUN` | Noun used in generated copy (import dialog, fallback row names). Default entry / entries. |
| `listLabel` | `string` | `Key/value pairs` | Accessible name of the row list. Default "Key/value pairs". |
| `emptyMessage` | `ReactNode` |  | Shown instead of the list when there are no rows (e.g. "No headers yet."). |
| `labels` | `Partial<KeyValueEditorLabels>` |  | Overrides for the other built-in texts (accessible names, tooltips, paste tip, import dialog), e.g. to translate them. Unset entries keep their English default. |

### useKeyValueRows

`useKeyValueRows(initial, options?)` returns an object. Keep `options` stable between renders.

| Name | Type | Role |
| --- | --- | --- |
| `initial` | `KeyValuePair[]` | Argument. The saved pairs. |
| `options` | `ValidateRowsOptions` | Argument. It holds `validateKey` and `messages`. |
| `rows` | `KeyValueRow[]` | The rows. Pass them to `value`. |
| `setRows` | function | The setter. Pass it to `onValueChange`. |
| `reset` | `(pairs) => void` | Replaces the saved pairs and the rows. |
| `pairs` | `KeyValuePair[]` | The rows as pairs, with no blank row. |
| `errors` | `Map` | The messages. The key of the map is the row id. |
| `valid` | `boolean` | `true` when there is no error. |
| `dirty` | `boolean` | `true` when `pairs` is different from the saved pairs. |

### Helpers

| Name | Role |
| --- | --- |
| `rowsFromPairs(pairs)` | Makes rows with new ids from pairs. |
| `pairsFromRows(rows)` | Makes pairs from rows. It removes blank rows and trims the keys. |
| `validateRows(rows, options?)` | Returns the messages in a map with the row id as key. |
| `mergeRows(rows, incoming)` | Updates the rows by key and adds the new keys at the end. |
| `newRowId()` | Returns a new row id. |
| `parseKeyValueText(text, options?)` | Reads `key=value` lines. It returns `pairs` and the `invalid` lines. |
| `formatKeyValueText(pairs)` | Writes pairs as `key=value` lines. |
| `validateIdentifierKey` | A validator for keys with letters, digits, `_`, `.` and `-`. A digit cannot come first. |
| `isIdentifierKey(key)` | Returns `true` for a key that the validator accepts. |
| `VALUE_MASK` | The text that replaces a masked value. |
