Key-value editor
Editable rows of keys and values, with secrets masked, for headers and metadata.
Caller lookup webhook
When a supplier calls, the agent looks up their vendor record before it answers.
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
allowSecretfor 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
Secrets stay masked
Anatomy#
Use letters, numbers and dashes.
- Column headers. 12px Slate Meta labels from
keyLabelandvalueLabel, shown from 640px up. - Key. A 32px Input in Geist Mono, two fifths of the row.
- Value. An Input group, three fifths of the row. A secret value is masked and gets an eye button to reveal it.
- Row actions. 32px ghost icon buttons with tooltips: the lock that marks a value secret (with
allowSecret) and the X that removes the row. - Key error. 13px Signal Red text under the row, linked to the key input.
- Add button. A small outline button with a plus, and a 3/6 count when
maxis 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.
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#
| State | Treatment |
|---|---|
| Empty | emptyText in 13px Slate Meta above the add button. |
| Rest | Rows of key and value inputs at rest. |
| Focus visible | Each input takes its own indigo border and 3px ring. |
| Invalid key | A duplicate key, or one validateKey rejects, gets a red border and ring and a message under the row. |
| Secret, masked | The value shows as dots in Geist Mono; the lock button is pressed, with a Menu Hover fill. |
| Secret, revealed | The eye button shows the value for this viewer only. |
| Full | At max, the add button is disabled and the count reads 6/6. |
| Disabled | Every 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
secreton the row throughonChangeand 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.
validateKeyruns after the duplicate check; return a message ornull. - 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#
Authorization header in plain text for anyone looking over a shoulder.Use lowercase snake_case.
validateKey and say the rule: Use lowercase snake_case.Vendor ID.Content#
- Name the columns for what they hold: Header and Value, Variable and Value, Label and Value.
- Placeholders are real examples:
Authorizationand Bearer …,vendor_idand{{portal.vendor_id}}. - The add button names the thing: Add header, Add variable, Add field.
emptyTextsays 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-describedbyand mark itaria-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.
| Keys | Action |
|---|---|
| Tab | Moves through each row: key, value, reveal, lock, remove, then the add button. |
| Enter | Activates the focused button. It doesn't add a row from an input. |
| Space | Toggles the lock or reveal button. |
Design tokens#
| Token | Used for |
|---|---|
--input | Key and value strokes |
--ring | Focus rings |
--destructive | Invalid key and its message |
--accent | Pressed lock button fill |
--muted-foreground | Column headers, empty text, row action icons |
--radius-lg | 10px input corners |
API reference#
KeyValueEditor
Editable rows of keys and values. Also exported: the KeyValuePair type, { key: string; value: string; secret?: boolean }.
| Prop | Type | Default | Description |
|---|---|---|---|
valueRequired | KeyValuePair[] | No default | The rows. |
onChangeRequired | (value: KeyValuePair[]) => void | No default | Called with the next rows on every edit, add, remove and mask. |
keyLabel | string | "Key" | Key column header and input name. |
valueLabel | string | "Value" | Value column header and input name. |
keyPlaceholder | string | "key" | Key placeholder. |
valuePlaceholder | string | "Value" | Value placeholder. |
addLabel | string | "Add row" | The add button's label. |
emptyText | ReactNode | "Nothing added yet." | Shown when there are no rows. |
allowSecret | boolean | false | Adds the lock button that masks a value. |
validateKey | (key: string) => string | null | No default | Return a message to flag a key, or null when it's fine. |
max | number | No default | The most rows allowed. Shows a count. |
disabled | boolean | No default | Disables every input and button. |
className | string | No default | On 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.