Inputs

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Check, FileText, GripVertical, Plus, Rows3, Trash2, TriangleAlert } from "lucide-react"
import {
  type Announcements,
  closestCenter,
  DndContext,
  type DragEndEvent,
  KeyboardSensor,
  PointerSensor,
  useSensor,
  useSensors,
} from "@dnd-kit/core"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/key-value-editor.json

Prompt

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() plus toLowerCase() unless caseSensitiveKeys, 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: value lines 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 exceed max is 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 native disabled attribute: buttons carry aria-disabled with a handler guard and fields become readOnly, 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.

On This Page