Skip to content

Key-value editor

Editable rows of keys and values, with secrets masked, for headers and metadata.

Status
Stable
Category
Editors
Adoption
Not used yet
import { KeyValueEditor } from "@oration/canon/components/key-value-editor";
packages/canon/src/components/key-value-editor.tsx

Caller lookup webhook

When a supplier calls, the agent looks up their vendor record before it answers.

GET
Headers
import { Button } from "@oration/canon/components/button";import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@oration/canon/components/input-group";import { KeyValueEditor, type KeyValuePair } from "@oration/canon/components/key-value-editor";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Hero() {    const id = React.useId();    const [headers, setHeaders] = React.useState<KeyValuePair[]>([        {            key: "Authorization",            value: "Bearer cdl_live_7Hq2VnX9pR4sK1",            secret: true,        },        { key: "X-Cedarline-Tenant", value: "cedarline" },    ]);    return (        <div className="flex w-full max-w-2xl flex-col overflow-hidden rounded-xl bg-card text-left shadow-border">            <div className="flex flex-col gap-4 p-4">                <div className="flex flex-col gap-1">                    <p className="text-sm font-semibold text-foreground">                        Caller lookup webhook                    </p>                    <p className="text-13 text-muted-foreground">                        When a supplier calls, the agent looks up their vendor                        record before it answers.                    </p>                </div>                <div className="flex flex-col gap-2">                    <label                        htmlFor={`${id}-url`}                        className="text-sm font-medium text-foreground"                    >                        Endpoint                    </label>                    <InputGroup>                        <InputGroupAddon>                            <InputGroupText className="font-mono text-xs">                                GET                            </InputGroupText>                        </InputGroupAddon>                        <InputGroupInput                            id={`${id}-url`}                            defaultValue="https://erp.cedarline.io/api/vendors/lookup"                            className="font-mono md:text-[13px]"                            spellCheck={false}                        />                    </InputGroup>                </div>                <div className="flex flex-col gap-2">                    <span className="text-sm font-medium text-foreground">                        Headers                    </span>                    <KeyValueEditor                        value={headers}                        onChange={setHeaders}                        keyLabel="Header"                        keyPlaceholder="Authorization"                        valuePlaceholder="Bearer …"                        addLabel="Add header"                        allowSecret                    />                </div>            </div>            <div className="flex items-center justify-end gap-2 border-t border-border bg-muted/50 px-4 py-3">                <Button                    type="button"                    variant="outline"                    onClick={() =>                        toast.add({                            type: "success",                            title: "Test lookup passed",                            description:                                "Northwind Freight, V-004417, in 184 ms.",                        })                    }                >                    Send test                </Button>                <Button                    type="button"                    onClick={() => {                        const saved = headers.filter((row) => row.key.trim());                        toast.add({                            type: "success",                            title: "Webhook saved",                            description: `${saved.length} ${saved.length === 1 ? "header" : "headers"}, ${saved.filter((row) => row.secret).length} masked.`,                        });                    }}                >                    Save webhook                </Button>            </div>        </div>    );}

Usage#

Key-value editor edits a list of named values in rows: headers on a lookup webhook, variables passed to the widget, fields on an assist card. Keys are set in Geist Mono, a value can be marked secret so it is masked until revealed, and a key used twice is flagged in place. It is fully controlled, and the array is yours: keep secret alongside each key and value when you save, and drop rows with an empty key before sending them anywhere.

When to use

  • For HTTP headers on a webhook or an API request, with allowSecret for tokens.
  • For variables and metadata passed to an agent, a widget or a flow step.
  • For a short list of label and template pairs, such as fields shown on an assist card.
  • When rows are added and removed often and their order is kept.

When not to use

  • For a list of single values, such as allowed domains. Use Tag input
  • For one read-only secret or ID that people copy. Use Copy row
  • For typed, nested structures with required fields and descriptions. Use JSON schema builder
  • For a fixed set of settings with known names. Give each its own labelled field. Use Field

The Machine Mono Rule

Keys are machine strings, so they are always Geist Mono. Values are mono only when masked as secrets; plain values such as a tenant name stay in Geist Sans.

Secrets stay masked

Tokens and keys are masked by default and revealed one row at a time. Revealing is local to the viewer and never changes what is saved.

Anatomy#

  • Use letters, numbers and dashes.

  1. Column headers. 12px Slate Meta labels from keyLabel and valueLabel, shown from 640px up.
  2. Key. A 32px Input in Geist Mono, two fifths of the row.
  3. Value. An Input group, three fifths of the row. A secret value is masked and gets an eye button to reveal it.
  4. Row actions. 32px ghost icon buttons with tooltips: the lock that marks a value secret (with allowSecret) and the X that removes the row.
  5. Key error. 13px Signal Red text under the row, linked to the key input.
  6. Add button. A small outline button with a plus, and a 3/6 count when max is set.

Examples#

Secrets

With allowSecret, the lock on each row masks its value in Geist Mono. The eye reveals one value for the viewer; it never changes what is saved.

import { KeyValueEditor, type KeyValuePair } from "@oration/canon/components/key-value-editor";import * as React from "react";export function Secrets() {    const [rows, setRows] = React.useState<KeyValuePair[]>([        { key: "NETSUITE_ACCOUNT", value: "4417221" },        { key: "NETSUITE_TOKEN", value: "ns_tok_91c4e2b7f0a3", secret: true },        {            key: "NETSUITE_TOKEN_SECRET",            value: "ns_sec_5d8a1f6e2c9b",            secret: true,        },    ]);    return (        <div className="w-full max-w-xl">            <KeyValueEditor                value={rows}                onChange={setRows}                keyLabel="Variable"                keyPlaceholder="NETSUITE_ACCOUNT"                addLabel="Add variable"                allowSecret            />        </div>    );}

Key validation

Repeated keys are flagged on every row that uses them. validateKey adds your own rule; return the message to show, or null.

  • This key is used more than once.

  • Use lowercase snake_case, like invoice_number.

  • This key is used more than once.

import { KeyValueEditor, type KeyValuePair } from "@oration/canon/components/key-value-editor";import * as React from "react";export function Validation() {    const [rows, setRows] = React.useState<KeyValuePair[]>([        { key: "vendor_id", value: "{{portal.vendor_id}}" },        { key: "Invoice Number", value: "{{portal.invoice}}" },        { key: "vendor_id", value: "{{crm.vendor_id}}" },    ]);    return (        <div className="w-full max-w-xl">            <KeyValueEditor                value={rows}                onChange={setRows}                keyLabel="Variable"                keyPlaceholder="vendor_id"                valuePlaceholder="{{portal.vendor_id}}"                addLabel="Add variable"                validateKey={(key) =>                    /^[a-z][a-z0-9_]*$/.test(key)                        ? null                        : "Use lowercase snake_case, like invoice_number."                }            />        </div>    );}

Empty and limited

emptyText says what happens with no rows. max adds a count and disables the add button at the limit.

No fields. The assist card shows the supplier's name and vendor ID.

0/6
import { KeyValueEditor, type KeyValuePair } from "@oration/canon/components/key-value-editor";import * as React from "react";export function EmptyAndMax() {    const [fields, setFields] = React.useState<KeyValuePair[]>([]);    return (        <div className="w-full max-w-xl">            <KeyValueEditor                value={fields}                onChange={setFields}                keyLabel="Label"                keyPlaceholder="Open balance"                valuePlaceholder="{{supplier.open_balance}}"                addLabel="Add field"                emptyText="No fields. The assist card shows the supplier's name and vendor ID."                max={6}            />        </div>    );}

Disabled

For people who can see but not change the configuration. Say why and who can change it.

Only workspace admins can change webhook headers. Ask Maya Okafor for access.

import { KeyValueEditor, type KeyValuePair } from "@oration/canon/components/key-value-editor";import * as React from "react";export function ReadOnly() {    const [rows, setRows] = React.useState<KeyValuePair[]>([        {            key: "Authorization",            value: "Bearer cdl_live_7Hq2VnX9pR4sK1",            secret: true,        },        { key: "X-Cedarline-Tenant", value: "cedarline" },    ]);    return (        <div className="flex w-full max-w-xl flex-col gap-2">            <KeyValueEditor                value={rows}                onChange={setRows}                keyLabel="Header"                addLabel="Add header"                allowSecret                disabled            />            <p className="text-13 text-muted-foreground">                Only workspace admins can change webhook headers. Ask Maya                Okafor for access.            </p>        </div>    );}

States#

States
StateTreatment
EmptyemptyText in 13px Slate Meta above the add button.
RestRows of key and value inputs at rest.
Focus visibleEach input takes its own indigo border and 3px ring.
Invalid keyA duplicate key, or one validateKey rejects, gets a red border and ring and a message under the row.
Secret, maskedThe value shows as dots in Geist Mono; the lock button is pressed, with a Menu Hover fill.
Secret, revealedThe eye button shows the value for this viewer only.
FullAt max, the add button is disabled and the count reads 6/6.
DisabledEvery input and button is disabled.

Behavior#

  • Add row appends an empty pair and moves focus to its key.
  • Removing a row moves focus to the key that takes its place, or the one above when it was last.
  • The lock button toggles secret on the row through onChange and masks the value again.
  • The eye button reveals or hides one masked value. Reveal state is local and resets when the row is re-masked.
  • Keys are compared trimmed; every row with a repeated key shows This key is used more than once. validateKey runs after the duplicate check; return a message or null.
  • Below 640px the column headers hide and each row wraps: the key on its own line, then the value and actions.
  • Nothing is filtered for you: empty keys and values are kept in the array until you drop them.

Do and don't#

Do. Mark tokens and signing secrets as secret so they are masked on screen and in screenshots.
Don't. Leave an Authorization header in plain text for anyone looking over a shoulder.
  • Use lowercase snake_case.

Do. Validate key format with validateKey and say the rule: Use lowercase snake_case.
Don't. Accept any key and let the webhook or template fail later on Vendor ID.

Content#

  • Name the columns for what they hold: Header and Value, Variable and Value, Label and Value.
  • Placeholders are real examples: Authorization and Bearer …, vendor_id and {{portal.vendor_id}}.
  • The add button names the thing: Add header, Add variable, Add field.
  • emptyText says what happens with no rows: No headers. Authentication headers are added for you.

Accessibility#

  • Every input is named on its own: Header, row 2 for a key and Value for Authorization for its value, so the hidden column headers aren't needed.
  • Key errors are linked to their input with aria-describedby and mark it aria-invalid.
  • The lock is a toggle button with aria-pressed, named Mask as secret or Stop masking value; the remove button is named for its row.
  • Adding and removing rows moves focus to a key input, so keyboard users never land on the page body mid-edit, except after removing the last row.
  • Action buttons are 32px, above the 24px minimum.
Keyboard interactions
KeysAction
TabMoves through each row: key, value, reveal, lock, remove, then the add button.
EnterActivates the focused button. It doesn't add a row from an input.
SpaceToggles the lock or reveal button.

Design tokens#

Design tokens
TokenUsed for
--inputKey and value strokes
--ringFocus rings
--destructiveInvalid key and its message
--accentPressed lock button fill
--muted-foregroundColumn headers, empty text, row action icons
--radius-lg10px input corners

API reference#

KeyValueEditor

Editable rows of keys and values. Also exported: the KeyValuePair type, { key: string; value: string; secret?: boolean }.

Props of KeyValueEditor
PropTypeDefaultDescription
valueRequiredKeyValuePair[]No defaultThe rows.
onChangeRequired(value: KeyValuePair[]) => voidNo defaultCalled with the next rows on every edit, add, remove and mask.
keyLabelstring"Key"Key column header and input name.
valueLabelstring"Value"Value column header and input name.
keyPlaceholderstring"key"Key placeholder.
valuePlaceholderstring"Value"Value placeholder.
addLabelstring"Add row"The add button's label.
emptyTextReactNode"Nothing added yet."Shown when there are no rows.
allowSecretbooleanfalseAdds the lock button that masks a value.
validateKey(key: string) => string | nullNo defaultReturn a message to flag a key, or null when it's fine.
maxnumberNo defaultThe most rows allowed. Shows a count.
disabledbooleanNo defaultDisables every input and button.
classNamestringNo defaultOn the root.

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

The eye button in a secret row has no tooltip, while the lock and remove buttons beside it do.

Masked values are type="password" inputs, so browsers and password managers may offer to fill or save them despite autoComplete="off".

Removing the only row drops focus to the page.

Key errors aren't announced when they appear, only linked to the input; values are never validated.