Key Value Editor
An editable list of flat key/value pairs — drag or Alt+Arrow to reorder, switch a row off instead of deleting it, paste a block of Key: value lines, and see both halves of every duplicate key.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/key-value-editor.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "KeyValueEditor" component using
@dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities, lucide-react and the shadcn
Input primitive. It is the flat-pair field: request headers, query parameters,
resource labels, build variables — a list of {key, value} rows where a row can be
switched off, reordered, pasted in bulk, or edited as raw text.
Contract
- export interface KeyValuePair { id: string; key: string; value: string;
enabled: boolean } and export type KeyValueDraft = Omit<KeyValuePair, "id">.
- forwardRef<HTMLDivElement>. Props extend
Omit<React.HTMLAttributes<HTMLDivElement>, "onChange"> with:
value: KeyValuePair[]; onChange: (next: KeyValuePair[]) => void;
label = "Key/value pairs"; keyLabel = "Key"; valueLabel = "Value";
keyPlaceholder = "Key"; valuePlaceholder = "Value"; addLabel = "Add row";
emptyLabel = "No pairs yet."; max?: number; reorderable = true;
togglable = true; allowRawText = true; caseSensitiveKeys = false;
disabled = false; onDuplicateKeysChange?: (keys: string[]) => void.
- Fully controlled: add / remove / move / toggle / paste / text edit all produce a
brand-new array through onChange (spread, filter, map, arrayMove). The input
array is never mutated. The component's own state is only: mode, the text draft,
the overflow count, one announcement string and one focus intent.
- `id` is identity ONLY — React key and dnd-kit drag id — and never reaches the
DOM. Field names come from aria-label and every element id comes from useId, so
a consumer may mint ids with crypto.randomUUID() in a state initialiser without
risking a hydration mismatch.
- Also export three pure helpers, because the caller needs the same three
operations at submit time: parseKeyValueText(text) -> KeyValueDraft[],
serializeKeyValueText(pairs) -> string, keyValuePairsToObject(pairs) ->
Record<string, string> (enabled rows with a non-blank trimmed key; last wins).
- Clamp max: Number.isFinite(max) ? Math.max(0, Math.floor(max)) : undefined.
Behavior
- Duplicate keys, both offenders. Normalise every ENABLED row's key with trim()
plus toLowerCase() unless caseSensitiveKeys, skip blanks, and group the row
indices by that normalised key in a Map. Every group of size >= 2 adds ALL of
its indices to a flagged Set and contributes one display key (the first
offender's trimmed key). Marking only the later row makes the reader hunt for
the other half of the collision. Disabled rows are excluded on purpose:
switching one of a colliding pair off is a legitimate fix, and a row that is
not sent cannot collide with anything.
- Duplicate reporting: a flagged key field gets aria-invalid plus
aria-describedby pointing at one summary line ("Duplicate keys: X. Only the
last copy is sent — rename one, or switch it off."). Do NOT push duplicates
into the live region: the set changes on every keystroke and would chatter.
onDuplicateKeysChange fires from an effect that compares a serialised
signature of the key list, so it reports the SET changing, not typing inside
one; the callback is held in a ref so an inline arrow never re-runs the effect.
- Enable toggle: a role="switch" button with aria-checked, named "Include <key>"
(falling back to "row N" while the key is blank). A switched-off row keeps its
text editable and only loses bg-card, its text colour and its place in the
payload. This is the alternative to deleting a header you may want back.
- Bulk paste. Only the KEY field intercepts paste — a value may legitimately be a
URL full of colons, and rewriting that into rows would be sabotage. Parse the
clipboard text; bail out (letting the browser paste normally) when it yields
nothing, or exactly one pair with an empty value and no newline in the source —
that is somebody pasting a single header name. Otherwise preventDefault and
splice: a blank target row is consumed by the paste, a filled one keeps its
place and the block lands after it. Room for max is
`max - value.length + (replaceTarget ? 1 : 0)`, clamped at 0, and the block is
sliced to it; focus lands on the value field of the last accepted row at index
`(replaceTarget ? index : index + 1) + accepted.length - 1`. Announce the
shortfall ("Pasted 3 of 7 rows — the limit of 10 was reached."), never drop
rows silently.
- Text parser (shared by paste and text mode): split on /\r\n|\r|\n/, trim each
line, skip blanks; a leading "#" marks the row disabled instead of dropping it,
which is what makes the projection round-trip; split at the FIRST ":" or "="
so a value keeps every later separator; trim both halves and unwrap one pair of
matching quotes around the value (.env style); a line with no separator becomes
a key with an empty value rather than vanishing.
- Text mode is a projection, not a second source of truth. Entering it serialises
`value` ONCE into a local draft; every keystroke then edits the draft, and the
draft is re-parsed into rows on each change. Rendering the draft (not a
re-serialisation of the parse) is what stops the caret from jumping while a
half-typed line has no separator yet. Reuse the existing row id at the same
index and mint ids only for extra lines: position is the only identity a
rewritten buffer has. The buffer respects max too — parsed rows past the limit
are not committed and a destructive line names how many lines are ignored.
- Serialisation is line-based, so a value containing a literal newline cannot
survive it: collapse newlines to spaces and say so in the live region when
entering text mode ("Line breaks in 2 values were collapsed to spaces.").
Blank rows also disappear through a round trip; the hint says so.
- Keyboard map (inside a key or value field):
Enter in a key field -> that row's value field.
Enter in a value field -> the next row's key field; on the last row it adds a
row, or announces "Row limit of N reached." when capped.
Backspace in the key field of a row where BOTH halves are empty -> delete the
row, focus the previous row's value field (or the add button when the list
empties).
Alt+ArrowUp / Alt+ArrowDown -> move the row without leaving the field.
Everything else stays native — plain arrows must keep moving the caret.
Bail out of the whole handler when event.nativeEvent.isComposing: an IME uses
Enter to commit a candidate, and hijacking it there eats the word.
Tab order per row: grip -> switch -> key -> value -> remove.
The grip is dnd-kit's KeyboardSensor: Space picks up, arrows move, Space drops,
Escape cancels.
- Focus follows every structural edit, driven by a focus-intent state object (a
fresh object per edit so the effect fires even when the target repeats) plus an
effect that resolves rows by DOM position under a ref on the list. Add focuses
the new key field; remove focuses the remove button that slid into the freed
slot; a move re-focuses the field the reader was in, because React reparents
the row's DOM node and a reparented element loses focus in several browsers. A
missing target falls back to the add button — never to <body>.
- Drag: DndContext (PointerSensor with activationConstraint distance 4, so a
click that lands on an input inside a row cannot misfire a drag, plus
KeyboardSensor with sortableKeyboardCoordinates) + SortableContext with
verticalListSortingStrategy. Pin DndContext's id with useId or its internal
aria ids drift between server and client. Resolve both indices by id on drag
end and arrayMove. Override accessibility.announcements to read positions off
the drag event's sortable data instead of speaking raw internal ids, and do NOT
announce again on drag end or every drop is spoken twice.
- `disabled` never uses the native disabled attribute. Fields become readOnly
(still focusable, selectable, copyable) and every button carries aria-disabled
with a handler guard. The same rule covers the cap: the add button at max is
aria-disabled, not disabled — the browser blurs a control the instant it
becomes disabled, and the row that hits the cap is usually the one under the
caret.
- Ids for new rows come from a counter ref read AND written synchronously inside
the handler that mints them (`seq += 1` then use it), so pasting twenty lines
in one event hands out twenty distinct ids; a state counter would reuse one.
- Degenerate cases: an empty array renders a dashed panel with emptyLabel and one
action, not an empty box; max = 0 caps everything immediately and the hint says
so; two blank rows are not duplicates (a blank key is incomplete, not
colliding); a key that is only whitespace is treated as blank everywhere;
removing the last row is allowed and lands focus on the add button.
- Cleanup: one live-region timer clears the announcement after ~4s (cleared on
the next announcement and on unmount) so an identical result can be spoken
again later; the prefers-reduced-motion listener is the teardown returned by
useSyncExternalStore's subscribe. No rAF, no polling, no other listeners.
Rendering & styling
- Semantic tokens only: rows are `rounded-lg border bg-card p-2`, a switched-off
row is `bg-muted/40` with `text-muted-foreground` fields, icon controls are
`text-muted-foreground` with `hover:bg-accent hover:text-accent-foreground` and
`focus-visible:ring-2 focus-visible:ring-ring`, remove hints danger with
`hover:bg-destructive/10 hover:text-destructive`, the checked switch box is
`border-primary bg-primary text-primary-foreground`, hints and the counter are
`text-xs text-muted-foreground`, duplicate and overflow lines are
`text-destructive`. No hex, no oklch, no arbitrary colours.
- cn() merges the consumer's className onto the root and the rest of the div
props spread onto it.
- Layout: each row is `flex items-start gap-1.5`; the two fields live in a
`min-w-0 flex-1 flex-col gap-2 sm:flex-row` wrapper so they stack on narrow
screens while the grip, switch and remove stay in one column. The key cell is
`min-w-0 flex-1`, the value cell `min-w-0 flex-[1.5]`, and an aria-hidden
`hidden sm:flex` header reuses those exact classes plus size-8 spacers so the
captions sit over their columns. The list is a `ul` with an explicit
role="list" (display:flex drops list semantics in WebKit) and rows are `li`.
- Accessibility: the root is role="group" with aria-label; each field is named by
aria-label (`Key 3`, `Value for Content-Type`) so no visible label is needed;
the switch is role="switch" + aria-checked; the raw-text toggle is a button
with aria-pressed; one permanently mounted `aria-live="polite" role="status"`
sr-only node carries add / remove / move / toggle / paste results; decorative
icons are aria-hidden; every button is type="button" with a focus-visible ring.
- prefers-reduced-motion: dnd-kit writes its settle transition as an inline
style, which a CSS-only motion-reduce variant cannot override — read the media
query with useSyncExternalStore (server snapshot false) and drop the transition
in JS. Rows snap instead of sliding; dragging still works. Every other
transition is colour-only and carries motion-reduce:transition-none.
Customization levers
- Chrome per row: `reorderable={false}` drops the grip and the Alt+Arrow
shortcuts (right for labels and headers, where order means nothing);
`togglable={false}` drops the switch (then seed every row enabled — nothing can
turn one back on); `allowRawText={false}` drops the text projection.
- Copy: keyLabel / valueLabel (column captions and accessible names),
keyPlaceholder / valuePlaceholder, addLabel and emptyLabel are the only
user-visible strings you pass in — the hint, counter and duplicate sentences
are in-file constants, and translating them is one search.
- Matching rules: `caseSensitiveKeys` switches duplicate detection between HTTP
semantics (case-insensitive) and label semantics (exact). To also forbid
duplicates against disabled rows, drop the `if (!pair.enabled) return` guard in
the grouping pass — the rest of the flow is unchanged.
- Separators: SEPARATORS is [":", "="]; make it [":"] for a strict header editor,
or add "\t" to accept spreadsheet paste.
- Limits: `max` caps Add, Enter-to-add, paste and the text buffer through the
same clamp. Add a `min` by mirroring `max`: gate the remove button with
aria-disabled plus a described-by reason instead of hiding it.
- Density: rows are `gap-1.5 p-2` with h-8 controls; drop to `p-1.5` for a
compact console, raise to `p-3 gap-3` for a settings page.
- Payload shape: keyValuePairsToObject is last-wins; swap it for an array of
tuples if your API allows repeated headers, and then relax duplicate detection
from an error to a note.Concepts
- Both offenders, never just the second — duplicates are grouped by a normalised key and every index in a group of two or more is flagged, so the reader sees the collision, not one arbitrary end of it. Normalisation is
trim()plustoLowerCase()unlesscaseSensitiveKeys, which is the difference between HTTP header semantics and Kubernetes label semantics. - Switched off, not deleted — a row you may want back keeps its text and its place and only leaves the payload. That also makes disabling one half of a clash a legitimate fix, which is why duplicate detection looks at enabled rows only.
- Paste is an input method, not an accident — a block of
Key: valuelines pasted into a key field fans out into rows, with#preserved as "off"; a single bare token is handed back to the browser, and a block that would exceedmaxis truncated with the shortfall spoken, never trimmed in silence. - Two projections of one list — text mode serialises once on entry and then edits a draft, parsing it back on every keystroke. Rendering the draft rather than a re-serialisation is what keeps the caret still while a line has no separator yet; ids are reused positionally because a rewritten buffer has no other identity.
- Refusal instead of removal — the cap, and
disabled, never use the nativedisabledattribute: buttons carryaria-disabledwith a handler guard and fields becomereadOnly, so focus is never dropped on<body>and the reason is announced instead of silently missing. - Focus follows the structure — add, remove, move and paste each set a focus intent that an effect resolves by DOM position: into the new row, onto the remove button that took the freed slot, back into the field a moved row was being typed in, and onto the add button when nothing is left.
Password Generator
A crypto-backed password generator with a length slider, required character sets, look-alike exclusion, a live entropy readout and an explicit refusal for impossible rule sets.
Sort Builder
A multi-key sort composer — ordered field plus direction rules, drag or keyboard reprioritising, direction wording that follows the field type, and a live plain-language summary of the whole chain.