Composites
Key Value
An editable list of key–value pairs: custom fields on a CRM record, the headers and metadata a webhook sends from an admin console. Rows are added and removed in place, keys are checked as they are typed, and pasting a block of KEY=value lines fills in many rows at once.
- Import
KeyValue,KeyValueList,KeyValueItem,KeyValueKeyInput,KeyValueValueInput,KeyValueRemove,KeyValueAdd,KeyValueErrorand the hookuseKeyValueStorefrom the main entry,@comitor/ui.- Install
- Nothing extra — no optional peer. There is no headless core here: the rows are the package's own Input, Textarea and Button.
- Runtime
- A client component (
"use client"), anduseKeyValueStoreis a client hook that throws when called outsideKeyValue. The validators andlabels.removeare functions, so keep the list inside a client component of your own. - Origin
- Added in
2.1.0, ported from Dice UI (MIT, © 2024 Sadman Sakib) onto the package's own primitives and tokens. What changed on the way is below and in the changelog.
Anatomy
KeyValueList renders its children once per pair, so you write the row once, as a template. Every part reads its pair from context — which is how a KeyValueKeyInput knows which key it edits without a prop. The value is an array of KeyValueItemData, { id, key, value }; the id exists for React keys and error ids, but it travels with the data.
import {
KeyValue,
KeyValueAdd,
KeyValueError,
KeyValueItem,
KeyValueKeyInput,
KeyValueList,
KeyValueRemove,
KeyValueValueInput,
} from '@comitor/ui'
<KeyValue value={pairs} onValueChange={setPairs}>
{/* role="list". Its children are a TEMPLATE, rendered once per pair — no .map() */}
<KeyValueList>
{/* role="listitem". Every part inside reads its pair from context. */}
<KeyValueItem>
{/* The package Input */}
<KeyValueKeyInput />
{/* The package Textarea, restyled to match the Input; grows with its content */}
<KeyValueValueInput />
{/* Icon button named "Remove <key>" */}
<KeyValueRemove />
{/* Renders only while that field has an error. The row is a flex row, so the
demos wrap a field and its error in a column of their own. */}
<KeyValueError field="key" />
</KeyValueItem>
</KeyValueList>
{/* "Add" — disabled at maxItems */}
<KeyValueAdd />
</KeyValue>Webhook headers in an admin console
The headers are controlled. Beside them, the object each delivery would carry, derived from value on every change. Header names are checked as you type — try a space inside a name, or content-type in a new row — and Save shows what the form itself posts.
Sent with every delivery
{
"Authorization": "Bearer whsec_3f9a1c77e2",
"Content-Type": "application/json",
"X-Comitor-Workspace": "northwind-freight"
}What the form posted
Press Save webhook.
- Derive, don't transform. Store what
onValueChangegives you as it is, and compute the headers object in render. The component echoes: whenvaluechanges from outside it callsonValueChangewith that array, so a handler that stores a new array — amap, afilter— sets off another echo and never settles. It also means loading saved headers callsonValueChange: do not treat every call as an edit. onKeyValidate(key, items)returns a message orundefined, anditemsincludes the row itself. The built-in duplicate check is exact, but header names ignore case, so this validator also catches content-type beside Content-Type — andlabels.duplicateKeygives both paths the same wording.- Save checks the whole list. Inline errors are computed for the row being edited only, so they can go stale (see Known limitations). The Save button runs the same validator over every row. An empty key is never an error on its own — the built-in check skips it, and so does this validator — so a row with a value and no name is simply left out of the object.
- The default trim is enough. The field keeps the space you type after Bearer, but
valuegets the trimmed text — so the validator, the headers object and the posted JSON never see a stray space, and the object is built from the rows as they are. - The form posts one field:
headers, the whole array as JSON, ids included. The visible inputs have noname. Parse it on the server, or send the derived object yourself.
Custom fields on a CRM record
Uncontrolled, from defaultValue. Try it from the keyboard: Add field puts focus in the new row's first box, and removing a row with the keyboard moves focus to the next row's ✕ rather than dropping it on the page. minItems keeps the last row; maxItems disables Add at six. Type Region into a new row to see the duplicate error.
Custom fields
Northwind Freight · Contact record
3 of 6 fields
- The counter is
useKeyValueStore((state) => state.value.length), rendered insideKeyValue. The selector must return something stable — a number, a string, or an object already in the state. Building a new array or object in it makes every read look like a change, and React re-renders without end. maxRows={3}lets the value grow with its content — see the Renewal row — and scroll beyond three lines.- A duplicate shows under the key it was typed into: the field turns red (
aria-invalid) and points at the message witharia-describedby. - The group is named by the card title (
role="group"witharia-labelledbyon the root), and each field by itsaria-label— see Accessibility.
Pasting environment variables
Paste a block of lines into a key field and each line becomes a row. Copy the sample — a staging file with a comment and a stray line in it — click the empty Key field, and paste.
# Staging — copied from the vault DATABASE_URL="postgres://[email protected]:5432/comitor" REDIS_URL=redis://cache.internal:6379 SENTRY_DSN: https://[email protected]/42 FEATURE_FLAGS kanban,mention MAIL_FROM='Comitor <[email protected]>' remember to rotate these
Nothing pasted yet.
- Each line is split on its first
=, else its first:, else a tab or a run of two or more spaces. Keys and values are trimmed, and withstripQuotes(on by default) a value wrapped in matching quotes loses them. - A line with none of those separators is skipped — here, the comment and the reminder, which is why five of seven lines become rows.
onPaste(event, items)receives the rows it made. - The new rows replace the row you pasted into when it was empty; otherwise they go in after it.
- It takes two lines or more: a single
DATABASE_URL=…lands in the key field as plain text. Only key fields listen —enablePaste={false}turns it off. - Pasted rows are not validated, and with
maxItemsset the list is cut to the limit afterwards — dropping rows from the end, including ones that were below the row you pasted into. - This demo keeps the default
trim. Put the caret at the end of a value and type a space: it stays in the field, while the stored value is trimmed — click away and the field shows the trimmed text too.
What the port guarantees
Key Value has no headless core — the port is the whole component — so these are behaviours the package's file owns outright, each one a place where it deliberately does not match upstream.
- Every ✕ is named.
labels.remove(key, row)reads Remove Region, or Remove row 3 while the key is empty. Upstream's icon-only button had no name at all, so a screen reader said button on every row. - …and is a full 36px square. It is
shrink-0: next to twow-fullfields, flex squeezed upstream'ssize="icon"button to about 18×36px. - The value field matches the key field. The package Textarea is
py-2— 38px tall on desktop, 42px on a phone — with--input-fillin dark and ashadow-xs, while Input is a flat 36px onbg-background. The value field is restyled here, not in either primitive:min-h-9withpy-1.5(on a desktop the 20px line sits 7px from the top, where Input centres its line),bg-backgroundin both themes, no shadow. It still grows with its content. Both fields carry the text size every field in the package shares since 3.13.4 —text-base md:text-sm, 16px on a phone and 14px frommdup; before that, the key field and this one weretext-smat every width. - Add moves focus into the new row. Upstream recorded the new row's id and never used it, so focus stayed on Add and a keyboard user had to Tab back up. Now the new row's key field takes focus — and only on Add: rows present at mount and rows created by a paste do not steal it.
- Removing a row hands focus on. A removed ✕ used to drop focus onto
<body>, sending a keyboard user back to the top of the page (WCAG 2.4.3). If the ✕ had focus, it moves to the next row's ✕, the previous one after the last row, or Add when no ✕ is left. - Ids without crypto.randomUUID. Upstream called it directly, and it only exists in a secure context — https or localhost. An admin console opened at
http://10.x.x.xthrew aTypeErrorwhile rendering the first row. The ids only key React elements and error messages, so there is now a fallback. - Strings you can change. Upstream hard-coded Add and Duplicate key; here every string is in
labelsand thekeyValuenamespace. - Valid ARIA, and red that reads.
aria-orientationis gone fromrole="list", where ARIA 1.2 does not allow it (axe:aria-allowed-attr). Error text istext-destructive-ink, the step for text on a page, instead of the fill steptext-destructive. - An untouched form posts its defaults. The hidden input used to be written only when the value changed, so a form submitted before anyone edited the list posted an empty string. Fixed in
2.1.0for every control that uses it. - Spaces can be typed. Upstream's
trim, on by default, trimmed every keystroke and wrote the result back into the field, so the space in Bearer token vanished before the next letter arrived. The field now shows exactly what is typed, and the trimmed text once it loses focus; everything the app sees —onValueChange, the hidden input, both validators, the duplicate check — gets the trimmed text, even when Enter submits the form right after a space. Fixed in3.0.1.
Language
Unlike Tags Input and Mention, Key Value puts the package's own words on screen: both placeholders, the Add button, the duplicate-key error — and, invisibly, the name of every ✕, which the preview reads back from the DOM. Switch the language, then type region into the empty row.
Demo language
Accessible names of the ✕ buttons, read from the DOM:
—
- Namespace
keyValue. Precedence, per string: thelabelsprop (and upstream'skeyPlaceholder/valuePlaceholderprops, which beat it) → the nearestComitorLocaleProvider→ English. removeis a function of the key and the 1-based row number, because both are interpolated and the word order differs by language. Functions cannot cross from a Server Component, so declare custom labels in a client component.DEFAULT_KEY_VALUE_LABELSis the English table, exported so you can spread it when you build a full set; the component no longer reads it directly. The Vietnamese table iscomitorMessagesVi.keyValue.- An error already on screen keeps the language it was produced in: the message is stored as text when the row is validated, and re-worded only the next time that row changes.
- Where the provider goes, and how
localeis chosen: Get started → Set the language.
'use client' // remove is a function — it cannot come from a Server Component
import { comitorMessagesVi, DEFAULT_KEY_VALUE_LABELS, KeyValue, type KeyValueLabels } from '@comitor/ui'
// One field: any subset of the keys, over the provider. Module-level, so the reference is stable.
const HEADER_LABELS: Partial<KeyValueLabels> = {
add: 'Add header',
remove: (key, row) => (key ? `Remove the ${key} header` : `Remove header row ${row}`),
}
<KeyValue labels={HEADER_LABELS}>…</KeyValue>
// The two built-in tables, to spread when you build a full set of your own:
DEFAULT_KEY_VALUE_LABELS // English — what the component shows with no provider
comitorMessagesVi.keyValue // VietnameseProps
KeyValue — plus every div attribute:
| Prop | Type | Default | Description |
|---|---|---|---|
value | KeyValueItemData[] | — | The pairs, controlled: { id, key, value }. Store exactly the array onValueChange gives you and derive anything else in render. |
defaultValue | KeyValueItemData[] | one empty row | Uncontrolled start. With neither value nor defaultValue the list starts with a single empty row; pass [] to start with none. |
onValueChange | (value: KeyValueItemData[]) => void | — | Called on every keystroke, add, removal and paste — and also when you change value from outside, which the component echoes back. Do not treat every call as a user edit. |
maxItems | number | — | Add is disabled at the limit, and a paste is cut off at it — rows past it are dropped from the end. |
minItems | number | 0 | Every ✕ is disabled while there are this many rows or fewer. Rows are not created to reach it — start with enough. |
labels | Partial<KeyValueLabels> | keyValue namespace | Any subset of keyPlaceholder, valuePlaceholder, add, remove and duplicateKey, laid over the provider's table. Keep the object stable. |
keyPlaceholder / valuePlaceholder | string | 'Key' / 'Value' | Upstream's prop names, kept. They beat labels.keyPlaceholder and labels.valuePlaceholder. |
onKeyValidate | (key: string, items: KeyValueItemData[]) => string | undefined | — | Return an error message for a row's key, shown by KeyValueError field='key'. Runs when that row's key or value changes; items is the whole list, this row included. |
onValueValidate | (value: string, key: string, items: KeyValueItemData[]) => string | undefined | — | The same for the value, shown by KeyValueError field='value'. |
allowDuplicateKeys | boolean | false | Turns off the built-in duplicate check — an exact, case-sensitive comparison that shows labels.duplicateKey on the row being edited. |
trim | boolean | true | Trims the key and the value on their way to the store — onValueChange, the form input, the validators and the duplicate check all get the trimmed text — while the field shows what is typed until it loses focus. Turn it off only to keep leading and trailing whitespace. Before 3.0.1 it also trimmed the field on every keystroke, so a typed space vanished. |
enablePaste | boolean | true | Pasting two or more lines into a key field splits them into rows. |
stripQuotes | boolean | true | On paste, removes a matching pair of single or double quotes around a value. |
onPaste | (event: ClipboardEvent, items: KeyValueItemData[]) => void | — | After a multi-line paste made rows; items are the new rows. |
onAdd / onRemove | (item: KeyValueItemData) => void | — | After Add appended its empty row, or after ✕ removed a row, with that row. |
disabled | boolean | false | Disables both fields on every row, every ✕ and Add. |
readOnly | boolean | false | Makes the fields read-only. Add and ✕ keep working — see Known limitations. |
required | boolean | false | Puts required on every key and value field, so the browser blocks submit until every row has both — an empty trailing row included. |
name | string | — | Inside a <form>, one hidden input posts the whole value as JSON, ids included. The visible fields have no name of their own. |
id | string | — | Put on the root element when you pass it. Either way it prefixes every error message's id — a useId() value stands in when it is unset. |
asChild | boolean | false | Render the root as your own element — a <section>, a <fieldset>. |
The parts. Each takes asChild and className as well:
| Prop | Type | Default | Description |
|---|---|---|---|
KeyValueList orientation | 'vertical' | 'horizontal' | 'vertical' | Rows stacked, or side by side. Only data-orientation reaches the DOM — aria-orientation is not allowed on role='list'. |
KeyValueKeyInput | input props | — | A package Input. Its own disabled, readOnly and required add to the root's. An aria-describedby you pass replaces the link to the error message. |
KeyValueValueInput maxRows | number | — | A package Textarea, restyled to match the key field. It grows with its content; maxRows caps it at exactly that many lines (each line is leading-5, 20px; padding and border on top, since 3.13.5) and then scrolls. It takes no rows prop. |
KeyValueRemove | Button props | variant='outline' size='icon' | Children default to an X icon; the accessible name is labels.remove(key, row). Disabled when the root is, or at minItems. |
KeyValueAdd | Button props | variant='outline' | Children default to a Plus icon and labels.add. Disabled when the root is, or at maxItems. Clicking moves focus to the new row's key field. |
KeyValueError field | 'key' | 'value' | — | Required. Renders a role='alert' span with the row's error for that field, or nothing. className merges, so text-xs replaces its text-sm. |
KeyValueItem | div props | — | role='listitem' in a flex row (items-start, gap-2). Wrap a field and its error in a column, as the demos do. |
The hook and the data type:
| Prop | Type | Default | Description |
|---|---|---|---|
useKeyValueStore(selector) | <T>(selector: (state) => T) => T | — | Reads the component's state from anything rendered inside KeyValue — outside it throws. state is { value, errors, focusedId }: errors is keyed by row id, focusedId is the row Add created last. |
KeyValueItemData | { id: string; key: string; value: string } | — | One pair. id is only for React keys and error ids — but it travels with the data, into onValueChange and into what a form posts. |
KeyValueLabels — English default, Vietnamese in the description:
| Prop | Type | Default | Description |
|---|---|---|---|
keyPlaceholder | string | 'Key' | Khoá in Vietnamese. |
valuePlaceholder | string | 'Value' | Giá trị in Vietnamese. |
add | string | 'Add' | Thêm in Vietnamese. The text of KeyValueAdd. |
remove | (key: string, index: number) => string | Remove {key} / Remove row {n} | FUNCTION — the ✕ button's name. Receives the row's key, which may be empty, and its 1-based row number: Xoá {key} or Xoá dòng {n} in Vietnamese. |
duplicateKey | string | 'Duplicate key' | Khoá bị trùng in Vietnamese. Shown by KeyValueError field='key' when two rows share a key. |
Known limitations
These are upstream behaviours the port keeps; the workaround is what an app can do today.
- Errors belong to the row you edited, and go stale. A duplicate flags the row you typed into, never the one it duplicates; fixing the clash by editing or removing the other row leaves the error until this row changes. Rows from a paste,
defaultValueorvalueare not checked at all. Validate the whole list when you save, as the webhook demo does. - readOnly stops typing, not editing. Add and ✕ keep working. For a read-only view, leave them out — or use
disabled. - required means every field. Each key and value field becomes required, so one empty trailing row blocks the submit. To require “at least one pair”, check
valueyourself. - The form value is internal-shaped. It is JSON of the array, ids included. Parse it on the server, or post an object you derive.
- Fields have no per-row names. No hook exposes the current row, so a field cannot be called “Value of Region”. Name the group, give the fields a generic
aria-label, and let the list semantics give position. - Paste rules are fixed. Two or more lines, key fields only, first
=then:then whitespace — soCookie: a=bsplits at the=. Pasted rows skip validation.
Related
- Tags Input — a flat list of values with no keys: labels, recipients.
- Form Section — the fields around it; put Key Value in a section of its own.
- Data Grid — when the pairs grow columns of their own and become a table.
- Patterns where it belongs: App (settings and admin pages), CRM (custom fields on a contact record) and Commerce (product attributes and order metadata).
Accessibility
Every part is a real control in the Tab order; the one behaviour you cannot see is multi-line paste.
| Key | When | What happens |
|---|---|---|
| Tab | Anywhere in the list | Key, value and ✕ on each row in turn, then Add. |
| EnterSpace | On Add | Appends an empty row and moves focus into its key field. |
| EnterSpace | On a ✕ | Removes the row. Focus moves to the next row's ✕ — the previous one after the last row, Add when none is left. |
| Enter | In a key field, inside a form | Submits the form, like any text input. Nothing else is bound to it. |
| Enter | In a value field | Starts a new line; the field grows. |
| CtrlV | Two or more lines, in a key field (⌘ V on a Mac) | Splits them into rows — see the paste demo for the format. |
- The list is
role="list"and each rowrole="listitem", so a screen reader announces how many pairs there are and which one you are in. The fields are the package's Input and Textarea; ✕ and Add are<button type="button">and never submit a form. - Name things. Left alone, the fields are named only by their placeholders — Key and Value on every row. Name the group (a
<fieldset>with a<legend>, orrole="group"witharia-labelledbyon the root) and give the fields anaria-label, as the webhook and CRM demos do. - Errors:
KeyValueErroris arole="alert"span, announced as it appears; the field getsaria-invalid— and with it the-inkred border — plusaria-describedbypointing at the message. Anaria-describedbyyou pass to a field replaces that link. - Focus is never dropped: Add moves it into the new row, and ✕ hands it to a neighbouring button.
- In a
<form>: one hidden input named bynameposts JSON of the array, and posts the starting value even if nobody touched the list.requiredapplies to every field,disabledto every control,readOnlyto the fields only. - Still yours: a group name and field names, a check of the whole list on save, leaving Add and ✕ out of a read-only view, saying how paste works if people are meant to use it, and the
langattribute on<html>.