ParameterGrid

Edit parameters by instrument, tier, or pair, with pending values, server-controlled enable checkboxes, and the time each row last changed.

import { useState } from "react"
import { createRowStore } from "@/lib/row-store"
import { ParameterGrid, type ParameterDef, type ParameterRow } from "@/components/ui/parameter-grid"

interface Sheet extends ParameterRow {
  skew: number | null
}

const parameters: ParameterDef<Sheet>[] = [
  { key: "skew", header: "Skew", accessor: (row) => row.skew, step: 0.25, min: -5, max: 5 },
]

export default function ParameterGridDemo() {
  const [store] = useState(() => {
    const store = createRowStore<Sheet>({ getRowId: (row) => row.id })
    store.applyDeltas({ upsert: [
      { id: "zn", name: "ZN", enabled: true, allowedActions: ["toggle", "edit"], skew: 0.5 },
      { id: "zb", name: "ZB", enabled: false, allowedActions: ["toggle", "edit"], skew: null },
    ] })
    return store
  })

  return (
    <div className="h-40 w-fit max-w-full">
      <ParameterGrid store={store} parameters={parameters} label="Pricing parameters" asOf={false} onEdit={(change) => {
        store.applyDeltas({ patch: [{ id: change.rowId, fields: { [change.key]: change.value, updatedAt: Date.now(), updatedBy: "you" } as Partial<Sheet> }] })
      }} />
    </div>
  )
}

Installation

pnpm dlx shadcn@latest add tradecn/ui/parameter-grid#v2.0.0
npx shadcn@latest add tradecn/ui/parameter-grid#v2.0.0
yarn dlx shadcn@latest add tradecn/ui/parameter-grid#v2.0.0
bunx --bun shadcn@latest add tradecn/ui/parameter-grid#v2.0.0

Usage

import { useState } from "react"
import { createRowStore } from "@/lib/row-store"
import { ParameterGrid, type ParameterDef, type ParameterRow } from "@/components/ui/parameter-grid"

interface Sheet extends ParameterRow {
  skew: number | null
}

const parameters: ParameterDef<Sheet>[] = [
  { key: "skew", header: "Skew", accessor: (row) => row.skew, step: 0.25, min: -5, max: 5 },
]

export default function ParameterGridDemo() {
  const [store] = useState(() => {
    const store = createRowStore<Sheet>({ getRowId: (row) => row.id })
    store.applyDeltas({ upsert: [
      { id: "zn", name: "ZN", enabled: true, allowedActions: ["toggle", "edit"], skew: 0.5 },
      { id: "zb", name: "ZB", enabled: false, allowedActions: ["toggle", "edit"], skew: null },
    ] })
    return store
  })

  return (
    <div className="h-40 w-fit max-w-full">
      <ParameterGrid store={store} parameters={parameters} label="Pricing parameters" asOf={false} onEdit={(change) => {
        store.applyDeltas({ patch: [{ id: change.rowId, fields: { [change.key]: change.value, updatedAt: Date.now(), updatedBy: "you" } as Partial<Sheet> }] })
      }} />
    </div>
  )
}

Seed the row store, define the parameter, and grant each row the actions it may take. This example acknowledges edits immediately by writing the requested value back to the store. Your application sends the request and applies the server's response.

Double-click a Skew value, or focus its cell and press Enter, to edit. Press Enter to commit or Escape to cancel. Up and Down step by 0.25; Shift uses ten steps. Values outside −5 through 5 stay in the editor with an error. Blank input becomes null. The enable checkbox also sends an edit request, and the Updated column records each accepted change.

The preview alignment controls keep an edge fixed while resizing columns and save that choice across examples.

Pending values and server replies

Commit a Width edit with Enter, or toggle ZN, then choose Receive reply. Until then, numeric edits stay pending and the checkbox keeps the store's value. This example handles one request at a time: it removes the row's allowed actions while a reply is pending and restores them after acceptance or refusal. You can inspect the pending state without a timer, then edit again once the reply arrives.

A Width over 8 is refused: the cell returns to the stored value and shows Over 8. Reopen it and commit a change to replace the error. Accepted requests update the row, its timestamp and author, and the as-of time. The changed-since marker identifies rows accepted during this session. The status reports whether a reply is pending, accepted, or refused.

import { useEffect, useRef, useState } from "react"
import { createRowStore } from "@/lib/row-store"
import type { EditChange } from "@/components/ui/data-grid"
import { ParameterGrid, type ParameterDef, type ParameterRow } from "@/components/ui/parameter-grid"

interface Sheet extends ParameterRow {
  width: number | null
}
interface Request {
  change: EditChange<Sheet>
  resolve: () => void
  reject: (error: Error) => void
}

const parameters: ParameterDef<Sheet>[] = [
  { key: "width", header: "Width", accessor: (row) => row.width, step: 0.5, min: 0.5, width: 120 },
]

export default function ParameterGridServerDemo() {
  const [openedAt] = useState(() => Date.now())
  const [store] = useState(() => {
    const store = createRowStore<Sheet>({ getRowId: (row) => row.id })
    store.applyDeltas({
      upsert: [{ id: "zn", name: "ZN", enabled: true, allowedActions: ["toggle", "edit"], width: 2, updatedAt: openedAt - 60_000, updatedBy: "desk" }],
      meta: { producedAt: openedAt },
    })
    return store
  })
  const request = useRef<Request | null>(null)
  const [pending, setPending] = useState(false)
  const [message, setMessage] = useState("Widths over 8 are refused.")

  useEffect(() => () => {
    request.current?.resolve()
    request.current = null
  }, [])

  function reply() {
    if (!request.current) return
    const { change, resolve, reject } = request.current
    request.current = null
    const allowedActions = change.row.allowedActions
    if (change.key === "width" && typeof change.value === "number" && change.value > 8) {
      store.applyDeltas({ patch: [{ id: change.rowId, fields: { allowedActions } }] })
      reject(new Error("Over 8"))
      setMessage("Refused: Over 8.")
    } else {
      const now = Date.now()
      store.applyDeltas({ patch: [{ id: change.rowId, fields: { [change.key]: change.value, allowedActions, updatedAt: now, updatedBy: "you" } as Partial<Sheet> }], meta: { producedAt: now } })
      resolve()
      setMessage("Accepted.")
    }
    setPending(false)
  }

  return (
    <>
      <div data-demo-controls className="flex flex-wrap items-center gap-3 text-xs lining-nums tabular-nums">
        <button type="button" className="rounded border border-border px-2 py-1 disabled:opacity-50" disabled={!pending} onClick={reply}>Receive reply</button>
        <span role="status">{pending ? "Waiting for reply." : message}</span>
      </div>
      <div className="h-44 w-fit max-w-full">
        <ParameterGrid store={store} parameters={parameters} label="Parameters awaiting server replies" changedSince={openedAt} onEdit={(change) => new Promise<void>((resolve, reject) => {
          request.current = { change, resolve, reject }
          store.applyDeltas({ patch: [{ id: change.rowId, fields: { allowedActions: [] } }] })
          setPending(true)
        })} />
      </div>
    </>
  )
}

Permissions and read-only parameters

ZN permits both edits and enable requests. ZB permits edits but cannot be enabled here. ZT permits neither. The Hedge ratio column is read-only on every row, independently of those permissions.

The permissions come from each row's allowedActions; readOnly comes from the parameter definition. Enabled state is separate from permission: ZB's Skew is editable even while its enable checkbox is off.

import { useState } from "react"
import { createRowStore } from "@/lib/row-store"
import { ParameterGrid, type ParameterDef, type ParameterRow } from "@/components/ui/parameter-grid"

interface Sheet extends ParameterRow {
  skew: number | null
  hedge: number
}

const parameters: ParameterDef<Sheet>[] = [
  { key: "skew", header: "Skew", accessor: (row) => row.skew, step: 0.25, min: -5, max: 5 },
  { key: "hedge", header: "Hedge ratio", accessor: (row) => row.hedge, decimals: 3, readOnly: true, width: 128 },
]

export default function ParameterGridPermissionsDemo() {
  const [store] = useState(() => {
    const store = createRowStore<Sheet>({ getRowId: (row) => row.id })
    store.applyDeltas({ upsert: [
      { id: "zn", name: "ZN · edit + toggle", enabled: true, allowedActions: ["toggle", "edit"], skew: 0.5, hedge: 0.75 },
      { id: "zb", name: "ZB · edit only", enabled: false, allowedActions: ["edit"], skew: null, hedge: 1 },
      { id: "zt", name: "ZT · locked", enabled: true, allowedActions: [], skew: 0, hedge: 0.25 },
    ] })
    return store
  })

  return (
    <div className="h-44 w-fit max-w-full">
      <ParameterGrid store={store} parameters={parameters} label="Parameter permissions" asOf={false} onEdit={(change) => {
        store.applyDeltas({ patch: [{ id: change.rowId, fields: { [change.key]: change.value, updatedAt: Date.now(), updatedBy: "you" } as Partial<Sheet> }] })
      }} />
    </div>
  )
}

API Reference

Uses the data grid's parameters preset: 24 px rows, single selection, ring flashes for numeric changes, and no reorder hold. The default columns are the name frozen on the left, the enable checkbox, one column per parameter, and the updated time. All are sortable.

Props

ParameterGridProps<T> requires T extends ParameterRow. It inherits DataGridProps<T> except preset, columns, label, and onEdit; the latter three have the definitions below. Other inherited props, including sorting, column state, and preset overrides such as rowHeight, pass to the grid.

Prop Type Default Purpose
store RowStore<T> Required Rows and batch metadata.
parameters readonly ParameterDef<T>[] Required Parameter columns in display order.
onEdit (change: EditChange<T>) => void | Promise<unknown> Required Send a value or enable request to the server.
columns ColumnDef<T>[] parameterColumns(options) Replace the generated column list.
label string "Parameters" Accessible name for the grid.
labels Partial<ParameterGridLabels & DataGridLabels> DEFAULT_PARAMETER_GRID_LABELS Override the labels listed below, and the grid's own, DataGrid's labels. A word given as undefined keeps its default.
time (ms: number) => string Local 24-hour HH:MM:SS Format updated and as-of times. A time a Date can't hold, such as NaN, prints – instead of calling it. The default reads the runtime's time zone, so server-rendered times use the server's.
changedSince number | null null Mark rows updated at or after this epoch time in milliseconds.
toggleAction string "toggle" Permission id for the enable checkbox.
editAction string "edit" Permission id for parameter edits.
asOf boolean true Show the as-of line above the grid.
getRowProps (row: T, id: RowId) => RowDecoration | undefined None Decorate rows; a supplied aria-description takes precedence over the changed label. Memoize it: the wrapper follows your function, so a new one re-renders every row and a memoized one is free.
className string None Classes on the outer tradecn-parameter-grid container, not the grid.

parameterColumns(options) returns ColumnDef<T>[] to add, drop, or reorder in your own list. Its ParameterColumnOptions<T> accepts parameters, labels, time, changedSince, toggleAction, and editAction with the defaults above. A supplied columns list replaces the generated columns and their formatting, permissions, and controls.

A row is named by its cells, so a screen reader can read the focused row again when its value changes, which on this sheet follows an edit or a server change. Pass getRowLabel, such as the parameter's name, for a name that holds, and the grid reads the row's cells once when focus rests on it instead; a toggle's result, or a refusal that arrives after the reading, then goes unsaid until focus moves.

The install shares byte-identical grid, store, and format files with data-grid, watchlist, blotter, and rfq-stack.

Rows

Extend ParameterRow with the fields your accessors read:

Field Type Required Purpose
id string Yes Row identity; use it in the store's getRowId.
name string Yes Frozen name column and checkbox label.
enabled boolean No Checkbox is checked only when this is true.
allowedActions readonly string[] No Allowed action ids; an absent or empty list permits nothing.
updatedAt number | null No Last change in milliseconds since the epoch.
updatedBy string | null No Name shown beside a present updated time.

The sheet is the server's

The grid reads your store and never writes it. A valid commit calls onEdit with { rowId, key, value, previous, row }, where previous is the current accessor value and row is the current store row. Committing that same value sends nothing. Return the request's promise from onEdit so a rejection reaches the cell instead of being lost to a store that never catches up. Settlement still matches by value, so two in-flight requests for the same value can settle each other: keep one request in flight per cell by withdrawing the action from allowedActions while a reply is pending, as the server demo does — that holds every path, where the pending checkbox's disabled state only blocks the pointer and the grid's Space, Enter, and F2 still send.

A parameter cell shows the committed text muted and italic while pending. Pending clears when the store's accessor value matches the committed value (Object.is), or when the returned promise resolves. Resolution displays the current store value, which may still be the old value. Returning nothing leaves the cell pending until the store matches.

A thrown error or rejection of a still-pending promise displays the current store value with the error message in the cell and its aria-description. Reopening clears the error while the editor is open; closing it untouched brings the error back, and committing a change replaces it. A pending parameter cell can also reopen, starting from its committed text.

Parameters

Each ParameterDef<T> defines one column:

Field Type Default Purpose
key string Required Column key, sent in onEdit.
header string Required Column heading and editor's accessible name.
accessor (row: T) => unknown Required Read the value for display, sorting, and acknowledgement.
width number 96 Column width in pixels.
format (value: unknown, row: T) => string Default formatter Text for the cell, editor, pending value, and range errors.
parse (text: string, row: T) => unknown Numeric parser Return a value or editProblem("…").
validate (value: unknown, row: T) => EditProblem | null | undefined None Return a problem to refuse a value after range checks.
step number | ((value: unknown, dir: 1 | -1, big: boolean, row: T) => unknown) None Change the editor value with Up or Down.
min / max number No bounds Inclusive limits for numeric values.
decimals number 2 Decimal places in the default formatter, a whole number from 0 to 20; another number is clamped to that range and one that isn't finite uses 2. Above zero, the default parser also reads one comma with no point after it, as in 1,234, as no number, whatever your own format prints.
numeric boolean true Right alignment, numeric font, decimal input mode, and directional flashes.
font "numeric" | "mono" "numeric" Font family for numeric cells; both use tabular figures.
readOnly boolean false Omit editing for this parameter on every row.

The default formatter prints finite numbers to decimals places, null, undefined, or nonfinite numbers as –, and other values as text. The default parser trims whitespace, accepts the typographic minus −, reads commas only between thousands, and reads a finite number; blank input becomes null. A parameter with decimals above zero prints a point after its groups, so there one comma with no point after it, as in 1,234, could be a decimal comma and is not a number; with decimals: 0 it reads as 1234. numeric: false changes presentation but keeps this parser. For text, supply a parser such as parse: (text) => text.trim().

min and max apply only to numeric values and use the column's formatter in error messages. validate runs after those checks. A numeric step moves by that amount, ten times with Shift, starting from zero for a nonnumeric value. A function receives direction 1 or -1 and big: true with Shift; it returns the next value. Stepping does not clamp to bounds; validation runs when committing. Without step, Up and Down leave the editor value unchanged.

parameterEdit(def, editAction, labels?) builds a CellEdit<T> for a custom column. editAction is required; labels defaults to DEFAULT_PARAMETER_GRID_LABELS and accepts a complete ParameterGridLabels object.

Editing

Enter, F2, a double click, or typing a non-space character opens an editable parameter cell. Opening selects its text unless you typed a character. Enter commits; Escape discards; Tab and Shift+Tab commit and open the next or previous editable text cell in the row, skipping the checkbox. At either end, focus returns to the grid.

Double-click plain cell content to open its editor. Controls and open editors keep their own pointer interactions; double-clicking an editor selects text without reopening its draft.

A parse or validation failure on commit keeps the editor open with an accessible error and sends nothing. Leaving the editor commits valid changes and discards invalid input; opened and left unchanged, it sends nothing, whatever rounding the format applies, so Tab can walk a row without writing it. Only one text editor opens at a time.

Up and Down step when configured. Ctrl, Cmd, or Alt with those arrows passes through to listeners above the grid. See the data-grid keyboard reference for navigation and column controls.

The enable box

The checkbox stays checked according to the store's enabled value. Pressing it calls onEdit with key: "enabled" and the opposite boolean. The box is held while pending, with aria-disabled, so a pressed box keeps focus and a further press on the box sends nothing; the acknowledgement and error rules above apply. The grid's own Space, Enter, and F2 on that cell are separate from the box and can send the same request while it is pending, once per press: a held key sends one request, so it can't toggle the value back once the server takes it.

The checkbox uses your installed checkbox component and stays outside the tab order. Its accessible name is labels.enable with the row's name, such as Enable ZN, in either state; its checked state says whether the parameter is on.

What the server allows

A row's allowedActions must include toggleAction to enable the checkbox and editAction to permit parameter edits, as in blotter and the tickets. allowsAction(row, action) checks membership and returns false for a missing list.

Cells blocked by permissions carry aria-readonly="true" when they have an edit definition. A parameter with readOnly: true has no edit definition and cannot open an editor, regardless of row permissions.

Changed since

Rows with a present updatedAt at or after changedSince show a dot beside their name and receive the Changed accessible description, unless getRowProps supplies one; the grid joins it with a row rule's words. A null or omitted changedSince disables the mark.

The updated column shows updatedAt and a nonempty updatedBy, with no flash: a time moves on without a direction, and the Changed mark says the row changed. A missing timestamp, or one a Date can't hold, displays –.

The as-of line shows the store's latest supplied producedAt, falling back to lastBatchAt when none has been supplied. Before either exists, it shows –, and a time a Date can't hold reads As of –. Both timestamps are milliseconds since the epoch and use the time formatter. The time shows once mounted, so a server's render, whose store stamps its own batches, matches the browser's first. Set asOf={false} to hide the line.

Labels

labels merges partial overrides into DEFAULT_PARAMETER_GRID_LABELS:

Label Default Substitutions
name / enabled / updated Name / On / Updated None
enable Enable {name} Row name; the enable box's name on or off, since the box says which
changed Changed None
asOf As of {time} Formatted batch time
notANumber Not a number. None
belowMin {n} is below the minimum of {min}. Formatted value and minimum
aboveMax {n} is above the maximum of {max}. Formatted value and maximum

Parameter headers, custom validation messages, and server errors come from your definitions and callbacks.

What it does not do

Your application owns values, persistence, and server requests. Multi-cell paste and row grouping are not supported; use one grid per book.

Tokens

The install adds the grid's tokens, up, down, and flat, and the soft variants of those and of stale and expiring, if you do not have them.

Built on shadcn's checkbox, context-menu, and dropdown-menu.

This page is docs/parameter-grid.md at v2.0.0.