Trang này chưa có bản tiếng Việt. Nội dung dưới đây là bản tiếng Anh.

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, KeyValueError and the hook useKeyValueStore from 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"), and useKeyValueStore is a client hook that throws when called outside KeyValue. The validators and labels.remove are 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.

anatomy.tsx
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.

Request headers

3 headers

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 onValueChange gives you as it is, and compute the headers object in render. The component echoes: when value changes from outside it calls onValueChange with that array, so a handler that stores a new array — a map, a filter — sets off another echo and never settles. It also means loading saved headers calls onValueChange: do not treat every call as an edit.
  • onKeyValidate(key, items) returns a message or undefined, and items includes 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 — and labels.duplicateKey gives 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 value gets 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 no name. 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 inside KeyValue. 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 with aria-describedby.
  • The group is named by the card title (role="group" with aria-labelledby on the root), and each field by its aria-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 with stripQuotes (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 maxItems set 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 two w-full fields, flex squeezed upstream's size="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-fill in dark and a shadow-xs, while Input is a flat 36px on bg-background. The value field is restyled here, not in either primitive: min-h-9 with py-1.5 (on a desktop the 20px line sits 7px from the top, where Input centres its line), bg-background in 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 from md up; before that, the key field and this one were text-sm at 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.x threw a TypeError while 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 labels and the keyValue namespace.
  • Valid ARIA, and red that reads. aria-orientation is gone from role="list", where ARIA 1.2 does not allow it (axe: aria-allowed-attr). Error text is text-destructive-ink, the step for text on a page, instead of the fill step text-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.0 for 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 in 3.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: the labels prop (and upstream's keyPlaceholder / valuePlaceholder props, which beat it) → the nearest ComitorLocaleProvider → English.
  • remove is 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_LABELS is 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 is comitorMessagesVi.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 locale is chosen: Get started → Set the language.
labels.tsx
'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 // Vietnamese

Props

KeyValue — plus every div attribute:

PropKiểuMặc địnhMô tả
valueKeyValueItemData[]—The pairs, controlled: { id, key, value }. Store exactly the array onValueChange gives you and derive anything else in render.
defaultValueKeyValueItemData[]one empty rowUncontrolled 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.
maxItemsnumber—Add is disabled at the limit, and a paste is cut off at it — rows past it are dropped from the end.
minItemsnumber0Every ✕ is disabled while there are this many rows or fewer. Rows are not created to reach it — start with enough.
labelsPartial<KeyValueLabels>keyValue namespaceAny subset of keyPlaceholder, valuePlaceholder, add, remove and duplicateKey, laid over the provider's table. Keep the object stable.
keyPlaceholder / valuePlaceholderstring'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'.
allowDuplicateKeysbooleanfalseTurns off the built-in duplicate check — an exact, case-sensitive comparison that shows labels.duplicateKey on the row being edited.
trimbooleantrueTrims 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.
enablePastebooleantruePasting two or more lines into a key field splits them into rows.
stripQuotesbooleantrueOn 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.
disabledbooleanfalseDisables both fields on every row, every ✕ and Add.
readOnlybooleanfalseMakes the fields read-only. Add and ✕ keep working — see Known limitations.
requiredbooleanfalsePuts required on every key and value field, so the browser blocks submit until every row has both — an empty trailing row included.
namestring—Inside a <form>, one hidden input posts the whole value as JSON, ids included. The visible fields have no name of their own.
idstring—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.
asChildbooleanfalseRender the root as your own element — a <section>, a <fieldset>.

The parts. Each takes asChild and className as well:

PropKiểuMặc địnhMô tả
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'.
KeyValueKeyInputinput 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 maxRowsnumber—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.
KeyValueRemoveButton propsvariant='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.
KeyValueAddButton propsvariant='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.
KeyValueItemdiv 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:

PropKiểuMặc địnhMô tả
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:

PropKiểuMặc địnhMô tả
keyPlaceholderstring'Key'Khoá in Vietnamese.
valuePlaceholderstring'Value'Giá trị in Vietnamese.
addstring'Add'Thêm in Vietnamese. The text of KeyValueAdd.
remove(key: string, index: number) => stringRemove {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.
duplicateKeystring'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, defaultValue or value are 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 value yourself.
  • 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 — so Cookie: a=b splits 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.

KeyWhenWhat happens
TabAnywhere in the listKey, value and ✕ on each row in turn, then Add.
EnterSpaceOn AddAppends an empty row and moves focus into its key field.
EnterSpaceOn a ✕Removes the row. Focus moves to the next row's ✕ — the previous one after the last row, Add when none is left.
EnterIn a key field, inside a formSubmits the form, like any text input. Nothing else is bound to it.
EnterIn a value fieldStarts a new line; the field grows.
CtrlVTwo 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 row role="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>, or role="group" with aria-labelledby on the root) and give the fields an aria-label, as the webhook and CRM demos do.
  • Errors: KeyValueError is a role="alert" span, announced as it appears; the field gets aria-invalid — and with it the -ink red border — plus aria-describedby pointing at the message. An aria-describedby you 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 by name posts JSON of the array, and posts the starting value even if nobody touched the list. required applies to every field, disabled to every control, readOnly to 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 lang attribute on <html>.